feat: 텔레그램 사용자용 대시보드 추가

- 텔레그램 환경 감지 시 대시보드 모드 자동 활성화
- 내 서버 목록/관리 (시작/중지/삭제)
- 사용량/비용 통계
- 알림 센터
- Mock 데이터로 UI 테스트 가능

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
kappa
2026-01-22 13:45:25 +09:00
parent abd2320b68
commit ea35848a97
2 changed files with 524 additions and 5 deletions

287
app.js
View File

@@ -66,6 +66,52 @@ const DEPLOY_TIMING = {
COMPLETE: 6000
};
// Mock 데이터 (API 없이 UI 테스트용)
const MOCK_SERVERS = [
{
id: 'srv-001',
name: 'production-web',
region: '🇯🇵 Tokyo',
ip: '45.12.89.101',
os: 'Debian 12',
plan: 'Pro',
status: 'running',
created_at: '2026-01-15',
vcpu: '2 Cores',
ram: '4 GB',
cost: 40700
},
{
id: 'srv-002',
name: 'dev-api',
region: '🇰🇷 Seoul',
ip: '45.12.89.102',
os: 'Ubuntu 24.04',
plan: 'Starter',
status: 'stopped',
created_at: '2026-01-20',
vcpu: '1 Core',
ram: '2 GB',
cost: 17000
}
];
const MOCK_STATS = {
totalCost: 57700,
totalServers: 2,
runningServers: 1,
costBreakdown: [
{ plan: 'Pro', count: 1, cost: 40700 },
{ plan: 'Starter', count: 1, cost: 17000 }
]
};
const MOCK_NOTIFICATIONS = [
{ id: 'n-001', type: 'info', title: '서버 생성 완료', message: 'production-web 서버가 Tokyo 리전에 생성되었습니다.', time: '10분 전', read: false },
{ id: 'n-002', type: 'warning', title: '결제 예정', message: '이번 달 결제가 3일 후 예정되어 있습니다. (₩57,700)', time: '1시간 전', read: false },
{ id: 'n-003', type: 'success', title: '시스템 업데이트 완료', message: 'dev-api 서버의 Ubuntu 패키지가 업데이트되었습니다.', time: '2일 전', read: true }
];
/**
* 가격 포맷팅 (한국 원화)
*/
@@ -84,14 +130,46 @@ function anvilApp() {
wizardStep: 0, // 0: region, 1: plan, 2: os, 3: payment, 4: confirm, 5+: deploying
deployStep: 0,
// 대시보드 상태
dashboardMode: false, // true면 대시보드, false면 랜딩
currentView: 'servers', // 'servers' | 'stats' | 'notifications'
// 텔레그램 연동
telegram: {
isAvailable: false, // 텔레그램 환경인지
user: null, // 사용자 정보
initData: null // 검증용 데이터
},
// 서버 구성
config: {
region: null,
plan: null,
os: null,
payment: null
payment: null,
telegram_id: null
},
// 서버 목록
servers: [],
loadingServers: false,
// 통계
stats: {
totalCost: 0,
totalServers: 0,
runningServers: 0,
costBreakdown: []
},
// 알림
notifications: [],
unreadCount: 0,
// API 상태
apiLoading: false,
apiError: null,
// 대화 메시지
messages: [],
@@ -108,6 +186,188 @@ function anvilApp() {
return ['Micro', 'Starter', 'Pro', 'Business'];
},
// 초기화 (텔레그램 연동 + 대시보드)
init() {
if (window.Telegram?.WebApp) {
const tg = window.Telegram.WebApp;
tg.ready();
tg.expand();
this.telegram.isAvailable = true;
this.telegram.user = tg.initDataUnsafe.user || null;
this.telegram.initData = tg.initData;
// 텔레그램 테마 색상 적용 (선택)
// document.body.style.backgroundColor = tg.backgroundColor;
console.log('[Telegram] Mini App initialized', {
user: this.telegram.user,
platform: tg.platform
});
// 텔레그램 환경이면 대시보드 모드 활성화
// (user가 없어도 텔레그램에서 열린 경우 대시보드 표시)
this.dashboardMode = true;
this.loadDashboard();
console.log('[Telegram] Dashboard mode activated', {
isAvailable: this.telegram.isAvailable,
hasUser: !!this.telegram.user
});
} else {
console.log('[Telegram] Running in web browser mode');
}
},
// 대시보드 초기 로드
async loadDashboard() {
console.log('[Dashboard] Loading dashboard data...');
await Promise.all([
this.fetchServers(),
this.fetchStats(),
this.fetchNotifications()
]);
},
// 서버 목록 조회
async fetchServers() {
this.loadingServers = true;
this.apiError = null;
try {
// TODO: 실제 API 호출로 교체
// const response = await fetch('/api/servers', {
// headers: { 'X-Telegram-Init-Data': this.telegram.initData }
// });
// this.servers = await response.json();
// Mock 데이터 사용
await new Promise(resolve => setTimeout(resolve, 500));
this.servers = [...MOCK_SERVERS];
console.log('[Dashboard] Servers loaded:', this.servers.length);
} catch (error) {
console.error('[Dashboard] Failed to fetch servers:', error);
this.apiError = '서버 목록을 불러오는데 실패했습니다.';
} finally {
this.loadingServers = false;
}
},
// 통계 조회
async fetchStats() {
try {
// TODO: 실제 API 호출로 교체
await new Promise(resolve => setTimeout(resolve, 300));
this.stats = { ...MOCK_STATS };
console.log('[Dashboard] Stats loaded:', this.stats);
} catch (error) {
console.error('[Dashboard] Failed to fetch stats:', error);
}
},
// 알림 조회
async fetchNotifications() {
try {
// TODO: 실제 API 호출로 교체
await new Promise(resolve => setTimeout(resolve, 400));
this.notifications = [...MOCK_NOTIFICATIONS];
this.unreadCount = this.notifications.filter(n => !n.read).length;
console.log('[Dashboard] Notifications loaded:', this.notifications.length);
} catch (error) {
console.error('[Dashboard] Failed to fetch notifications:', error);
}
},
// 서버 시작
async startServer(serverId) {
const server = this.servers.find(s => s.id === serverId);
if (!server) return;
console.log('[Dashboard] Starting server:', serverId);
this.apiLoading = true;
try {
// TODO: 실제 API 호출로 교체
await new Promise(resolve => setTimeout(resolve, 1000));
server.status = 'running';
this.stats.runningServers++;
console.log('[Dashboard] Server started:', serverId);
} catch (error) {
console.error('[Dashboard] Failed to start server:', error);
alert('서버를 시작하는데 실패했습니다.');
} finally {
this.apiLoading = false;
}
},
// 서버 중지
async stopServer(serverId) {
const server = this.servers.find(s => s.id === serverId);
if (!server) return;
console.log('[Dashboard] Stopping server:', serverId);
this.apiLoading = true;
try {
// TODO: 실제 API 호출로 교체
await new Promise(resolve => setTimeout(resolve, 1000));
server.status = 'stopped';
this.stats.runningServers--;
console.log('[Dashboard] Server stopped:', serverId);
} catch (error) {
console.error('[Dashboard] Failed to stop server:', error);
alert('서버를 중지하는데 실패했습니다.');
} finally {
this.apiLoading = false;
}
},
// 서버 삭제
async deleteServer(serverId) {
if (!confirm('정말로 이 서버를 삭제하시겠습니까?')) return;
console.log('[Dashboard] Deleting server:', serverId);
this.apiLoading = true;
try {
// TODO: 실제 API 호출로 교체
await new Promise(resolve => setTimeout(resolve, 800));
const serverIndex = this.servers.findIndex(s => s.id === serverId);
if (serverIndex !== -1) {
const deletedServer = this.servers[serverIndex];
this.servers.splice(serverIndex, 1);
// 통계 업데이트
this.stats.totalServers--;
this.stats.totalCost -= deletedServer.cost;
if (deletedServer.status === 'running') {
this.stats.runningServers--;
}
console.log('[Dashboard] Server deleted:', serverId);
}
} catch (error) {
console.error('[Dashboard] Failed to delete server:', error);
alert('서버를 삭제하는데 실패했습니다.');
} finally {
this.apiLoading = false;
}
},
// 모든 알림 읽음 처리
markAllRead() {
this.notifications.forEach(n => n.read = true);
this.unreadCount = 0;
console.log('[Dashboard] All notifications marked as read');
},
// 뷰 전환
switchView(view) {
this.currentView = view;
console.log('[Dashboard] View switched to:', view);
},
// 가격 조회
getPrice(plan) {
const prices = LAUNCHER_PRICES[plan];
@@ -192,6 +452,11 @@ function anvilApp() {
// 서버 배포 시작
startLaunch() {
// 텔레그램 사용자 ID 추가
if (this.telegram.user) {
this.config.telegram_id = this.telegram.user.id;
}
this.launching = true;
this.wizardStep = 5;
this.deployStep = 1;
@@ -201,6 +466,9 @@ function anvilApp() {
`📦 ${this.config.os} 이미지 준비 중...`
];
// 디버그: config 출력
console.log('[Server Launch] Config:', this.config);
setTimeout(() => {
this.logs.push('✅ 이미지 준비 완료');
this.logs.push('🔧 컨테이너 인스턴스 생성 중...');
@@ -224,13 +492,26 @@ function anvilApp() {
this.launching = false;
this.deployStep = 5;
this.logs.push('🎉 서버가 활성화되었습니다!');
// 대시보드 모드에서는 서버 목록 새로고침
if (this.dashboardMode) {
setTimeout(() => {
this.fetchServers();
this.fetchStats();
}, 1000);
}
}, DEPLOY_TIMING.COMPLETE);
},
// 런처 열기
// 런처 열기 (대시보드용 추가 기능)
openLauncher() {
this.launcherOpen = true;
this.messages = [{ type: 'bot', text: '어느 리전에 서버를 생성할까요?', time: new Date() }];
// 대시보드 모드에서는 서버 생성 후 목록 새로고침
if (this.dashboardMode) {
console.log('[Dashboard] Launcher opened from dashboard');
}
},
// 런처 초기화
@@ -239,7 +520,7 @@ function anvilApp() {
this.launching = false;
this.wizardStep = 0;
this.deployStep = 0;
this.config = { region: null, plan: null, os: null, payment: null };
this.config = { region: null, plan: null, os: null, payment: null, telegram_id: null };
this.messages = [];
this.logs = [];
},

View File

@@ -3,7 +3,7 @@
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self' https://cdn.jsdelivr.net 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; font-src 'self'; img-src 'self' data:; connect-src 'self';">
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self' https://cdn.jsdelivr.net https://telegram.org 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; font-src 'self'; img-src 'self' data:; connect-src 'self';">
<title>Anvil Hosting - 개발자를 위한 컨테이너 클라우드</title>
<!-- SEO Meta Tags -->
@@ -31,6 +31,9 @@
<!-- Favicon -->
<link rel="icon" type="image/svg+xml" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><rect fill='%230ea5e9' rx='20' width='100' height='100'/><text x='50' y='70' font-size='60' text-anchor='middle' fill='white' font-family='sans-serif' font-weight='bold'>A</text></svg>">
<!-- Telegram Web App SDK -->
<script src="https://telegram.org/js/telegram-web-app.js"></script>
<!-- Tailwind CSS (Production Build) -->
<link rel="stylesheet" href="style.css">
@@ -61,7 +64,7 @@
<!-- Alpine.js (pinned version with SRI) -->
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.14.3/dist/cdn.min.js" integrity="sha384-iZD2X8o1Zdq0HR5H/7oa8W30WS4No+zWCKUPD7fHRay9I1Gf+C4F8sVmw7zec1wW" crossorigin="anonymous"></script>
</head>
<body x-data="anvilApp()" @keydown.window="handleKeydown($event)" class="font-sans antialiased selection:bg-brand-500/30 mesh-bg">
<body x-data="anvilApp()" x-init="init()" @keydown.window="handleKeydown($event)" class="font-sans antialiased selection:bg-brand-500/30 mesh-bg">
<!-- Skip Link for Keyboard Users -->
<a href="#main-content" class="skip-link">메인 콘텐츠로 건너뛰기</a>
@@ -79,6 +82,19 @@
<a href="#pricing" class="hover:text-white transition">요금제</a>
<a href="#automation" class="hover:text-white transition">자동화</a>
</div>
<!-- Telegram User Info -->
<template x-if="telegram.isAvailable && telegram.user">
<div class="flex items-center gap-2 px-3 py-1.5 bg-brand-500/10 border border-brand-500/20 rounded-full text-xs text-slate-300">
<span class="text-brand-400">👤</span>
<span x-text="`@${telegram.user.username || telegram.user.first_name} (ID: ${telegram.user.id})`"></span>
</div>
</template>
<template x-if="!telegram.isAvailable">
<div class="flex items-center gap-2 px-3 py-1.5 bg-slate-800/50 border border-slate-700/50 rounded-full text-xs text-slate-400">
<span>🌐</span>
<span>웹 브라우저</span>
</div>
</template>
</div>
<a href="https://t.me/AnvilForgeBot" target="_blank" rel="noopener noreferrer" aria-label="Console 시작 - 텔레그램 봇으로 이동" class="px-4 py-2 bg-brand-700 hover:bg-brand-500 text-white text-sm font-bold rounded-lg transition shadow-lg shadow-brand-500/20 flex items-center gap-2">
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 24 24" aria-hidden="true"><path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm4.64 6.8c-.15 1.58-.8 5.42-1.13 7.19-.14.75-.42 1-.68 1.03-.58.05-1.02-.38-1.58-.75-.88-.58-1.38-.94-2.23-1.5-.99-.65-.35-1.01.22-1.59.15-.15 2.71-2.48 2.76-2.69a.2.2 0 00-.05-.18c-.06-.05-.14-.03-.21-.02-.09.02-1.49.95-4.22 2.79-.4.27-.76.41-1.08.4-.36-.01-1.04-.2-1.55-.37-.62-.2-1.12-.31-1.15-.63.03-.37.59-.75 1.5-.95 6.07-2.64 10.12-4.38 12.15-5.21 2.91-1.2 3.51-1.4 3.91-1.41.09 0 .28.02.41.09.11.06.23.14.3.24.08.12.12.33.09.57z"/></svg>
@@ -90,6 +106,10 @@
<!-- Main Content -->
<main id="main-content">
<!-- Landing Page (Web Users) -->
<template x-if="!dashboardMode">
<div>
<!-- Hero Section -->
<section class="relative pt-24 pb-20 overflow-hidden grid-bg gradient-bg noise-bg">
<!-- Animated Blobs -->
@@ -712,6 +732,224 @@
</div>
</section>
</div>
</template>
<!-- End Landing Page -->
<!-- Dashboard (Telegram Users) -->
<template x-if="dashboardMode">
<div class="max-w-7xl mx-auto px-6 py-24">
<!-- Dashboard Header -->
<div class="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4 mb-8">
<div>
<h1 class="text-3xl font-bold">
안녕하세요<template x-if="telegram.user">, <span class="text-brand-400" x-text="telegram.user?.first_name || telegram.user?.username"></span></template><template x-if="!telegram.user">!</template>
</h1>
<p class="text-slate-400 mt-1">서버를 관리하고 사용량을 확인하세요</p>
</div>
<button @click="openLauncher()" class="px-6 py-3 bg-gradient-to-r from-brand-600 to-brand-500 text-white font-bold rounded-xl hover:shadow-lg hover:shadow-brand-500/30 transition-all flex items-center gap-2">
<span>+</span> 새 서버 생성
</button>
</div>
<!-- Tab Navigation -->
<div class="flex gap-1 mb-8 bg-slate-800/50 p-1 rounded-xl w-fit">
<button @click="switchView('servers')"
:class="currentView === 'servers' ? 'bg-brand-500 text-white shadow-lg' : 'text-slate-400 hover:text-white hover:bg-slate-700/50'"
class="px-5 py-2.5 rounded-lg font-medium transition-all">
내 서버 (<span x-text="servers.length"></span>)
</button>
<button @click="switchView('stats')"
:class="currentView === 'stats' ? 'bg-brand-500 text-white shadow-lg' : 'text-slate-400 hover:text-white hover:bg-slate-700/50'"
class="px-5 py-2.5 rounded-lg font-medium transition-all">
사용량/비용
</button>
<button @click="switchView('notifications')"
:class="currentView === 'notifications' ? 'bg-brand-500 text-white shadow-lg' : 'text-slate-400 hover:text-white hover:bg-slate-700/50'"
class="px-5 py-2.5 rounded-lg font-medium transition-all relative">
알림
<span x-show="unreadCount > 0"
x-transition
class="absolute -top-1 -right-1 bg-red-500 text-white text-xs rounded-full w-5 h-5 flex items-center justify-center"
x-text="unreadCount"></span>
</button>
</div>
<!-- Server List View -->
<div x-show="currentView === 'servers'" x-transition:enter="transition ease-out duration-200" x-transition:enter-start="opacity-0" x-transition:enter-end="opacity-100">
<!-- Loading State -->
<div x-show="loadingServers" class="text-center py-16">
<div class="animate-spin w-12 h-12 border-4 border-brand-500 border-t-transparent rounded-full mx-auto"></div>
<p class="text-slate-400 mt-4">서버 목록을 불러오는 중...</p>
</div>
<!-- Empty State -->
<div x-show="!loadingServers && servers.length === 0" class="glass-card p-16 rounded-2xl text-center">
<div class="text-6xl mb-4">🚀</div>
<h3 class="text-xl font-bold mb-2">아직 서버가 없습니다</h3>
<p class="text-slate-400 mb-6">첫 번째 서버를 생성해보세요!</p>
<button @click="openLauncher()" class="px-6 py-3 bg-brand-500 hover:bg-brand-400 text-white font-bold rounded-xl transition">
서버 생성하기
</button>
</div>
<!-- Server Cards Grid -->
<div x-show="!loadingServers && servers.length > 0" class="grid md:grid-cols-2 lg:grid-cols-3 gap-6">
<template x-for="server in servers" :key="server.id">
<div class="glass-card p-6 rounded-2xl hover:border-brand-500/50 transition-all duration-300 group">
<!-- Server Header -->
<div class="flex justify-between items-start mb-4">
<div>
<h3 class="font-bold text-lg group-hover:text-brand-400 transition" x-text="server.name"></h3>
<p class="text-sm text-slate-400" x-text="server.region"></p>
</div>
<span class="px-3 py-1 rounded-full text-xs font-bold"
:class="server.status === 'running' ? 'bg-green-500/20 text-green-400' : 'bg-slate-700 text-slate-400'"
x-text="server.status === 'running' ? '🟢 실행 중' : '⚪ 중지됨'"></span>
</div>
<!-- Server Info -->
<div class="space-y-2 text-sm mb-4">
<div class="flex justify-between">
<span class="text-slate-400">IP</span>
<code class="text-brand-400 bg-black/30 px-2 py-0.5 rounded text-xs font-mono" x-text="server.ip"></code>
</div>
<div class="flex justify-between">
<span class="text-slate-400">스펙</span>
<span class="text-white" x-text="`${server.vcpu} vCPU / ${server.ram}`"></span>
</div>
<div class="flex justify-between">
<span class="text-slate-400">OS</span>
<span class="text-white" x-text="server.os"></span>
</div>
<div class="flex justify-between">
<span class="text-slate-400">비용</span>
<span class="text-brand-400 font-bold" x-text="`₩${server.cost.toLocaleString()}/월`"></span>
</div>
</div>
<!-- Action Buttons -->
<div class="flex gap-2 pt-4 border-t border-slate-700/50">
<button x-show="server.status === 'stopped'"
@click="startServer(server.id)"
:disabled="apiLoading"
class="flex-1 py-2 bg-green-500/20 hover:bg-green-500/30 text-green-400 rounded-lg transition disabled:opacity-50 disabled:cursor-not-allowed">
▶ 시작
</button>
<button x-show="server.status === 'running'"
@click="stopServer(server.id)"
:disabled="apiLoading"
class="flex-1 py-2 bg-yellow-500/20 hover:bg-yellow-500/30 text-yellow-400 rounded-lg transition disabled:opacity-50 disabled:cursor-not-allowed">
⏸ 중지
</button>
<button @click="deleteServer(server.id)"
:disabled="apiLoading"
class="px-4 py-2 bg-red-500/20 hover:bg-red-500/30 text-red-400 rounded-lg transition disabled:opacity-50 disabled:cursor-not-allowed">
🗑
</button>
</div>
</div>
</template>
</div>
</div>
<!-- Stats View -->
<div x-show="currentView === 'stats'" x-transition:enter="transition ease-out duration-200" x-transition:enter-start="opacity-0" x-transition:enter-end="opacity-100" class="space-y-6">
<!-- Summary Cards -->
<div class="grid md:grid-cols-3 gap-6">
<div class="glass-card p-6 rounded-2xl">
<div class="text-slate-400 text-sm mb-2">총 서버 수</div>
<div class="text-3xl font-bold" x-text="stats.totalServers"></div>
<div class="text-xs text-green-400 mt-1">
<span x-text="stats.runningServers"></span>개 실행 중
</div>
</div>
<div class="glass-card p-6 rounded-2xl">
<div class="text-slate-400 text-sm mb-2">이번 달 예상 비용</div>
<div class="text-3xl font-bold text-brand-400" x-text="`₩${stats.totalCost.toLocaleString()}`"></div>
<div class="text-xs text-slate-500 mt-1">VAT 별도</div>
</div>
<div class="glass-card p-6 rounded-2xl">
<div class="text-slate-400 text-sm mb-2">평균 서버당 비용</div>
<div class="text-3xl font-bold" x-text="stats.totalServers > 0 ? `₩${Math.round(stats.totalCost / stats.totalServers).toLocaleString()}` : '₩0'"></div>
<div class="text-xs text-slate-500 mt-1">월 기준</div>
</div>
</div>
<!-- Cost Breakdown -->
<div class="glass-card p-6 rounded-2xl">
<h3 class="font-bold text-lg mb-4">서버별 비용 상세</h3>
<div x-show="stats.costBreakdown.length === 0" class="text-center py-8 text-slate-400">
서버가 없습니다
</div>
<div x-show="stats.costBreakdown.length > 0" class="space-y-3">
<template x-for="item in stats.costBreakdown" :key="item.name">
<div class="flex justify-between items-center py-3 border-b border-slate-700/50 last:border-0">
<div>
<div class="font-semibold" x-text="item.name"></div>
<div class="text-xs text-slate-400" x-text="`${item.region} · ${item.plan}`"></div>
</div>
<div class="text-brand-400 font-bold" x-text="`₩${item.cost.toLocaleString()}`"></div>
</div>
</template>
</div>
</div>
<!-- Monthly Usage Chart Placeholder -->
<div class="glass-card p-6 rounded-2xl">
<h3 class="font-bold text-lg mb-4">월별 비용 추이</h3>
<div class="h-48 flex items-center justify-center text-slate-500 border border-dashed border-slate-700 rounded-xl">
📊 차트 영역 (추후 구현 예정)
</div>
</div>
</div>
<!-- Notifications View -->
<div x-show="currentView === 'notifications'" x-transition:enter="transition ease-out duration-200" x-transition:enter-start="opacity-0" x-transition:enter-end="opacity-100" class="space-y-4">
<div class="flex justify-between items-center">
<h2 class="text-xl font-bold">알림</h2>
<button x-show="unreadCount > 0" @click="markAllRead()" class="text-sm text-brand-400 hover:text-brand-300 hover:underline transition">
모두 읽음 처리
</button>
</div>
<!-- Empty State -->
<div x-show="notifications.length === 0" class="glass-card p-16 rounded-2xl text-center">
<div class="text-6xl mb-4">🔔</div>
<h3 class="text-xl font-bold mb-2">새로운 알림이 없습니다</h3>
<p class="text-slate-400">서버 상태 변경 시 알림을 받을 수 있습니다</p>
</div>
<!-- Notification List -->
<div x-show="notifications.length > 0" class="space-y-3">
<template x-for="notif in notifications" :key="notif.id">
<div class="glass-card p-4 rounded-xl flex gap-4 transition-all duration-300"
:class="!notif.read ? 'border-brand-500/30 bg-brand-500/5' : 'hover:border-slate-600'">
<div class="text-2xl" x-text="notif.icon"></div>
<div class="flex-1 min-w-0">
<div class="font-semibold" x-text="notif.title"></div>
<div class="text-sm text-slate-400 truncate" x-text="notif.message"></div>
<div class="text-xs text-slate-500 mt-1" x-text="notif.time"></div>
</div>
<div x-show="!notif.read" class="w-2 h-2 rounded-full bg-brand-500 mt-2 flex-shrink-0"></div>
</div>
</template>
</div>
</div>
</div>
</template>
<!-- End Dashboard -->
</main>
<!-- End Main Content -->