feat: add WebSocket connection handling and UI styles

- Implemented WebSocket connection management in `connection.rs` with automatic reconnection and event handling.
- Created `handlers.rs` to define WebSocket event and status enums.
- Added global CSS reset styles in `reset.css`.
- Introduced design tokens in `tokens.css` for consistent theming.
- Developed UI component styles in `ui.css`, including buttons, cards, badges, and modals.
- Added utility classes in `utilities.css` for layout, spacing, and typography.
This commit is contained in:
asepharyana
2026-07-05 21:18:59 +07:00
parent e7d4a45391
commit 0ba4228042
31 changed files with 2937 additions and 1182 deletions
+5 -6
View File
@@ -3,19 +3,18 @@
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="theme-color" content="#23a1eb" />
<meta name="theme-color" content="#3b82f6" />
<title>IMPHNEN -- Discord Moderation</title>
<link data-trunk rel="rust" data-crate="frontend" data-wasm="frontend.wasm" />
<link data-trunk rel="css" href="src/app.css" />
<link data-trunk rel="css" href="src/styles/main.css" />
<link data-trunk rel="copy-dir" href="public/" />
<!-- Poppins font -->
<!-- Inter + JetBrains Mono fonts -->
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Poppins:wght@300;400;500;600;700;800&display=swap" rel="stylesheet" />
<!-- Preload WASM (Trunk inlines this, but just in case) -->
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&family=JetBrains+Mono:wght@400;500;600;700&display=swap" rel="stylesheet" />
<!-- Preload WASM -->
<link rel="preload" href="/frontend.wasm" as="fetch" crossorigin="anonymous" />
</head>
<body>
<!-- Leptos CSR mounts to document.body by default -->
</body>
</html>
-852
View File
@@ -1,852 +0,0 @@
/* ── IMPHNEN Design System v3 — Premium Glass ──────────── *
* Brand: Approachable Modernism × Glass Aesthetic *
* Primary: #23a1eb → Gradient: #23a1eb → #7b86f5 *
* Font: Poppins (300800) *
* Rounded: sm=6px, md=10px, lg=16px, xl=24px, full=9999px*
* Spacing unit: 8px *
* ─────────────────────────────────────────────────────── */
/* ── Design Tokens ────────────────────────────────────── */
:root {
--color-primary: #23a1eb;
--color-primary-hover: #1a8fd9;
--color-primary-active: #0877c1;
--color-primary-muted: rgba(35, 161, 235, 0.12);
--gradient-primary: linear-gradient(135deg, #23a1eb 0%, #7b86f5 100%);
--gradient-primary-hover: linear-gradient(135deg, #1a8fd9 0%, #6b76f0 100%);
--gradient-brand: linear-gradient(135deg, #23a1eb 0%, #5865f2 50%, #7b86f5 100%);
--color-secondary: #1877f2;
--color-secondary-muted: rgba(24, 119, 242, 0.12);
--color-tertiary: #5865f2;
--color-accent: #7b86f5;
--color-accent-muted: rgba(123, 134, 245, 0.12);
--surface-base: #f4f6fb;
--surface-raised: #ffffff;
--surface-overlay: #eeeff4;
--surface-container-low: #e8eaf0;
--surface-container: #dde0e8;
--surface-border: rgba(0, 0, 0, 0.06);
--surface-hover: rgba(35, 161, 235, 0.05);
--surface-glass: rgba(255, 255, 255, 0.72);
--surface-glass-heavy: rgba(255, 255, 255, 0.88);
--text-primary: #0f172a;
--text-secondary: #475569;
--text-tertiary: #94a3b8;
--text-inverse: #ffffff;
--color-success: #10b981;
--color-warning: #f59e0b;
--color-error: #e4405f;
--color-info: #23a1eb;
--color-destructive: #e4405f;
--color-ai-flagged: #e4405f;
--color-ai-clean: #10b981;
--color-ai-warn: #f59e0b;
--color-ai-pending: #94a3b8;
--color-ai-processing: #23a1eb;
--color-ai-error: #dc2626;
--color-ai-deleted: #6b7280;
--shadow-sm: 0 1px 3px rgba(0, 0, 0, 0.04), 0 1px 2px rgba(0, 0, 0, 0.03);
--shadow-md: 0 4px 12px rgba(0, 0, 0, 0.06), 0 2px 4px rgba(0, 0, 0, 0.04);
--shadow-lg: 0 12px 40px rgba(0, 0, 0, 0.08), 0 4px 12px rgba(0, 0, 0, 0.04);
--shadow-xl: 0 24px 60px rgba(0, 0, 0, 0.10), 0 8px 24px rgba(0, 0, 0, 0.06);
--glow-primary: 0 0 24px rgba(35, 161, 235, 0.20);
--glow-error: 0 0 24px rgba(228, 64, 95, 0.18);
--glow-success: 0 0 24px rgba(16, 185, 129, 0.18);
--glow-accent: 0 0 24px rgba(123, 134, 245, 0.20);
--radius-sm: 6px;
--radius-md: 10px;
--radius-lg: 16px;
--radius-xl: 24px;
--radius-full: 9999px;
--space-0: 0px;
--space-1: 4px;
--space-2: 8px;
--space-3: 12px;
--space-4: 16px;
--space-5: 20px;
--space-6: 24px;
--space-8: 32px;
--space-10: 40px;
--space-12: 48px;
--space-16: 64px;
--sidebar-width: 240px;
--sidebar-collapsed-width: 64px;
--header-height: 60px;
--transition-fast: 150ms cubic-bezier(0.4, 0, 0.2, 1);
--transition-normal: 300ms cubic-bezier(0.4, 0, 0.2, 1);
--transition-slow: 500ms cubic-bezier(0.4, 0, 0.2, 1);
--transition-bounce: 400ms cubic-bezier(0.34, 1.56, 0.64, 1);
--z-sidebar: 30;
--z-header: 40;
--z-overlay: 50;
--z-modal: 60;
--z-toast: 70;
}
[data-theme="dark"] {
--color-primary: #54a2ff;
--color-primary-hover: #7ab7ff;
--color-primary-active: #3d8eff;
--color-primary-muted: rgba(84, 162, 255, 0.15);
--gradient-primary: linear-gradient(135deg, #54a2ff 0%, #9da6ff 100%);
--gradient-brand: linear-gradient(135deg, #54a2ff 0%, #7b86f5 50%, #9da6ff 100%);
--color-secondary: #4a8cff;
--color-secondary-muted: rgba(74, 140, 255, 0.15);
--color-tertiary: #7b86f5;
--color-accent: #9da6ff;
--color-accent-muted: rgba(157, 166, 255, 0.15);
--surface-base: #080c14;
--surface-raised: #0f1522;
--surface-overlay: #161e30;
--surface-container-low: #1c2540;
--surface-container: #253050;
--surface-border: rgba(255, 255, 255, 0.06);
--surface-hover: rgba(84, 162, 255, 0.06);
--surface-glass: rgba(8, 12, 20, 0.78);
--surface-glass-heavy: rgba(8, 12, 20, 0.90);
--text-primary: #e2e8f0;
--text-secondary: #8892a8;
--text-tertiary: #4a5470;
--text-inverse: #080c14;
--shadow-sm: 0 1px 3px rgba(0, 0, 0, 0.30);
--shadow-md: 0 4px 12px rgba(0, 0, 0, 0.35);
--shadow-lg: 0 12px 40px rgba(0, 0, 0, 0.40);
--shadow-xl: 0 24px 60px rgba(0, 0, 0, 0.45);
--glow-primary: 0 0 30px rgba(84, 162, 255, 0.20);
--glow-error: 0 0 30px rgba(240, 82, 115, 0.18);
--glow-success: 0 0 30px rgba(16, 185, 129, 0.18);
--glow-accent: 0 0 30px rgba(157, 166, 255, 0.20);
--color-error: #f05273;
--color-destructive: #f05273;
}
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
html { font-size: 16px; -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; }
body { font-family: 'Poppins', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; line-height: 1.6; min-height: 100vh; overflow-x: hidden; background: var(--surface-base); }
a { color: var(--color-primary); text-decoration: none; transition: color var(--transition-fast); }
a:hover { color: var(--color-primary-hover); }
img { max-width: 100%; height: auto; }
svg { display: inline-block; vertical-align: middle; }
::-webkit-scrollbar { width: 6px; height: 6px; }
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb { background: var(--color-primary); border-radius: var(--radius-full); }
::selection { background: rgba(35, 161, 235, 0.25); color: var(--text-primary); }
[data-theme="dark"] ::selection { background: rgba(84, 162, 255, 0.30); }
/* ================================================================
UTILITY CLASSES
================================================================ */
.flex { display: flex; }
.inline-flex { display: inline-flex; }
.grid { display: grid; }
.block { display: block; }
.hidden { display: none; }
.flex-col { flex-direction: column; }
.flex-row { flex-direction: row; }
.flex-wrap { flex-wrap: wrap; }
.flex-1 { flex: 1 1 0%; }
.flex-shrink-0, .shrink-0 { flex-shrink: 0; }
.items-center { align-items: center; }
.items-start { align-items: flex-start; }
.items-end { align-items: flex-end; }
.items-baseline { align-items: baseline; }
.justify-center { justify-content: center; }
.justify-between { justify-content: space-between; }
.justify-end { justify-content: flex-end; }
.gap-0 { gap: 0; }
.gap-0\.5 { gap: 2px; }
.gap-1 { gap: var(--space-1); }
.gap-1\.5 { gap: 6px; }
.gap-2 { gap: var(--space-2); }
.gap-3 { gap: var(--space-3); }
.gap-4 { gap: var(--space-4); }
.gap-6 { gap: var(--space-6); }
.gap-8 { gap: var(--space-8); }
.grid-cols-2 { grid-template-columns: repeat(2, 1fr); }
.grid-cols-3 { grid-template-columns: repeat(3, 1fr); }
.w-full { width: 100%; }
.w-auto { width: auto; }
.h-full { height: 100%; }
.h-auto { height: auto; }
.min-w-0 { min-width: 0; }
.min-h-0 { min-height: 0; }
.h-3 { height: 12px; }
.h-4 { height: 16px; }
.h-5 { height: 20px; }
.h-6 { height: 24px; }
.h-8 { height: 32px; }
.h-10 { height: 40px; }
.h-12 { height: 48px; }
.h-16 { height: 64px; }
.h-28 { height: 112px; }
.w-3 { width: 12px; }
.w-4 { width: 16px; }
.w-5 { width: 20px; }
.w-6 { width: 24px; }
.w-8 { width: 32px; }
.w-10 { width: 40px; }
.w-12 { width: 48px; }
.w-16 { width: 64px; }
.w-48 { width: 192px; }
.relative { position: relative; }
.absolute { position: absolute; }
.fixed { position: fixed; }
.sticky { position: sticky; }
.inset-0 { inset: 0; }
.top-0 { top: 0; }
.right-0 { right: 0; }
.bottom-0 { bottom: 0; }
.left-0 { left: 0; }
.overflow-auto { overflow: auto; }
.overflow-hidden { overflow: hidden; }
.overflow-y-auto { overflow-y: auto; }
.overflow-x-auto { overflow-x: auto; }
.z-0 { z-index: 0; }
.z-10 { z-index: 10; }
.z-50 { z-index: 50; }
.m-0 { margin: 0; }
.mx-auto { margin-left: auto; margin-right: auto; }
.ml-auto { margin-left: auto; }
.mr-auto { margin-right: auto; }
.mt-0 { margin-top: 0; }
.mt-1 { margin-top: var(--space-1); }
.mt-2 { margin-top: var(--space-2); }
.mt-3 { margin-top: var(--space-3); }
.mt-4 { margin-top: var(--space-4); }
.mt-6 { margin-top: var(--space-6); }
.mb-0 { margin-bottom: 0; }
.mb-1 { margin-bottom: var(--space-1); }
.mb-2 { margin-bottom: var(--space-2); }
.mb-3 { margin-bottom: var(--space-3); }
.mb-4 { margin-bottom: var(--space-4); }
.mb-6 { margin-bottom: var(--space-6); }
.ml-0 { margin-left: 0; }
.ml-1 { margin-left: var(--space-1); }
.ml-2 { margin-left: var(--space-2); }
.mr-1 { margin-right: var(--space-1); }
.mr-2 { margin-right: var(--space-2); }
.mr-1\.5 { margin-right: 6px; }
.p-0 { padding: 0; }
.p-1 { padding: var(--space-1); }
.p-2 { padding: var(--space-2); }
.p-3 { padding: var(--space-3); }
.p-4 { padding: var(--space-4); }
.p-5 { padding: var(--space-5); }
.p-6 { padding: var(--space-6); }
.px-1 { padding-left: var(--space-1); padding-right: var(--space-1); }
.px-2 { padding-left: var(--space-2); padding-right: var(--space-2); }
.px-3 { padding-left: var(--space-3); padding-right: var(--space-3); }
.px-4 { padding-left: var(--space-4); padding-right: var(--space-4); }
.px-6 { padding-left: var(--space-6); padding-right: var(--space-6); }
.py-0 { padding-top: 0; padding-bottom: 0; }
.py-1 { padding-top: var(--space-1); padding-bottom: var(--space-1); }
.py-2 { padding-top: var(--space-2); padding-bottom: var(--space-2); }
.py-3 { padding-top: var(--space-3); padding-bottom: var(--space-3); }
.py-4 { padding-top: var(--space-4); padding-bottom: var(--space-4); }
.pt-2 { padding-top: var(--space-2); }
.pt-3 { padding-top: var(--space-3); }
.pt-4 { padding-top: var(--space-4); }
.pb-2 { padding-bottom: var(--space-2); }
.pb-3 { padding-bottom: var(--space-3); }
.pb-4 { padding-bottom: var(--space-4); }
.pl-2 { padding-left: var(--space-2); }
.pr-2 { padding-right: var(--space-2); }
.border { border: var(--px) solid var(--surface-border); }
.border-0 { border: none; }
.border-t { border-top: var(--px) solid var(--surface-border); }
.border-b { border-bottom: var(--px) solid var(--surface-border); }
.border-l { border-left: var(--px) solid var(--surface-border); }
.border-r { border-right: var(--px) solid var(--surface-border); }
.border-l-3 { border-left-width: 3px; }
.border-destructive { border-color: var(--color-error); }
.border-border\/30 { border-color: rgba(0, 0, 0, 0.03); }
.border-border { border-color: var(--surface-border); }
.border-primary { border-color: var(--color-primary); }
[data-theme="dark"] .border-border\/30 { border-color: rgba(255, 255, 255, 0.03); }
.rounded-sm { border-radius: var(--radius-sm); }
.rounded { border-radius: var(--radius-md); }
.rounded-md { border-radius: var(--radius-md); }
.rounded-lg { border-radius: var(--radius-lg); }
.rounded-xl { border-radius: var(--radius-xl); }
.rounded-full { border-radius: var(--radius-full); }
.rounded-t-lg { border-top-left-radius: var(--radius-lg); border-top-right-radius: var(--radius-lg); }
.ring-2 { box-shadow: 0 0 0 2px var(--surface-raised), 0 0 0 4px var(--color-primary); }
.ring-primary\/30 { box-shadow: 0 0 0 2px var(--surface-raised), 0 0 0 4px rgba(35, 161, 235, 0.3); }
[data-theme="dark"] .ring-primary\/30 { box-shadow: 0 0 0 2px var(--surface-raised), 0 0 0 4px rgba(84, 162, 255, 0.3); }
.text-left { text-align: left; }
.text-center { text-align: center; }
.text-right { text-align: right; }
.whitespace-nowrap { white-space: nowrap; }
.whitespace-pre-wrap { white-space: pre-wrap; }
.break-words { word-break: break-word; }
.truncate { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.text-\[10px\] { font-size: 10px; }
.text-xs { font-size: 0.75rem; line-height: 1rem; }
.text-sm { font-size: 0.875rem; line-height: 1.25rem; }
.text-base { font-size: 1rem; line-height: 1.5rem; }
.text-lg { font-size: 1.125rem; line-height: 1.75rem; }
.text-xl { font-size: 1.25rem; line-height: 1.75rem; }
.text-2xl { font-size: 1.5rem; line-height: 2rem; }
.text-3xl { font-size: 1.875rem; line-height: 2.25rem; }
.leading-3 { line-height: 0.75rem; }
.leading-4 { line-height: 1rem; }
.leading-5 { line-height: 1.25rem; }
.leading-6 { line-height: 1.5rem; }
.leading-relaxed { line-height: 1.625; }
.font-normal { font-weight: 400; }
.font-medium { font-weight: 500; }
.font-semibold { font-weight: 600; }
.font-bold { font-weight: 700; }
.font-extrabold { font-weight: 800; }
.text-primary { color: var(--text-primary); }
.text-secondary { color: var(--text-secondary); }
.text-tertiary { color: var(--text-tertiary); }
.text-primary-color { color: var(--color-primary); }
.text-error { color: var(--color-error); }
.text-destructive { color: var(--color-destructive); }
.text-success { color: var(--color-success); }
.text-warning { color: var(--color-warning); }
.text-inverse { color: var(--text-inverse); }
.bg-surface { background: var(--surface-raised); }
.bg-surface\/50 { background: rgba(255, 255, 255, 0.5); }
.bg-surface\/60 { background: rgba(255, 255, 255, 0.6); }
.bg-surface\/80 { background: rgba(255, 255, 255, 0.8); }
.bg-overlay { background: var(--surface-overlay); }
.bg-base { background: var(--surface-base); }
.bg-primary { background: var(--color-primary); }
.bg-primary\/5 { background: rgba(35, 161, 235, 0.05); }
.bg-primary\/10 { background: rgba(35, 161, 235, 0.1); }
.bg-success\/5 { background: rgba(16, 185, 129, 0.05); }
.bg-warning\/5 { background: rgba(245, 158, 11, 0.05); }
.bg-error\/5 { background: rgba(228, 64, 95, 0.05); }
.bg-black { background: #000; }
.bg-transparent { background: transparent; }
[data-theme="dark"] .bg-surface\/50 { background: rgba(15, 21, 34, 0.5); }
[data-theme="dark"] .bg-surface\/60 { background: rgba(15, 21, 34, 0.6); }
[data-theme="dark"] .bg-surface\/80 { background: rgba(15, 21, 34, 0.8); }
[data-theme="dark"] .bg-primary\/5 { background: rgba(84, 162, 255, 0.05); }
[data-theme="dark"] .bg-primary\/10 { background: rgba(84, 162, 255, 0.1); }
[data-theme="dark"] .bg-error\/5 { background: rgba(240, 82, 115, 0.05); }
.opacity-0 { opacity: 0; }
.opacity-50 { opacity: 0.5; }
.opacity-60 { opacity: 0.6; }
.opacity-70 { opacity: 0.7; }
.opacity-80 { opacity: 0.8; }
.opacity-85 { opacity: 0.85; }
.object-contain { object-fit: contain; }
.object-cover { object-fit: cover; }
.cursor-pointer { cursor: pointer; }
.cursor-default { cursor: default; }
.cursor-not-allowed { cursor: not-allowed; }
.select-none { user-select: none; }
.pointer-events-none { pointer-events: none; }
.pointer-events-auto { pointer-events: auto; }
.transition-all { transition: all var(--transition-fast); }
.transition-transform { transition: transform var(--transition-fast); }
.transition-opacity { transition: opacity var(--transition-fast); }
.transition-colors { transition: background-color var(--transition-fast), border-color var(--transition-fast), color var(--transition-fast); }
.transition-shadow { transition: box-shadow var(--transition-fast); }
.hover\:scale-105:hover { transform: scale(1.05); }
.hover\:opacity-80:hover { opacity: 0.8; }
.animate-spin { animation: spin 1s linear infinite; }
@keyframes spin { from { transform: rotate(0deg); } to { transform: rotate(360deg); } }
/* ================================================================
BUTTON — Premium Glass
================================================================ */
.btn {
display: inline-flex; align-items: center; justify-content: center; gap: var(--space-2);
padding: 12px 24px; height: 44px; border: 1px solid transparent; border-radius: var(--radius-lg);
font-family: inherit; font-size: 14px; font-weight: 600; line-height: 20px;
letter-spacing: 0.02em; cursor: pointer; transition: all var(--transition-fast);
white-space: nowrap; user-select: none; -webkit-tap-highlight-color: transparent;
text-decoration: none;
}
.btn:active:not(:disabled) { transform: scale(0.97); }
.btn:disabled { opacity: 0.4; cursor: not-allowed; pointer-events: none; }
.btn-primary { background: var(--gradient-primary); color: white; border: none; }
.btn-primary:hover:not(:disabled) { background: var(--gradient-primary-hover); box-shadow: var(--glow-primary); transform: translateY(-1px); }
.btn-secondary { background: transparent; color: var(--color-primary); border: 1.5px solid var(--color-primary); }
.btn-secondary:hover:not(:disabled) { background: var(--color-primary-muted); box-shadow: var(--glow-primary); transform: translateY(-1px); }
.btn-destructive { background: var(--color-error); color: white; border: none; }
.btn-destructive:hover:not(:disabled) { background: #d63856; box-shadow: var(--glow-error); transform: translateY(-1px); }
.btn-outline { background: var(--surface-glass-heavy); border: 1px solid var(--surface-border); color: var(--text-secondary); backdrop-filter: blur(8px); }
.btn-outline:hover:not(:disabled) { background: var(--surface-raised); border-color: var(--color-primary); color: var(--color-primary); box-shadow: var(--shadow-sm); }
.btn-ghost { background: transparent; color: var(--text-secondary); border: none; height: auto; padding: 0.5rem 0.75rem; border-radius: var(--radius-md); }
.btn-ghost:hover:not(:disabled) { background: var(--surface-hover); color: var(--text-primary); }
.btn-tertiary { background: transparent; color: var(--color-primary); border: none; height: auto; padding: 0.5rem 0.75rem; border-radius: var(--radius-md); }
.btn-tertiary:hover:not(:disabled) { background: var(--color-primary-muted); }
.btn-link { background: transparent; color: var(--color-primary); border: none; height: auto; padding: 0; font-weight: 500; letter-spacing: normal; }
.btn-link:hover:not(:disabled) { text-decoration: underline; }
.btn-sm { padding: 8px 16px; height: 36px; font-size: 12px; line-height: 16px; border-radius: var(--radius-md); letter-spacing: 0.03em; }
.btn-lg { padding: 14px 28px; height: 48px; font-size: 16px; border-radius: var(--radius-lg); }
.btn-icon { width: 44px; height: 44px; padding: 0; display: inline-flex; align-items: center; justify-content: center; border-radius: var(--radius-lg); font-size: 1.25rem; }
.btn-icon-sm { width: 36px; height: 36px; padding: 0; display: inline-flex; align-items: center; justify-content: center; border-radius: var(--radius-md); font-size: 1rem; }
/* ================================================================
BADGE — Premium
================================================================ */
.badge {
display: inline-flex; align-items: center; padding: 4px 12px; border-radius: var(--radius-full);
font-size: 12px; font-weight: 500; line-height: 16px; letter-spacing: 0.03em;
background: var(--surface-container); color: var(--text-secondary); border: none;
transition: all var(--transition-fast);
}
.badge-primary { background: linear-gradient(135deg, rgba(35, 161, 235, 0.15), rgba(123, 134, 245, 0.10)); color: var(--color-primary); }
.badge-success { background: rgba(16, 185, 129, 0.12); color: #059669; }
.badge-warning { background: rgba(245, 158, 11, 0.12); color: #d97706; }
.badge-destructive { background: rgba(228, 64, 95, 0.12); color: #e11d48; }
.badge-outline { background: transparent; border: 1px solid var(--surface-border); color: var(--text-tertiary); }
.badge-info { background: rgba(35, 161, 235, 0.12); color: var(--color-primary); }
.badge-secondary { background: rgba(24, 119, 242, 0.12); color: var(--color-secondary); }
[data-theme="dark"] .badge-primary { background: linear-gradient(135deg, rgba(84, 162, 255, 0.18), rgba(157, 166, 255, 0.12)); color: #7ab7ff; }
[data-theme="dark"] .badge-success { background: rgba(16, 185, 129, 0.15); color: #6ee7b7; }
[data-theme="dark"] .badge-warning { background: rgba(245, 158, 11, 0.15); color: #fcd34d; }
[data-theme="dark"] .badge-destructive { background: rgba(240, 82, 115, 0.15); color: #fda4af; }
[data-theme="dark"] .badge-info { background: rgba(84, 162, 255, 0.15); color: #7ab7ff; }
[data-theme="dark"] .badge-secondary { background: rgba(74, 140, 255, 0.15); color: #93c5fd; }
/* ================================================================
CARD — Glass Premium
================================================================ */
.card {
background: var(--surface-glass-heavy); backdrop-filter: blur(12px); -webkit-backdrop-filter: blur(12px);
border: 1px solid var(--surface-border); border-radius: var(--radius-xl); overflow: hidden;
box-shadow: var(--shadow-sm); transition: all var(--transition-normal);
}
.card:hover { border-color: rgba(35, 161, 235, 0.12); box-shadow: var(--shadow-md); transform: translateY(-1px); }
.card-header { padding: var(--space-4) var(--space-6); border-bottom: var(--px) solid var(--surface-border); }
.card-title { font-size: 1rem; font-weight: 600; color: var(--text-primary); letter-spacing: -0.01em; }
.card-description { font-size: 0.875rem; color: var(--text-secondary); margin-top: var(--space-1); }
.card-content { padding: var(--space-6); }
.card-footer { padding: var(--space-4) var(--space-6); border-top: var(--px) solid var(--surface-border); }
/* ================================================================
INPUT FIELD — Glass
================================================================ */
.input {
display: block; width: 100%; padding: var(--space-3);
background: var(--surface-container-low); border: 1.5px solid transparent;
border-radius: var(--radius-md); color: var(--text-primary); font-family: inherit;
font-size: 16px; font-weight: 400; line-height: 24px; letter-spacing: 0.01em;
transition: all var(--transition-fast); outline: none;
}
.input::placeholder { color: var(--text-tertiary); }
.input:focus { border-color: var(--color-primary); box-shadow: 0 0 0 3px rgba(35, 161, 235, 0.12), var(--glow-primary); background: var(--surface-raised); }
.input[aria-invalid="true"], .input-error { border-color: var(--color-error); box-shadow: 0 0 0 3px rgba(228, 64, 95, 0.12); }
.select {
display: block; width: 100%; padding: var(--space-3) 2rem var(--space-3) var(--space-3);
background: var(--surface-container-low); border: 1.5px solid transparent;
border-radius: var(--radius-md); color: var(--text-primary); font-family: inherit;
font-size: 0.875rem; cursor: pointer; transition: all var(--transition-fast);
outline: none; appearance: none;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24' fill='none' stroke='%2394a3b8' stroke-width='2'%3E%3Cpolyline points='6 9 12 15 18 9'%3E%3C/polyline%3E%3C/svg%3E");
background-repeat: no-repeat; background-position: right 0.75rem center;
}
.select:focus { border-color: var(--color-primary); box-shadow: 0 0 0 3px rgba(35, 161, 235, 0.12); background: var(--surface-raised); }
/* ================================================================
SKELETON
================================================================ */
.skeleton {
background: linear-gradient(110deg, var(--surface-container-low) 30%, rgba(35, 161, 235, 0.04) 50%, var(--surface-container-low) 70%);
background-size: 200% 100%; animation: shimmer 1.8s ease-in-out infinite; border-radius: var(--radius-md);
}
.skeleton-circular { border-radius: 50%; }
@keyframes shimmer { 0% { background-position: 200% 0; } 100% { background-position: -200% 0; } }
/* ================================================================
TABS
================================================================ */
.tabs { display: flex; flex-direction: column; }
.tab-list { display: flex; gap: 2px; padding: 4px; background: var(--surface-container-low); border-radius: var(--radius-lg); }
.tab-trigger { padding: 8px 16px; background: transparent; border: none; border-radius: var(--radius-md); color: var(--text-secondary); font-family: inherit; font-size: 14px; font-weight: 500; cursor: pointer; transition: all var(--transition-fast); }
.tab-trigger:hover { color: var(--text-primary); background: var(--surface-hover); }
.tab-trigger.active, .tab-trigger[aria-selected="true"] { background: var(--surface-glass-heavy); backdrop-filter: blur(8px); color: var(--color-primary); font-weight: 600; box-shadow: var(--shadow-sm); }
.tab-content { padding-top: var(--space-4); animation: fade-in-up var(--transition-normal) ease-out; }
/* ================================================================
APP SHELL
================================================================ */
.app-shell { position: relative; z-index: 1; display: flex; flex-direction: column; height: 100vh; background: var(--surface-base); color: var(--text-primary); }
.app-header { height: var(--header-height); border-bottom: 1px solid var(--surface-border); display: flex; align-items: center; justify-content: space-between; gap: var(--space-4); padding: 0 var(--space-6); background: var(--surface-glass); backdrop-filter: blur(20px); -webkit-backdrop-filter: blur(20px); position: sticky; top: 0; z-index: var(--z-header); }
.app-brand { display: flex; align-items: center; gap: var(--space-3); }
.app-brand-mark { font-weight: 800; font-size: 1.25rem; background: var(--gradient-brand); -webkit-background-clip: text; -webkit-text-fill-color: transparent; background-clip: text; letter-spacing: -0.03em; }
.app-brand-subtitle { color: var(--text-tertiary); font-size: 0.6875rem; font-weight: 500; text-transform: uppercase; letter-spacing: 0.08em; padding: 3px 10px; background: var(--surface-container-low); border-radius: var(--radius-full); border: 1px solid var(--surface-border); }
.app-main { flex: 1; min-height: 0; display: flex; position: relative; }
.app-sidebar { width: var(--sidebar-width); flex-shrink: 0; border-right: 1px solid var(--surface-border); padding: var(--space-3); background: var(--surface-glass); backdrop-filter: blur(16px); -webkit-backdrop-filter: blur(16px); display: flex; flex-direction: column; gap: var(--space-1); }
.app-content { flex: 1; min-width: 0; overflow-y: auto; padding: var(--space-6); animation: content-enter 500ms cubic-bezier(0.16, 1, 0.3, 1) both; }
.app-content > * { animation: card-in 500ms cubic-bezier(0.16, 1, 0.3, 1) both; }
.app-content > :nth-child(1) { animation-delay: 30ms; }
.app-content > :nth-child(2) { animation-delay: 60ms; }
.app-content > :nth-child(3) { animation-delay: 90ms; }
.theme-toggle { width: 44px; height: 44px; border: 1px solid var(--surface-border); border-radius: var(--radius-lg); background: var(--surface-glass-heavy); backdrop-filter: blur(8px); color: var(--text-tertiary); cursor: pointer; font-size: 1.25rem; transition: all var(--transition-fast); -webkit-tap-highlight-color: transparent; }
.theme-toggle:hover { color: var(--color-primary); border-color: var(--color-primary); box-shadow: var(--glow-primary); transform: translateY(-1px); }
.theme-toggle:active { transform: scale(0.92); }
.sidebar-btn { display: flex; align-items: center; width: 100%; justify-content: flex-start; padding: 10px 12px; border: none; border-radius: var(--radius-md); background: transparent; color: var(--text-secondary); font-family: inherit; font-size: 14px; font-weight: 500; line-height: 20px; letter-spacing: 0.02em; cursor: pointer; transition: all var(--transition-fast); position: relative; gap: var(--space-3); }
.sidebar-btn:hover { background: var(--surface-hover); color: var(--color-primary); transform: translateX(2px); }
.sidebar-btn.is-active { background: linear-gradient(135deg, var(--color-primary-muted), rgba(123, 134, 245, 0.06)); color: var(--color-primary); font-weight: 600; }
.sidebar-btn.is-active::after { content: ''; position: absolute; left: 0; top: 50%; transform: translateY(-50%); width: 3px; height: 24px; border-radius: var(--radius-full); background: var(--gradient-primary); box-shadow: var(--glow-primary); animation: indicator-grow 250ms cubic-bezier(0.16, 1, 0.3, 1) both; }
/* ── Status Badge ─────────────────────────────── */
.status-badge { display: inline-flex; align-items: center; gap: var(--space-1); padding: 3px 10px; border-radius: var(--radius-full); font-size: 11px; font-weight: 600; letter-spacing: 0.02em; transition: all var(--transition-fast); }
.status-badge::before { content: ''; width: 6px; height: 6px; border-radius: 50%; flex-shrink: 0; }
.status-badge-flagged { background: rgba(228, 64, 95, 0.12); color: var(--color-ai-flagged); }
.status-badge-flagged::before { background: var(--color-ai-flagged); box-shadow: 0 0 8px rgba(228, 64, 95, 0.6); }
.status-badge-clean { background: rgba(16, 185, 129, 0.12); color: var(--color-ai-clean); }
.status-badge-clean::before { background: var(--color-ai-clean); box-shadow: 0 0 8px rgba(16, 185, 129, 0.4); }
.status-badge-warn { background: rgba(245, 158, 11, 0.12); color: var(--color-ai-warn); }
.status-badge-warn::before { background: var(--color-ai-warn); box-shadow: 0 0 8px rgba(245, 158, 11, 0.4); }
.status-badge-pending { background: rgba(148, 163, 184, 0.12); color: var(--color-ai-pending); }
.status-badge-pending::before { background: var(--color-ai-pending); }
.status-badge-processing { background: rgba(35, 161, 235, 0.12); color: var(--color-ai-processing); }
.status-badge-processing::before { background: var(--color-ai-processing); animation: pulse-dot 1.5s ease-in-out infinite; }
.status-badge-error { background: rgba(220, 38, 38, 0.12); color: var(--color-ai-error); }
.status-badge-error::before { background: var(--color-ai-error); box-shadow: 0 0 8px rgba(220, 38, 38, 0.4); }
@keyframes pulse-dot { 0%, 100% { opacity: 1; } 50% { opacity: 0.3; } }
[data-theme="dark"] .status-badge-pending { background: rgba(74, 84, 112, 0.20); }
[data-theme="dark"] .status-badge-processing { background: rgba(84, 162, 255, 0.18); }
/* ================================================================
MESSAGES PANEL
================================================================ */
.messages-panel { display: flex; flex-direction: column; gap: var(--space-5); }
.message-stats { display: flex; flex-wrap: wrap; align-items: center; gap: var(--space-1); }
.search-row { display: flex; flex-wrap: wrap; align-items: center; gap: var(--space-2); }
.filter-chip { padding: 0.25rem 0.75rem; border-radius: var(--radius-full); font-size: 0.6875rem; font-weight: 500; border: 1px solid transparent; background: var(--surface-hover); color: var(--text-tertiary); cursor: pointer; transition: all var(--transition-fast); font-family: inherit; }
.filter-chip:hover { color: var(--text-secondary); background: var(--surface-container-low); }
.filter-chip:active { transform: scale(0.95); }
.filter-chip.active { background: var(--gradient-primary); color: white; }
.active { background: var(--gradient-primary); color: white; }
.message-card { background: var(--surface-glass-heavy); backdrop-filter: blur(8px); border: 1px solid var(--surface-border); border-radius: var(--radius-lg); overflow: hidden; transition: all var(--transition-fast); }
.message-card:hover { border-color: rgba(35, 161, 235, 0.20); box-shadow: var(--shadow-md); transform: translateY(-1px); }
.message-row { padding: var(--space-3) 0; border-bottom: 1px solid var(--surface-border); transition: all var(--transition-fast); }
.message-row:hover { background: var(--surface-hover); margin: 0 calc(-1 * var(--space-4)); padding-left: var(--space-4); padding-right: var(--space-4); border-radius: var(--radius-sm); }
.message-row:last-child { border-bottom: none; }
.message-row:last-child:hover { margin-bottom: calc(-1 * var(--space-3)); }
.message-timestamp { font-size: 0.6875rem; color: var(--text-tertiary); font-variant-numeric: tabular-nums; }
.custom-emoji { display: inline-block; height: 20px; width: 20px; vertical-align: middle; object-fit: contain; }
/* ================================================================
TOAST — Premium
================================================================ */
.toast-container { position: fixed; top: var(--space-4); right: var(--space-4); z-index: var(--z-toast); display: flex; flex-direction: column; gap: var(--space-2); pointer-events: none; }
.toast { display: flex; align-items: center; gap: var(--space-3); padding: 0.75rem 1rem; background: var(--surface-glass-heavy); backdrop-filter: blur(16px); -webkit-backdrop-filter: blur(16px); border: 1px solid var(--surface-border); border-radius: var(--radius-lg); box-shadow: var(--shadow-lg); min-width: 300px; max-width: 420px; pointer-events: auto; animation: slide-in-right var(--transition-bounce); }
.toast-success { border-left: 3px solid var(--color-success); }
.toast-error { border-left: 3px solid var(--color-error); }
.toast-warning { border-left: 3px solid var(--color-warning); }
.toast-info { border-left: 3px solid var(--color-primary); }
.toast-close { margin-left: auto; background: none; border: none; color: var(--text-tertiary); cursor: pointer; padding: 0.25rem; transition: color var(--transition-fast); }
.toast-close:hover { color: var(--text-primary); }
/* ================================================================
MODAL
================================================================ */
.modal-overlay { position: fixed; inset: 0; background: rgba(0, 0, 0, 0.55); backdrop-filter: blur(8px); -webkit-backdrop-filter: blur(8px); display: flex; align-items: center; justify-content: center; z-index: var(--z-modal); animation: fade-in var(--transition-fast) ease-out; }
.modal-content { background: var(--surface-glass-heavy); backdrop-filter: blur(20px); -webkit-backdrop-filter: blur(20px); border-radius: var(--radius-xl); border: 1px solid var(--surface-border); box-shadow: var(--shadow-xl); max-width: 90vw; max-height: 85vh; overflow: auto; animation: scale-in var(--transition-bounce); }
.modal-header { display: flex; align-items: center; justify-content: space-between; padding: var(--space-4) var(--space-6); border-bottom: 1px solid var(--surface-border); }
.modal-body { padding: var(--space-6); }
.modal-footer { display: flex; justify-content: flex-end; gap: var(--space-2); padding: var(--space-4) var(--space-6); border-top: 1px solid var(--surface-border); }
.empty-state { display: flex; flex-direction: column; align-items: center; justify-content: center; padding: var(--space-16) var(--space-4); text-align: center; }
.empty-state-title { font-size: 0.9375rem; font-weight: 600; color: var(--text-secondary); margin-bottom: var(--space-2); }
.empty-state-description { font-size: 0.8125rem; color: var(--text-tertiary); max-width: 280px; line-height: 1.5; }
/* ================================================================
ANIMATIONS
================================================================ */
@keyframes fade-in { from { opacity: 0; } to { opacity: 1; } }
@keyframes fade-in-up { from { opacity: 0; transform: translateY(10px); } to { opacity: 1; transform: translateY(0); } }
@keyframes scale-in { from { opacity: 0; transform: scale(0.92); } to { opacity: 1; transform: scale(1); } }
@keyframes slide-in-right { from { opacity: 0; transform: translateX(120%); } to { opacity: 1; transform: translateX(0); } }
@keyframes bar-pulse { 0%, 100% { transform: scaleY(1); } 50% { transform: scaleY(0.5); } }
@keyframes glow-pulse { 0%, 100% { box-shadow: var(--glow-primary); } 50% { box-shadow: 0 0 40px rgba(35, 161, 235, 0.35); } }
.animate-fade-in { animation: fade-in var(--transition-normal) ease-out; }
.animate-fade-in-up { animation: fade-in-up var(--transition-normal) ease-out; }
.animate-scale-in { animation: scale-in var(--transition-normal) ease-out; }
.animate-slide-in-right { animation: slide-in-right var(--transition-normal) ease-out; }
.animate-content-enter { animation: content-enter 500ms cubic-bezier(0.16, 1, 0.3, 1) both; }
.animate-float { animation: float 4s ease-in-out infinite; }
.animate-glow { animation: glow-pulse 2s ease-in-out infinite; }
.animate-stagger-1 { animation-delay: 50ms; }
.animate-stagger-2 { animation-delay: 100ms; }
.animate-stagger-3 { animation-delay: 150ms; }
.animate-stagger-4 { animation-delay: 200ms; }
@keyframes content-enter { from { opacity: 0; transform: translateY(16px); } to { opacity: 1; transform: translateY(0); } }
@keyframes float { 0%, 100% { transform: translateY(0); } 50% { transform: translateY(-8px); } }
@keyframes indicator-grow { from { height: 0; opacity: 0; } to { height: 24px; opacity: 1; } }
@keyframes pulse-connecting { 0%, 100% { opacity: 1; transform: scale(1); } 50% { opacity: 0.4; transform: scale(0.7); } }
@keyframes orb-drift { 0% { transform: translate(0, 0); } 25% { transform: translate(40px, -30px); } 50% { transform: translate(-30px, 20px); } 75% { transform: translate(20px, -40px); } 100% { transform: translate(0, 0); } }
@keyframes card-in { from { opacity: 0; transform: translateY(20px); } to { opacity: 1; transform: translateY(0); } }
@media (prefers-reduced-motion: reduce) { *, *::before, *::after { animation-duration: 0.01ms !important; animation-iteration-count: 1 !important; transition-duration: 0.01ms !important; } }
/* ================================================================
PARTICLE BACKGROUND — Premium Orbs
================================================================ */
.particle-bg { position: fixed; inset: 0; overflow: hidden; pointer-events: none; z-index: 0; }
.particle-orb { position: absolute; border-radius: 50%; opacity: 0.5; will-change: transform; }
.particle-orb:nth-child(1) { width: 600px; height: 600px; top: -200px; right: -150px; background: radial-gradient(circle, var(--color-primary) 0%, transparent 70%); filter: blur(100px); animation: orb-drift 25s ease-in-out infinite; }
.particle-orb:nth-child(2) { width: 500px; height: 500px; bottom: -150px; left: -150px; background: radial-gradient(circle, var(--color-tertiary) 0%, transparent 70%); filter: blur(100px); animation: orb-drift 30s ease-in-out infinite reverse; }
.particle-orb:nth-child(3) { width: 350px; height: 350px; top: 40%; left: 60%; background: radial-gradient(circle, var(--color-accent) 0%, transparent 70%); filter: blur(100px); animation: orb-drift 35s ease-in-out infinite; animation-delay: -8s; }
.particle-orb:nth-child(4) { width: 250px; height: 250px; top: 10%; left: 20%; background: radial-gradient(circle, var(--color-success) 0%, transparent 70%); opacity: 0.3; filter: blur(100px); animation: orb-drift 20s ease-in-out infinite; animation-delay: -3s; }
@media (max-width: 768px) { .particle-bg { display: none; } }
/* ================================================================
IMAGE GRID
================================================================ */
.image-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); gap: var(--space-2); }
.image-grid-item { aspect-ratio: 1; border-radius: var(--radius-md); overflow: hidden; cursor: pointer; transition: all var(--transition-fast); border: 1px solid var(--surface-border); }
.image-grid-item:hover { opacity: 0.85; transform: scale(1.02); box-shadow: var(--shadow-sm); }
.image-grid-item img { width: 100%; height: 100%; object-fit: cover; }
/* ================================================================
DASHBOARD
================================================================ */
.dashboard-panel { display: flex; flex-direction: column; gap: var(--space-6); }
.dashboard-header { display: flex; align-items: flex-start; justify-content: space-between; gap: var(--space-4); }
.dashboard-stats-grid { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: var(--space-4); }
.dashboard-metric-card { padding: var(--space-5); overflow: hidden; }
.dashboard-metric-card:hover { transform: translateY(-3px); box-shadow: var(--shadow-md); border-color: rgba(35, 161, 235, 0.15); }
.dashboard-metric-content { display: flex; align-items: center; justify-content: space-between; gap: var(--space-3); }
.dashboard-metric-label { color: var(--text-secondary); font-size: 0.75rem; font-weight: 500; letter-spacing: 0.02em; text-transform: uppercase; }
.dashboard-metric-value { margin-top: var(--space-1); font-size: 1.625rem; font-weight: 800; letter-spacing: -0.03em; color: var(--text-primary); }
.dashboard-metric-icon { display: flex; align-items: center; justify-content: center; width: 2.75rem; height: 2.75rem; border-radius: var(--radius-lg); font-size: 1.25rem; }
.dashboard-wide-card { grid-column: span 2; }
.dashboard-top-channels, .dashboard-summary-list { display: flex; flex-direction: column; gap: var(--space-2); }
.dashboard-top-channel-row { display: flex; align-items: center; justify-content: space-between; gap: var(--space-2); padding: var(--space-2) var(--space-3); border-radius: var(--radius-md); background: var(--surface-container-low); color: var(--text-secondary); font-size: 0.875rem; transition: all var(--transition-fast); }
.dashboard-top-channel-row:hover { background: var(--surface-overlay); transform: translateX(2px); }
.dashboard-moderation-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: var(--space-3); }
.dashboard-queue-card { padding: var(--space-5); border: 1px solid var(--surface-border); border-radius: var(--radius-lg); background: var(--surface-container-low); text-align: center; transition: all var(--transition-fast); }
.dashboard-queue-card:hover { border-color: var(--color-primary); box-shadow: var(--shadow-sm); transform: translateY(-1px); }
.dashboard-queue-value { font-size: 1.75rem; font-weight: 800; color: var(--text-primary); letter-spacing: -0.03em; }
.dashboard-queue-label { margin-top: var(--space-1); color: var(--text-secondary); font-size: 0.75rem; text-transform: uppercase; letter-spacing: 0.04em; }
.dashboard-list-card { overflow: hidden; }
.dashboard-list-toolbar { margin-bottom: var(--space-4); }
.dashboard-summary-row { display: flex; align-items: flex-start; gap: var(--space-3); padding: var(--space-3); border: 1px solid var(--surface-border); border-radius: var(--radius-lg); background: var(--surface-glass-heavy); backdrop-filter: blur(8px); transition: all var(--transition-fast); }
.dashboard-summary-row:hover { border-color: var(--color-primary); background: var(--surface-raised); box-shadow: var(--shadow-sm); }
.dashboard-summary-avatar { display: flex; align-items: center; justify-content: center; flex-shrink: 0; width: 2.5rem; height: 2.5rem; border-radius: var(--radius-full); background: var(--surface-container); color: var(--text-secondary); font-weight: 700; overflow: hidden; }
.dashboard-summary-avatar-img { width: 100%; height: 100%; object-fit: cover; }
.dashboard-channel-avatar { color: var(--color-primary); background: rgba(35, 161, 235, 0.1); }
.dashboard-summary-main { min-width: 0; flex: 1; }
.dashboard-summary-title { color: var(--text-primary); font-size: 0.875rem; font-weight: 600; }
.dashboard-summary-text { margin-top: var(--space-1); color: var(--text-secondary); font-size: 0.8125rem; line-height: 1.45; }
.dashboard-summary-meta { display: flex; flex-wrap: wrap; gap: var(--space-2); margin-top: var(--space-2); color: var(--text-tertiary); font-size: 0.75rem; }
.dashboard-list-empty { display: flex; flex-direction: column; align-items: center; justify-content: center; gap: var(--space-3); padding: var(--space-10) var(--space-4); text-align: center; }
/* ================================================================
MASCOT CHATBOT
================================================================ */
.mascot-widget { position: fixed; right: var(--space-6); bottom: var(--space-6); z-index: var(--z-toast); }
.mascot-launcher { width: 3.75rem; height: 3.75rem; border: none; border-radius: var(--radius-full); background: var(--gradient-primary); color: white; box-shadow: var(--shadow-xl), var(--glow-primary); cursor: pointer; font-size: 1.5rem; transition: all var(--transition-fast); animation: float 4s ease-in-out infinite; }
.mascot-launcher:hover { transform: translateY(-3px) scale(1.05); animation: none; box-shadow: var(--shadow-lg), var(--glow-primary); }
.mascot-panel { width: min(24rem, calc(100vw - 2rem)); height: min(32.5rem, calc(100vh - 7rem)); display: flex; flex-direction: column; overflow: hidden; border: 1px solid var(--surface-border); border-radius: var(--radius-xl); background: var(--surface-glass-heavy); backdrop-filter: blur(20px); -webkit-backdrop-filter: blur(20px); box-shadow: var(--shadow-xl); animation: fade-in-up var(--transition-bounce); }
.mascot-panel.minimized { height: 3.75rem; }
.mascot-header { display: flex; align-items: center; justify-content: space-between; gap: var(--space-3); padding: var(--space-3); background: var(--gradient-primary); color: white; flex-shrink: 0; }
.mascot-header-icon { display: flex; align-items: center; justify-content: center; width: 2rem; height: 2rem; border-radius: var(--radius-md); background: rgba(255,255,255,0.15); }
.mascot-title { font-size: 0.875rem; font-weight: 700; line-height: 1.15; letter-spacing: 0.02em; }
.mascot-subtitle { margin-top: 0.125rem; font-size: 0.6875rem; opacity: 0.7; }
.mascot-icon-button { width: 2rem; height: 2rem; border: none; border-radius: var(--radius-md); background: transparent; color: white; cursor: pointer; transition: background var(--transition-fast); }
.mascot-icon-button:hover { background: rgba(255,255,255,0.20); }
.mascot-messages { flex: 1; min-height: 0; overflow-y: auto; display: flex; flex-direction: column; gap: var(--space-3); padding: var(--space-4); }
.mascot-message-row { display: flex; gap: var(--space-2); animation: fade-in-up var(--transition-fast) both; }
.mascot-message-row.user { justify-content: flex-end; }
.mascot-avatar { display: flex; align-items: center; justify-content: center; flex-shrink: 0; width: 1.5rem; height: 1.5rem; border-radius: var(--radius-full); background: var(--surface-container-low); font-size: 0.875rem; }
.mascot-bubble { max-width: 17.5rem; padding: var(--space-2) var(--space-3); border-radius: var(--radius-xl); font-size: 0.875rem; line-height: 1.5; overflow-wrap: anywhere; }
.mascot-bubble.user { color: white; background: var(--gradient-primary); border-bottom-right-radius: var(--radius-sm); }
.mascot-bubble.mascot { color: var(--text-primary); background: var(--surface-container-low); border: 1px solid var(--surface-border); border-bottom-left-radius: var(--radius-sm); }
.mascot-bubble.typing { display: flex; gap: 0.25rem; padding-top: 0.7rem; padding-bottom: 0.7rem; }
.mascot-bubble.typing span { width: 0.5rem; height: 0.5rem; border-radius: var(--radius-full); background: var(--text-tertiary); animation: notification-pulse 0.8s infinite; }
.mascot-bubble.typing span:nth-child(2) { animation-delay: 0.1s; }
.mascot-bubble.typing span:nth-child(3) { animation-delay: 0.2s; }
@keyframes notification-pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.3; } }
.mascot-form { display: flex; gap: var(--space-2); padding: var(--space-3); border-top: 1px solid var(--surface-border); }
.mascot-input { flex: 1; min-width: 0; border: 1.5px solid transparent; border-radius: var(--radius-lg); padding: var(--space-2) var(--space-3); background: var(--surface-container-low); color: var(--text-primary); font-size: 0.875rem; transition: all var(--transition-fast); }
.mascot-input:focus { outline: none; border-color: var(--color-primary); box-shadow: 0 0 0 3px rgba(35, 161, 235, 0.12); background: var(--surface-raised); }
.mascot-send { width: 2.25rem; border: none; border-radius: var(--radius-lg); background: var(--gradient-primary); color: white; cursor: pointer; transition: all var(--transition-fast); }
.mascot-send:hover { box-shadow: var(--glow-primary); }
.mascot-send:disabled, .mascot-input:disabled { cursor: not-allowed; opacity: 0.55; }
/* ── Tone Helpers ───────────────────────────────── */
.tone-primary { background: linear-gradient(135deg, rgba(35, 161, 235, 0.12), rgba(123, 134, 245, 0.08)); color: var(--color-primary); }
.tone-success { background: rgba(16, 185, 129, 0.10); color: var(--color-success); }
.tone-error { background: rgba(228, 64, 95, 0.10); color: var(--color-error); }
.tone-warning { background: rgba(245, 158, 11, 0.10); color: var(--color-warning); }
.tone-info { background: rgba(35, 161, 235, 0.10); color: var(--color-primary); }
[data-theme="dark"] .tone-primary { background: linear-gradient(135deg, rgba(84, 162, 255, 0.15), rgba(157, 166, 255, 0.10)); color: #7ab7ff; }
[data-theme="dark"] .tone-info { background: rgba(84, 162, 255, 0.12); color: #7ab7ff; }
/* ================================================================
RESPONSIVE
================================================================ */
@media (max-width: 1024px) { .dashboard-stats-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
@media (max-width: 768px) {
.grid-cols-2 { grid-template-columns: 1fr; }
.grid-cols-3 { grid-template-columns: 1fr; }
.app-main { flex-direction: column; }
.app-sidebar { width: 100%; border-right: none; border-bottom: 1px solid var(--surface-border); padding: var(--space-2); overflow-x: auto; backdrop-filter: blur(12px); }
.app-sidebar .flex-col { flex-direction: row; overflow-x: auto; gap: var(--space-1); }
.app-content { padding: var(--space-4); }
.app-brand-subtitle { display: none; }
.dashboard-stats-grid { grid-template-columns: 1fr; }
.dashboard-wide-card { grid-column: span 1; }
.dashboard-moderation-grid { grid-template-columns: 1fr; }
.mascot-widget { right: var(--space-4); bottom: var(--space-4); }
.mascot-panel { width: calc(100vw - 2rem); height: min(32.5rem, calc(100vh - 5rem)); }
.hide-mobile { display: none !important; }
}
@media (min-width: 769px) { .hide-desktop { display: none !important; } }
/* ================================================================
v2 — CLEAN SEMANTIC COMPONENT CLASSES
================================================================ */
.panel-card { background: var(--surface-glass-heavy); backdrop-filter: blur(12px); -webkit-backdrop-filter: blur(12px); border: 1px solid var(--surface-border); border-radius: var(--radius-lg); box-shadow: var(--shadow-sm); overflow: hidden; transition: all var(--transition-normal); }
.panel-card:hover { box-shadow: var(--shadow-md); }
.panel-card-head { padding: var(--space-5) var(--space-6); border-bottom: 1px solid var(--surface-border); }
.panel-card-title { font-size: 1rem; font-weight: 600; color: var(--text-primary); }
.panel-card-desc { font-size: 0.875rem; color: var(--text-secondary); margin-top: var(--space-1); }
.stats-bar { display: flex; flex-wrap: wrap; align-items: center; gap: var(--space-1); }
.search-bar { display: flex; flex-wrap: wrap; align-items: center; gap: var(--space-2); }
.search-wrap { position: relative; flex: 1; min-width: 200px; }
.search-input { width: 100%; padding: 0.5rem 0.75rem 0.5rem 2.25rem; background: var(--surface-container-low); border: 1.5px solid transparent; border-radius: var(--radius-full); color: var(--text-primary); font-family: inherit; font-size: 0.875rem; transition: all var(--transition-fast); outline: none; }
.search-input::placeholder { color: var(--text-tertiary); }
.search-input:focus { border-color: var(--color-primary); box-shadow: 0 0 0 3px rgba(35, 161, 235, 0.12); background: var(--surface-raised); }
.search-icon { position: absolute; left: 0.75rem; top: 50%; transform: translateY(-50%); color: var(--color-primary); pointer-events: none; }
.filter-group { display: flex; align-items: center; gap: 0.375rem; margin-left: auto; }
.filter-chip-v2 { padding: 0.25rem 0.75rem; border-radius: var(--radius-full); font-size: 0.6875rem; font-weight: 500; border: 1px solid transparent; background: var(--surface-hover); color: var(--text-tertiary); cursor: pointer; transition: all var(--transition-fast); font-family: inherit; }
.filter-chip-v2:hover { color: var(--text-secondary); background: var(--surface-container-low); }
.filter-chip-v2.is-active { background: var(--gradient-primary); color: white; box-shadow: var(--glow-primary); }
.msg-card { background: var(--surface-glass-heavy); backdrop-filter: blur(8px); -webkit-backdrop-filter: blur(8px); border: 1px solid var(--surface-border); border-radius: var(--radius-lg); overflow: hidden; transition: all var(--transition-fast); }
.msg-card:hover { border-color: rgba(35, 161, 235, 0.20); box-shadow: var(--shadow-md); transform: translateY(-2px); }
.msg-card.is-deleted { opacity: 0.6; border-color: var(--color-error); }
.msg-card-inner { display: flex; gap: var(--space-3); padding: var(--space-4); }
.msg-card-avatar { width: 40px; height: 40px; flex-shrink: 0; border-radius: 50%; object-fit: cover; box-shadow: 0 0 0 2px var(--surface-raised), 0 0 0 4px rgba(35, 161, 235, 0.15); }
.msg-card-body { min-width: 0; flex: 1; }
.msg-card-head { display: flex; align-items: baseline; gap: var(--space-2); margin-bottom: var(--space-2); }
.msg-card-name { font-size: 0.875rem; font-weight: 600; color: var(--text-primary); }
.msg-card-channel { display: inline-flex; align-items: center; gap: var(--space-1); font-size: 0.75rem; color: var(--text-tertiary); background: var(--surface-container-low); padding: 1px 8px; border-radius: var(--radius-full); }
.msg-card-channel::before { content: '#'; opacity: 0.5; }
.msg-card-time { font-size: 0.75rem; color: var(--text-tertiary); opacity: 0.6; }
.msg-card-messages.separated > * + * { margin-top: 0.625rem; padding-top: 0.625rem; border-top: 1px solid var(--surface-border); }
.msg-row { padding: var(--space-3) 0; border-bottom: 1px solid var(--surface-border); transition: all var(--transition-fast); }
.msg-row:hover { background: var(--surface-hover); margin: 0 calc(-1 * var(--space-4)); padding-left: var(--space-4); padding-right: var(--space-4); border-radius: var(--radius-sm); }
.msg-row:last-child { border-bottom: none; }
.msg-row-bar { display: flex; flex-wrap: wrap; align-items: center; gap: 0.25rem 0.5rem; }
.msg-row-time { font-size: 0.6875rem; color: var(--text-tertiary); font-variant-numeric: tabular-nums; min-width: 48px; }
.msg-row-badge { display: inline-flex; align-items: center; gap: 0.125rem; font-size: 0.75rem; }
.msg-row-badge.edited { color: var(--text-secondary); }
.msg-row-badge.deleted { color: var(--color-error); }
.msg-row-status { margin-left: auto; display: flex; align-items: center; gap: var(--space-1); }
.msg-row-body { font-size: 0.875rem; line-height: 1.5rem; white-space: pre-wrap; word-break: break-word; }
.msg-row-body.is-deleted { opacity: 0.6; color: var(--text-secondary); }
.msg-media-row { display: flex; flex-wrap: wrap; gap: var(--space-2); margin-top: var(--space-2); }
.msg-media-row.is-scroll { overflow-x: auto; flex-wrap: nowrap; }
.msg-thumb { width: 64px; height: 64px; object-fit: cover; border-radius: var(--radius-md); border: 1px solid var(--surface-border); transition: all var(--transition-fast); }
.msg-thumb:hover { transform: scale(1.08); }
.msg-thumb-link { display: block; flex-shrink: 0; overflow: hidden; border-radius: var(--radius-md); border: 1px solid var(--surface-border); transition: all var(--transition-fast); }
.msg-thumb-link:hover { border-color: var(--color-primary); }
.msg-sticker { width: 48px; height: 48px; object-fit: contain; border-radius: var(--radius-md); border: 1px solid var(--surface-border); }
.msg-sticker-placeholder { display: flex; width: 48px; height: 48px; align-items: center; justify-content: center; border-radius: var(--radius-md); border: 1px solid var(--surface-border); }
.msg-video { height: 112px; width: 192px; flex-shrink: 0; border-radius: var(--radius-md); border: 1px solid var(--surface-border); object-fit: cover; background: #000; }
.msg-media-overflow { display: flex; width: 64px; height: 64px; align-items: center; justify-content: center; border-radius: var(--radius-md); border: 1px solid var(--surface-border); font-size: 0.75rem; color: var(--text-secondary); background: var(--surface-container-low); }
.msg-media-overflow.tall { height: 112px; width: 64px; }
.msg-cats { display: flex; flex-wrap: wrap; gap: var(--space-1); margin-top: var(--space-2); }
.msg-analysis { margin-top: var(--space-2); padding: var(--space-2) var(--space-3); border-radius: var(--radius-md); font-size: 0.75rem; border-left: 3px solid; }
.msg-analysis.flagged { background: rgba(228, 64, 95, 0.06); border-color: var(--color-ai-flagged); }
.msg-analysis.clean { background: rgba(16, 185, 129, 0.06); border-color: var(--color-ai-clean); }
.msg-analysis-row { display: flex; align-items: flex-start; gap: var(--space-2); }
.msg-analysis-icon { margin-top: 0.125rem; flex-shrink: 0; }
.msg-analysis-body { min-width: 0; flex: 1; }
.msg-analysis-summary { display: block; font-weight: 500; margin-bottom: var(--space-1); }
.msg-analysis-text { font-size: 0.75rem; line-height: 1.5; white-space: pre-wrap; }
.msg-error { margin-top: var(--space-2); padding: var(--space-2) var(--space-3); border-radius: var(--radius-md); font-size: 0.75rem; color: var(--color-warning); background: rgba(245, 158, 11, 0.06); }
.msg-actions { display: flex; align-items: center; gap: var(--space-2); margin-top: var(--space-2); }
.msg-retry-hint { font-size: 0.75rem; color: var(--text-tertiary); opacity: 0.7; }
.msg-skel { display: flex; gap: var(--space-3); padding: var(--space-4); }
.msg-skel-avatar { width: 40px; height: 40px; border-radius: 50%; background: linear-gradient(90deg, var(--surface-container-low) 25%, rgba(35, 161, 235, 0.04) 50%, var(--surface-container-low) 75%); background-size: 200% 100%; animation: shimmer 1.5s ease-in-out infinite; flex-shrink: 0; }
.msg-skel-lines { min-width: 0; flex: 1; display: flex; flex-direction: column; gap: var(--space-3); }
.msg-skel-line { height: 20px; background: linear-gradient(90deg, var(--surface-container-low) 25%, rgba(35, 161, 235, 0.04) 50%, var(--surface-container-low) 75%); background-size: 200% 100%; animation: shimmer 1.5s ease-in-out infinite; border-radius: var(--radius-md); }
.msg-skel-badges { display: flex; gap: var(--space-2); }
.msg-skel-badge { height: 24px; border-radius: 9999px; background: linear-gradient(90deg, var(--surface-container-low) 25%, rgba(35, 161, 235, 0.04) 50%, var(--surface-container-low) 75%); background-size: 200% 100%; animation: shimmer 1.5s ease-in-out infinite; }
.feed-wrap { display: flex; flex-direction: column; gap: var(--space-4); }
.feed-empty { display: flex; flex-direction: column; align-items: center; justify-content: center; padding: var(--space-16) var(--space-4); text-align: center; }
.feed-empty-title { font-size: 0.9375rem; font-weight: 600; color: var(--text-secondary); margin-bottom: var(--space-2); }
.feed-empty-desc { font-size: 0.8125rem; color: var(--text-tertiary); max-width: 280px; line-height: 1.5; }
.feed-sentinel { height: var(--space-4); }
.feed-loader { margin-top: var(--space-4); text-align: center; }
.auth-box { width: 400px; text-align: center; }
.auth-lock { font-size: 3rem; margin-bottom: var(--space-4); }
.auth-title { font-size: 1.375rem; font-weight: 700; margin-bottom: var(--space-2); background: var(--gradient-brand); -webkit-background-clip: text; -webkit-text-fill-color: transparent; background-clip: text; }
.auth-desc { font-size: 0.875rem; color: var(--text-secondary); margin-bottom: var(--space-6); }
.auth-form { display: flex; flex-direction: column; gap: var(--space-3); }
.auth-error { font-size: 0.75rem; color: var(--color-error); padding: var(--space-2) var(--space-3); background: rgba(228, 64, 95, 0.08); border-radius: var(--radius-md); }
.search-count { font-size: 0.875rem; color: var(--text-secondary); }
.icon-spin { animation: spin 1s linear infinite; }
.text-dim { color: var(--text-tertiary); opacity: 0.6; }
/* ── LIVE PANEL ───────────────────────────────── */
.live-body { display: flex; flex-direction: column; gap: var(--space-6); }
.live-head { display: flex; align-items: center; justify-content: space-between; }
.live-title { font-size: 1.5rem; font-weight: 700; letter-spacing: -0.025em; background: var(--gradient-brand); -webkit-background-clip: text; -webkit-text-fill-color: transparent; background-clip: text; }
.live-desc { font-size: 0.875rem; color: var(--text-secondary); margin-top: var(--space-1); }
.live-grid { display: grid; gap: var(--space-6); }
.live-grid-3 { grid-template-columns: repeat(3, 1fr); }
.live-span-2 { grid-column: span 2; }
@media (max-width: 1024px) { .live-grid-3 { grid-template-columns: repeat(2, 1fr); } .live-span-2 { grid-column: span 2; } }
@media (max-width: 768px) { .live-grid-3 { grid-template-columns: 1fr; } .live-span-2 { grid-column: span 1; } }
.scrn-actions { display: flex; gap: var(--space-2); }
.scrn-status { display: inline-block; padding: 2px 8px; border-radius: var(--radius-sm); font-size: 0.75rem; color: var(--color-success); }
.np-body { display: flex; flex-direction: column; gap: var(--space-2); }
.np-meta { display: flex; align-items: center; justify-content: space-between; font-size: 0.75rem; color: var(--text-secondary); }
.np-tags { display: flex; align-items: center; gap: var(--space-2); }
.np-sep { border-top: 1px solid var(--surface-border); padding-top: var(--space-3); }
.wave-wrap { border: 1px solid var(--surface-border); border-radius: var(--radius-md); padding: var(--space-3); }
.wave-progress { height: var(--space-2); border-radius: var(--radius-full); overflow: hidden; margin-bottom: var(--space-2); }
.wave-bar { height: 100%; border-radius: var(--radius-full); background: var(--gradient-primary); transition: width 200ms ease; }
.wave-info { display: flex; align-items: center; justify-content: space-between; }
.wave-title { font-size: 0.875rem; font-weight: 500; max-width: 192px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.wave-time { display: flex; align-items: center; gap: var(--space-2); font-size: 0.75rem; font-family: monospace; }
.mic-row { display: flex; align-items: center; gap: var(--space-2); }
.mic-track { position: relative; flex: 1; height: var(--space-2); border-radius: var(--radius-full); overflow: hidden; }
.mic-clip { position: absolute; height: 100%; width: 2px; }
.rec-empty { display: flex; flex-direction: column; align-items: center; justify-content: center; padding: var(--space-8); gap: var(--space-2); }
.rec-list { display: flex; flex-direction: column; gap: var(--space-2); }
.rec-item { display: flex; align-items: center; gap: var(--space-3); padding: var(--space-3); border-radius: var(--radius-md); border: 1px solid var(--surface-border); transition: all var(--transition-fast); }
.rec-item:hover { background: var(--surface-hover); border-color: var(--color-primary); transform: translateX(4px); box-shadow: var(--shadow-sm); }
.rec-info { flex: 1; min-width: 0; }
.rec-name { font-size: 0.875rem; font-weight: 500; }
.rec-meta { display: flex; align-items: center; gap: var(--space-2); font-size: 0.75rem; color: var(--text-secondary); margin-top: var(--space-1); }
.rec-actions { display: flex; align-items: center; gap: 0.375rem; flex-shrink: 0; }
.rec-footer { margin-top: var(--space-3); text-align: center; }
.speak-list { display: flex; flex-direction: column; gap: var(--space-2); }
.music-body { display: flex; flex-direction: column; gap: var(--space-2); }
.dash-panel { display: flex; flex-direction: column; gap: var(--space-6); }
.dash-head { display: flex; align-items: flex-start; justify-content: space-between; gap: var(--space-4); }
.dash-title { font-size: 1.5rem; font-weight: 700; letter-spacing: -0.025em; background: var(--gradient-brand); -webkit-background-clip: text; -webkit-text-fill-color: transparent; background-clip: text; }
.dash-desc { font-size: 0.875rem; color: var(--text-secondary); margin-top: var(--space-1); }
.img-empty { display: flex; align-items: center; justify-content: center; height: 128px; color: var(--text-secondary); font-style: italic; }
.head-left { display: flex; align-items: center; gap: var(--space-3); }
.head-right { display: flex; align-items: center; gap: var(--space-4); margin-left: auto; }
.head-status { display: flex; align-items: center; gap: var(--space-1); font-size: 0.75rem; color: var(--text-secondary); }
.head-status-dot { width: 8px; height: 8px; border-radius: 50%; transition: all var(--transition-fast); }
.head-status-dot.is-connecting { animation: pulse-connecting 1s ease-in-out infinite; }
.mascot-controls { display: flex; align-items: center; gap: var(--space-2); }
.mascot-inline { display: flex; align-items: center; gap: var(--space-1); }
+10 -57
View File
@@ -2,8 +2,9 @@ use crate::api::config as config_api;
use crate::features::dashboard::DashboardPanel;
use crate::features::live::LivePanel;
use crate::features::messages::MessagesPanel;
use crate::features::polish::components::{MascotChatbot, ParticleBackground, ThemeToggle};
use crate::features::polish::components::{MascotChatbot, ParticleBackground};
use crate::features::polish::{initial_theme, ThemeContext};
use crate::layout::sidebar::Sidebar;
use crate::ws::context::WsContext;
use leptos::prelude::*;
use shared_types::ui_state::Tab;
@@ -12,9 +13,6 @@ use crate::{log_info, log_warn, make_logger};
make_logger!();
/// Derive WebSocket URL from the page's own origin.
/// In development (serve on :8080, backend on :3001) use the detected host + /ws path.
/// In production (nginx proxies /ws to backend) the same logic works.
fn get_ws_url() -> String {
web_sys::window()
.map(|w| {
@@ -36,8 +34,6 @@ pub struct AppConfig {
pub monitor_guild_id: RwSignal<Option<String>>,
}
// ── Contexts ────────────────────────────────────────────
#[derive(Clone)]
pub struct AuthContext {
pub authenticated: RwSignal<bool>,
@@ -50,11 +46,8 @@ pub struct UiContext {
pub selected_guild: RwSignal<Option<String>>,
}
// ── App ─────────────────────────────────────────────────
#[component]
pub fn App() -> impl IntoView {
// Initialize contexts
let auth = AuthContext {
authenticated: RwSignal::new(false),
password: RwSignal::new(String::new()),
@@ -82,7 +75,6 @@ pub fn App() -> impl IntoView {
ws.connect();
log_info!("App mounted, WS connecting to {}", get_ws_url());
// Try to fetch config on startup (works if password is already in localStorage)
spawn_local({
let config = config.clone();
async move {
@@ -98,7 +90,6 @@ pub fn App() -> impl IntoView {
}
});
// Re-fetch config when user authenticates (handles first-time login)
Effect::new(move |_| {
if auth.authenticated.get() {
spawn_local({
@@ -121,57 +112,19 @@ pub fn App() -> impl IntoView {
<div data-theme=move || theme.theme.get()>
<ParticleBackground />
// Main content
<div class="app-shell">
<header class="app-header">
<div class="app-brand">
<span class="app-brand-mark">"IMPHNEN"</span>
<span class="app-brand-subtitle">"Discord Moderation"</span>
</div>
<ThemeToggle />
</header>
<Sidebar />
<main class="app-main">
<nav class="app-sidebar">
<div class="flex flex-col gap-2">
<TabButton tab=Tab::Messages ui=ui.clone() label="Pesan & Moderasi" />
<TabButton tab=Tab::Dashboard ui=ui.clone() label="Dashboard Guild" />
<TabButton tab=Tab::Live ui=ui.clone() label="Voice & Media" />
</div>
</nav>
<div class="app-content">
{move || match ui.active_tab.get() {
Tab::Messages => view! { <MessagesPanel /> }.into_any(),
Tab::Live => view! { <LivePanel /> }.into_any(),
Tab::Dashboard => view! { <DashboardPanel /> }.into_any(),
}}
</div>
</main>
<div class="app-content">
{move || match ui.active_tab.get() {
Tab::Messages => view! { <MessagesPanel /> }.into_any(),
Tab::Live => view! { <LivePanel /> }.into_any(),
Tab::Dashboard => view! { <DashboardPanel /> }.into_any(),
}}
</div>
</div>
{move || auth.authenticated.get().then(|| view! { <MascotChatbot /> })}
</div>
}
}
// ── Tab Button Helper ───────────────────────────────────
#[component]
fn TabButton(tab: Tab, ui: UiContext, label: &'static str) -> impl IntoView {
let active_tab = ui.active_tab;
let tab1 = tab.clone();
let tab2 = tab.clone();
let tab3 = tab.clone();
let tab4 = tab;
view! {
<button
class="sidebar-btn"
class:is-active=move || active_tab.get() == tab1
on:click=move |_| active_tab.set(tab4.clone())
>
{label}
</button>
}
}
+1 -4
View File
@@ -1,4 +1,3 @@
// services/frontend-leptos/frontend/src/auth.rs
use crate::api::auth as auth_api;
use crate::app::AuthContext;
use crate::app::UiContext;
@@ -36,7 +35,6 @@ pub fn AuthOverlay() -> impl IntoView {
match auth_api::login(&pwd_clone).await {
Ok(true) => {
log_info!("Auth login successful");
// Store password in sessionStorage
if let Some(storage) = web_sys::window()
.and_then(|w| w.local_storage().ok())
.flatten()
@@ -71,8 +69,8 @@ pub fn AuthOverlay() -> impl IntoView {
<div class="modal-content auth-box" on:click=move |ev| ev.stop_propagation()>
<div class="modal-body" style="position:relative">
<button
class="auth-close-btn"
on:click=skip_dismiss
style="position:absolute;top:0;right:0;background:none;border:none;color:var(--text-tertiary);font-size:1.25rem;cursor:pointer;padding:0.25rem;line-height:1"
title="Tutup"
>"×"</button>
<div class="auth-lock">
@@ -104,7 +102,6 @@ pub fn AuthOverlay() -> impl IntoView {
<button
on:click=skip_dismiss
class="btn btn-ghost btn-sm"
style="width:100%"
>
"Lihat dashboard saja"
</button>
@@ -0,0 +1,28 @@
use leptos::prelude::*;
use shared_types::message::AiStatus;
use std::sync::Arc;
// ─── Reanalyze Button ────────────────────────────────────
#[component]
pub fn ReanalyzeButton(
message_id: String,
ai_status: AiStatus,
on_reanalyze: Arc<dyn Fn(String) + Send + Sync + 'static>,
) -> impl IntoView {
let on_click_re = move |_| on_reanalyze(message_id.clone());
view! {
<div class="msg-actions">
<button
class=format!("btn btn-sm {}", if ai_status == AiStatus::Error { "btn-destructive" } else { "btn-outline" })
on:click=on_click_re
disabled=ai_status == AiStatus::Processing
>
<svg class="h-3 w-3" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 7v6h6"></path><path d="M21 17a9 9 0 00-9-9 9 9 0 00-6 2.3L3 13"></path></svg>
" Re-analyze"
</button>
{(ai_status == AiStatus::Error).then(|| view! {
<span class="msg-retry-hint">"Click to retry"</span>
})}
</div>
}
}
@@ -1,118 +1,10 @@
use leptos::prelude::*;
use regex::Regex;
use shared_types::message::{AiSeverity, AiStatus, AttachmentRef, MessageRecord};
use std::sync::{Arc, OnceLock};
use wasm_bindgen::prelude::*;
use std::sync::Arc;
// ─── Helpers ──────────────────────────────────────────────
fn custom_emoji_regex() -> &'static Regex {
static RE: OnceLock<Regex> = OnceLock::new();
RE.get_or_init(|| Regex::new(r"<(a)?:([a-zA-Z0-9_]+):(\d+)>").unwrap())
}
fn render_emojis(content: &str) -> Vec<AnyView> {
let re = custom_emoji_regex();
let mut parts: Vec<AnyView> = Vec::new();
let mut last = 0;
let content_owned = content.to_string();
for cap in re.captures_iter(&content_owned) {
let m = cap.get(0).unwrap();
if m.start() > last {
let text = content_owned[last..m.start()].to_string();
parts.push(view! { <span>{text}</span> }.into_any());
}
let animated = cap.get(1).is_some();
let name = cap.get(2).map(|c| c.as_str()).unwrap_or("").to_string();
let id = cap.get(3).map(|c| c.as_str()).unwrap_or("0").to_string();
let ext = if animated { "gif" } else { "png" };
let url = format!("https://cdn.discordapp.com/emojis/{}.{}?size=128", id, ext);
let title = format!(":{}:", name);
parts.push(
view! {
<img src=url alt=name class="custom-emoji" title=title loading="lazy" />
}
.into_any(),
);
last = m.end();
}
if last < content_owned.len() {
let text = content_owned[last..].to_string();
parts.push(view! { <span>{text}</span> }.into_any());
}
parts
}
fn time_ago(ts: i64) -> String {
let now = js_sys::Date::now() as i64;
// created_at is in milliseconds (from Discord's message.createdTimestamp)
let secs = if now > ts { (now - ts) / 1000 } else { 0 };
if secs < 60 {
format!("{}s ago", secs)
} else if secs < 3600 {
format!("{}m ago", secs / 60)
} else if secs < 86400 {
format!("{}h ago", secs / 3600)
} else {
let d = js_sys::Date::new(&JsValue::from_f64(ts as f64));
format!("{}", d.to_locale_date_string("en-US", &JsValue::UNDEFINED))
}
}
fn fmt_time(ts: i64) -> String {
let d = js_sys::Date::new(&JsValue::from_f64(ts as f64));
format!("{:02}:{:02}", d.get_hours(), d.get_minutes())
}
fn severity_class(s: &AiSeverity) -> &'static str {
match s {
AiSeverity::Critical | AiSeverity::High => "badge-destructive",
AiSeverity::Medium => "badge-warning",
AiSeverity::Low => "badge-info",
AiSeverity::None => "badge-outline",
}
}
fn is_fallback(t: &str) -> bool {
t.starts_with("[Attachment:") || t.starts_with("[Sticker:") || t.starts_with("[Embed]")
}
fn get_cats(raw: &Option<Vec<String>>) -> Vec<String> {
raw.as_ref()
.map(|v| {
v.iter()
.filter(|c| *c != "analysis_incomplete")
.cloned()
.collect()
})
.unwrap_or_default()
}
// ─── StatusBadgeInline ────────────────────────────────────
#[component]
fn StatusBadgeInline(status: AiStatus) -> impl IntoView {
let (cl, icon_svg): (&'static str, AnyView) = match &status {
AiStatus::Clean => ("status-badge-clean", view! { <svg class="h-3 w-3" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"><path d="M10 15.586L6.707 12.293a1 1 0 00-1.414 1.414l4 4a1 1 0 001.414 0l8-8a1 1 0 10-1.414-1.414L10 15.586z"></path></svg> }.into_any()),
AiStatus::Flagged => ("status-badge-flagged", view! { <svg class="h-3 w-3" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"></circle><line x1="12" y1="8" x2="12" y2="16"></line><line x1="8" y1="12" x2="16" y2="12"></line></svg> }.into_any()),
AiStatus::Error => ("status-badge-error", view! { <svg class="h-3 w-3" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"></circle><line x1="12" y1="8" x2="12" y2="16"></line><line x1="8" y1="12" x2="16" y2="12"></line></svg> }.into_any()),
AiStatus::Pending => {
("status-badge-pending", ().into_any())
},
AiStatus::Processing => {
("status-badge-processing", ().into_any())
},
AiStatus::Warn => {
("status-badge-warn", ().into_any())
},
};
view! {
<span class=format!("status-badge {}", cl)>
{icon_svg}
{format!("{:?}", status)}
</span>
}
}
use super::message_actions::ReanalyzeButton;
use super::message_embed::{MessageAnalysis, MessageError};
use super::message_meta::{fmt_time, get_cats, is_fallback, render_emojis, severity_class, StatusBadgeInline, time_ago};
// ─── MessageRow ───────────────────────────────────────────
#[component]
@@ -187,9 +79,6 @@ pub fn MessageRow(
.cloned()
.unwrap_or_default();
let reanalyze_id = message.id.clone();
let on_click_re = move |_| on_reanalyze(reanalyze_id.clone());
view! {
<div class="msg-row">
{/* Header */}
@@ -329,20 +218,10 @@ pub fn MessageRow(
{/* AI Analysis */}
{message.ai_analysis.as_ref().map(|analysis| {
let f_cls = if ai_st == AiStatus::Flagged { "flagged" } else { "clean" };
let icon = if ai_st == AiStatus::Flagged { "🚨" } else { "" };
let analysis_summary_str = analysis_summary.clone();
let analysis_str = analysis.clone();
view! {
<div class=format!("msg-analysis {}", f_cls)>
<div class="msg-analysis-row">
<span class="msg-analysis-icon">{icon}</span>
<div class="msg-analysis-body">
<span class="msg-analysis-summary">{analysis_summary_str}</span>
<div class="msg-analysis-text">{analysis_str}</div>
</div>
</div>
</div>
<MessageAnalysis analysis=analysis_str ai_status=ai_st.clone() analysis_summary=analysis_summary_str />
}
})}
@@ -350,26 +229,12 @@ pub fn MessageRow(
{message.ai_error.as_ref().map(|e| {
let error_str = e.clone();
view! {
<div class="msg-error">
<span>"AI error: "{error_str}</span>
</div>
<MessageError error=error_str />
}
})}
{/* Re-analyze */}
<div class="msg-actions">
<button
class=format!("btn btn-sm {}", if ai_st == AiStatus::Error { "btn-destructive" } else { "btn-outline" })
on:click=on_click_re
disabled=ai_st == AiStatus::Processing
>
<svg class="h-3 w-3" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 7v6h6"></path><path d="M21 17a9 9 0 00-9-9 9 9 0 00-6 2.3L3 13"></path></svg>
" Re-analyze"
</button>
{(ai_st == AiStatus::Error).then(|| view! {
<span class="msg-retry-hint">"Click to retry"</span>
})}
</div>
<ReanalyzeButton message_id=message.id.clone() ai_status=ai_st.clone() on_reanalyze=on_reanalyze.clone() />
</div>
}
}
@@ -419,7 +284,7 @@ pub fn MessageCard(
</span>
</div>
<div class="msg-card-messages" class:separated=has_multi>
{messages.into_iter().enumerate().map(|(i, msg)| {
{messages.into_iter().enumerate().map(|(_i, msg)| {
view! {
<MessageRow message=msg on_reanalyze=on_reanalyze.clone() />
}
@@ -0,0 +1,36 @@
use leptos::prelude::*;
use shared_types::message::AiStatus;
// ─── Message Analysis ─────────────────────────────────────
#[component]
pub fn MessageAnalysis(
analysis: String,
ai_status: AiStatus,
analysis_summary: String,
) -> impl IntoView {
let f_cls = if ai_status == AiStatus::Flagged { "flagged" } else { "clean" };
let icon = if ai_status == AiStatus::Flagged { "🚨" } else { "" };
view! {
<div class=format!("msg-analysis {}", f_cls)>
<div class="msg-analysis-row">
<span class="msg-analysis-icon">{icon}</span>
<div class="msg-analysis-body">
<span class="msg-analysis-summary">{analysis_summary}</span>
<div class="msg-analysis-text">{analysis}</div>
</div>
</div>
</div>
}
}
// ─── Message Error ────────────────────────────────────────
#[component]
pub fn MessageError(
error: String,
) -> impl IntoView {
view! {
<div class="msg-error">
<span>"AI error: "{error}</span>
</div>
}
}
@@ -0,0 +1,114 @@
use leptos::prelude::*;
use regex::Regex;
use shared_types::message::{AiSeverity, AiStatus};
use std::sync::OnceLock;
use wasm_bindgen::prelude::*;
// ─── Helpers ──────────────────────────────────────────────
pub fn custom_emoji_regex() -> &'static Regex {
static RE: OnceLock<Regex> = OnceLock::new();
RE.get_or_init(|| Regex::new(r"<(a)?:([a-zA-Z0-9_]+):(\d+)>").unwrap())
}
pub fn render_emojis(content: &str) -> Vec<AnyView> {
let re = custom_emoji_regex();
let mut parts: Vec<AnyView> = Vec::new();
let mut last = 0;
let content_owned = content.to_string();
for cap in re.captures_iter(&content_owned) {
let m = cap.get(0).unwrap();
if m.start() > last {
let text = content_owned[last..m.start()].to_string();
parts.push(view! { <span>{text}</span> }.into_any());
}
let animated = cap.get(1).is_some();
let name = cap.get(2).map(|c| c.as_str()).unwrap_or("").to_string();
let id = cap.get(3).map(|c| c.as_str()).unwrap_or("0").to_string();
let ext = if animated { "gif" } else { "png" };
let url = format!("https://cdn.discordapp.com/emojis/{}.{}?size=128", id, ext);
let title = format!(":{}:", name);
parts.push(
view! {
<img src=url alt=name class="custom-emoji" title=title loading="lazy" />
}
.into_any(),
);
last = m.end();
}
if last < content_owned.len() {
let text = content_owned[last..].to_string();
parts.push(view! { <span>{text}</span> }.into_any());
}
parts
}
pub fn time_ago(ts: i64) -> String {
let now = js_sys::Date::now() as i64;
// created_at is in milliseconds (from Discord's message.createdTimestamp)
let secs = if now > ts { (now - ts) / 1000 } else { 0 };
if secs < 60 {
format!("{}s ago", secs)
} else if secs < 3600 {
format!("{}m ago", secs / 60)
} else if secs < 86400 {
format!("{}h ago", secs / 3600)
} else {
let d = js_sys::Date::new(&JsValue::from_f64(ts as f64));
format!("{}", d.to_locale_date_string("en-US", &JsValue::UNDEFINED))
}
}
pub fn fmt_time(ts: i64) -> String {
let d = js_sys::Date::new(&JsValue::from_f64(ts as f64));
format!("{:02}:{:02}", d.get_hours(), d.get_minutes())
}
pub fn severity_class(s: &AiSeverity) -> &'static str {
match s {
AiSeverity::Critical | AiSeverity::High => "badge-destructive",
AiSeverity::Medium => "badge-warning",
AiSeverity::Low => "badge-info",
AiSeverity::None => "badge-outline",
}
}
pub fn is_fallback(t: &str) -> bool {
t.starts_with("[Attachment:") || t.starts_with("[Sticker:") || t.starts_with("[Embed]")
}
pub fn get_cats(raw: &Option<Vec<String>>) -> Vec<String> {
raw.as_ref()
.map(|v| {
v.iter()
.filter(|c| *c != "analysis_incomplete")
.cloned()
.collect()
})
.unwrap_or_default()
}
// ─── StatusBadgeInline ────────────────────────────────────
#[component]
pub fn StatusBadgeInline(status: AiStatus) -> impl IntoView {
let (cl, icon_svg): (&'static str, AnyView) = match &status {
AiStatus::Clean => ("status-badge-clean", view! { <svg class="h-3 w-3" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"><path d="M10 15.586L6.707 12.293a1 1 0 00-1.414 1.414l4 4a1 1 0 001.414 0l8-8a1 1 0 10-1.414-1.414L10 15.586z"></path></svg> }.into_any()),
AiStatus::Flagged => ("status-badge-flagged", view! { <svg class="h-3 w-3" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"></circle><line x1="12" y1="8" x2="12" y2="16"></line><line x1="8" y1="12" x2="16" y2="12"></line></svg> }.into_any()),
AiStatus::Error => ("status-badge-error", view! { <svg class="h-3 w-3" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"></circle><line x1="12" y1="8" x2="12" y2="16"></line><line x1="8" y1="12" x2="16" y2="12"></line></svg> }.into_any()),
AiStatus::Pending => {
("status-badge-pending", ().into_any())
},
AiStatus::Processing => {
("status-badge-processing", ().into_any())
},
AiStatus::Warn => {
("status-badge-warn", ().into_any())
},
};
view! {
<span class=format!("status-badge {}", cl)>
{icon_svg}
{format!("{:?}", status)}
</span>
}
}
@@ -1,3 +1,6 @@
pub mod image_grid;
pub mod message_card;
pub mod message_embed;
pub mod message_meta;
pub mod message_actions;
pub mod message_feed;
@@ -0,0 +1,135 @@
use leptos::prelude::*;
use shared_types::message::MessageRecord;
use std::sync::Arc;
use wasm_bindgen_futures::spawn_local;
use crate::{log_info, log_warn, make_logger};
make_logger!();
type AiFilter = &'static str;
const FILTERS: &[AiFilter] = &["all", "analyzed", "clean", "flagged", "error", "pending"];
#[component]
pub fn FilterBar(
search_query: ReadSignal<String>,
set_search_query: WriteSignal<String>,
show_search: ReadSignal<bool>,
set_show_search: WriteSignal<bool>,
is_searching: ReadSignal<bool>,
set_is_searching: WriteSignal<bool>,
set_search_results: WriteSignal<Vec<MessageRecord>>,
ai_filter: RwSignal<String>,
error_count: Memo<usize>,
retrying_all: ReadSignal<bool>,
set_retrying_all: WriteSignal<bool>,
reanalyze_all_errors: Arc<dyn Fn() + Send + Sync>,
) -> impl IntoView {
// Search handler - takes any event type and triggers the search
let do_search = {
let q = search_query;
move || {
let query = q.get();
if query.trim().is_empty() {
set_show_search.set(false);
set_search_results.set(Vec::new());
return;
}
set_is_searching.set(true);
let q_clone = query.trim().to_string();
log_info!("Messages searching for: {}", q_clone);
spawn_local(async move {
match crate::api::messages::search_messages(&q_clone, Some(50)).await {
Ok(results) => {
log_info!("Messages search found {} results", results.len());
set_search_results.set(results);
set_show_search.set(true);
}
Err(_) => {
log_warn!("Messages search failed");
set_search_results.set(Vec::new());
}
}
set_is_searching.set(false);
});
}
};
// Separate closures for different event types so on:click/on:keydown type-check
let handle_search_click = move |_: web_sys::MouseEvent| do_search();
let handle_search_keydown = move |_: web_sys::KeyboardEvent| do_search();
// Clear search
let clear_search = move |_| {
set_show_search.set(false);
set_search_results.set(Vec::new());
set_search_query.set(String::new());
};
// Filter chip click
let set_filter = {
let af = ai_filter;
move |f: &'static str| af.set(f.to_string())
};
view! {
<div class="search-bar">
<div class="search-wrap">
<svg width="16" height="16" class="search-icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="8"></circle><path d="m21 21-4.35-4.35"></path></svg>
<input
class="search-input"
placeholder="Search message content..."
prop:value=search_query
on:input=move |ev| set_search_query.set(event_target_value(&ev))
on:keydown=move |ev| {
if ev.key() == "Enter" { handle_search_keydown(ev); }
}
disabled=move || is_searching.get()
/>
</div>
<button
class="btn btn-primary btn-sm"
on:click=handle_search_click
disabled=move || is_searching.get() || search_query.get().trim().is_empty()
>
{move || if is_searching.get() { "Searching..." } else { "Search" }}
</button>
{move || show_search.get().then(|| view! {
<button class="btn btn-outline btn-sm" on:click=clear_search>
"✕ Clear"
</button>
})}
{move || {
let err = error_count.get();
(err > 0 && !show_search.get()).then(|| {
let cb = reanalyze_all_errors.clone();
let err_count = err;
view! {
<button
class="btn btn-destructive btn-sm"
on:click=move |_| {
set_retrying_all.set(true);
let cb = cb.clone();
spawn_local(async move {
cb();
set_retrying_all.set(false);
});
}
disabled=move || retrying_all.get()
>
<svg class=format!("mr-1.5 h-3.5 w-3.5{}", if retrying_all.get() { " icon-spin" } else { "" }) xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 7v6h6"></path><path d="M21 17a9 9 0 00-9-9 9 9 0 00-6 2.3L3 13"></path></svg>
{move || if retrying_all.get() { "Retrying...".to_string() } else { format!("Retry All Errors ({})", err_count) }}
</button>
}
})
}}
<div class="filter-group">
<svg width="16" height="16" style="color:var(--color-primary)" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polygon points="22 3 2 3 10 12.46 10 19 14 21 14 12.46 22 3"></polygon></svg>
{FILTERS.iter().map(|f| {
let f_ptr: &'static str = f;
view! {
<button class="filter-chip-v2" class:is-active=move || ai_filter.get() == f_ptr on:click=move |_| set_filter(f_ptr) >{*f}</button>
}
}).collect::<Vec<_>>()}
</div>
</div>
}
}
@@ -0,0 +1,87 @@
use leptos::prelude::*;
use shared_types::message::MessageRecord;
use std::sync::Arc;
use super::components::message_feed::MessageFeed;
use super::components::image_grid::ImageGrid;
#[derive(Clone, PartialEq)]
pub enum ViewTab {
All,
Images,
}
#[component]
pub fn MessageListView(
view_tab: RwSignal<ViewTab>,
show_search: ReadSignal<bool>,
search_results: ReadSignal<Vec<MessageRecord>>,
filtered_messages: Memo<Vec<MessageRecord>>,
image_messages: ReadSignal<Vec<MessageRecord>>,
loading: ReadSignal<bool>,
has_more: Memo<bool>,
loading_more: ReadSignal<bool>,
on_load_more: Arc<dyn Fn() + Send + Sync + 'static>,
on_reanalyze: Arc<dyn Fn(String) + Send + Sync + 'static>,
) -> impl IntoView {
view! {
{/* Search results count */}
{move || show_search.get().then(|| {
let n = search_results.get().len();
view! {
<div class="search-count">
"Found " {n} " result" {if n != 1 { "s" } else { "" }}
</div>
}
})}
{/* View tabs + content */}
<div class="tabs">
<div class="tab-list">
<button
class="tab-trigger"
class:active=move || view_tab.get() == ViewTab::All
on:click=move |_| view_tab.set(ViewTab::All)
aria-selected=move || if view_tab.get() == ViewTab::All { "true" } else { "false" }
>
{move || {
let label = if show_search.get() { "Search" } else { "All" };
format!("{} ({})", label, filtered_messages.with(|m| m.len()))
}}
</button>
<button
class="tab-trigger"
class:active=move || view_tab.get() == ViewTab::Images
on:click=move |_| view_tab.set(ViewTab::Images)
aria-selected=move || if view_tab.get() == ViewTab::Images { "true" } else { "false" }
>
"Images"
</button>
</div>
<div class="tab-content" style:display=move || if view_tab.get() == ViewTab::All { "block" } else { "none" }>
{move || {
let effective_has_more = if show_search.get() { false } else { has_more.get() };
let load_more_cb = on_load_more.clone();
let empty_text: &'static str = if show_search.get() { "No messages found matching your search." } else { "No captures yet." };
let on_load_more_clone: Arc<dyn Fn() + Send + Sync + 'static> = Arc::new(move || load_more_cb());
view! {
<MessageFeed
messages=filtered_messages.get()
empty_text=empty_text
loading=loading.get()
has_more=effective_has_more
loading_more=loading_more.get()
on_load_more=on_load_more_clone
on_reanalyze=on_reanalyze.clone()
/>
}
}}
</div>
<div class="tab-content" style:display=move || if view_tab.get() == ViewTab::Images { "block" } else { "none" }>
{move || view! {
<ImageGrid messages=image_messages.get() />
}}
</div>
</div>
}
}
@@ -15,7 +15,7 @@ pub fn initial_theme() -> String {
.and_then(|window| window.local_storage().ok().flatten())
.and_then(|storage| storage.get_item("imphnen-theme").ok().flatten())
.filter(|value| value == "dark" || value == "light")
.unwrap_or_else(|| "light".to_string());
.unwrap_or_else(|| "dark".to_string());
log_info!("Initial theme resolved: {}", theme);
theme
}
@@ -1,26 +1,17 @@
// services/frontend-leptos/frontend/src/layout/dashboard_layout.rs
use super::header::Header;
use super::mobile_tab_bar::MobileTabBar;
use super::sidebar::Sidebar;
use super::tab_strip::TabStrip;
use leptos::children::Children;
use leptos::prelude::*;
#[component]
pub fn DashboardLayout(children: Children) -> impl IntoView {
view! {
<div style="display: flex; flex-direction: column; height: 100vh;">
<Header />
<div style="flex: 1; display: flex; overflow: hidden;">
<Sidebar />
<main style="flex: 1; overflow: auto;">
<TabStrip />
<div style="padding: 1.5rem; max-width: 1280px;">
{children()}
</div>
</main>
</div>
<div class="app-shell">
<Sidebar />
<main class="app-content" style="padding: 0; max-width: none;">
{children()}
</main>
<MobileTabBar />
</div>
}
@@ -1,6 +1,6 @@
// services/frontend-leptos/frontend/src/layout/header.rs
use crate::ws::context::WsContext;
use crate::ws::socket::WsStatus;
use crate::ws::handlers::WsStatus;
use leptos::prelude::*;
@@ -1,62 +1,37 @@
// services/frontend-leptos/frontend/src/layout/mobile_tab_bar.rs
use crate::app::UiContext;
use leptos::prelude::*;
use shared_types::ui_state::Tab;
#[component]
pub fn MobileTabBar() -> impl IntoView {
let ui = use_context::<UiContext>().expect("UiContext not provided");
view! {
<div class="hide-desktop" style="
position: fixed;
bottom: 0;
left: 0;
right: 0;
display: flex;
background: var(--surface-base);
border-top: 1px solid var(--surface-border);
z-index: var(--z-overlay);
">
<MobileTabItem icon="message-square" label="Pesan" tab=Tab::Messages ui=ui.clone() />
<MobileTabItem icon="radio" label="Voice" tab=Tab::Live ui=ui.clone() />
<MobileTabItem icon="shield" label="Dashboard" tab=Tab::Dashboard ui=ui.clone() />
<div class="mobile-tab-bar">
<MobileTabItem label="Pesan" tab=Tab::Messages ui=ui.clone() />
<MobileTabItem label="Voice" tab=Tab::Live ui=ui.clone() />
<MobileTabItem label="Dashboard" tab=Tab::Dashboard ui=ui.clone() />
</div>
}
}
#[component]
fn MobileTabItem(
icon: &'static str,
label: &'static str,
tab: Tab,
ui: UiContext,
) -> impl IntoView {
fn MobileTabItem(label: &'static str, tab: Tab, ui: UiContext) -> impl IntoView {
let tab_active = tab.clone();
let tab_click = tab;
view! {
<button
class="mobile-tab-item"
class:is-active=move || ui.active_tab.get() == tab_active
on:click=move |_| ui.active_tab.set(tab_click.clone())
style="
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
gap: 0.25rem;
padding: 0.5rem;
background: none;
border: none;
font-family: inherit;
font-size: 0.625rem;
cursor: pointer;
transition: color var(--transition-fast);
"
style:color=move || if ui.active_tab.get() == tab_active { "var(--color-primary)" } else { "var(--text-tertiary)" }
>
<span style="font-size: 1.25rem;">
// In Phase 2, replace text with lucide-leptos icons
{icon}
<span class="mobile-tab-item-icon">
{match tab_active {
Tab::Messages => "💬",
Tab::Live => "🎮",
Tab::Dashboard => "📊",
}}
</span>
<span>{label}</span>
</button>
@@ -1,6 +1,5 @@
// services/frontend-leptos/frontend/src/layout/mod.rs
pub mod dashboard_layout;
pub mod header;
pub mod mobile_tab_bar;
pub mod sidebar;
pub mod tab_strip;
@@ -1,68 +1,118 @@
// services/frontend-leptos/frontend/src/layout/sidebar.rs
use crate::app::UiContext;
use crate::features::polish::{persist_theme, ThemeContext};
use crate::ws::context::WsContext;
use crate::ws::handlers::WsStatus;
use leptos::prelude::*;
use shared_types::ui_state::Tab;
#[component]
pub fn Sidebar() -> impl IntoView {
let ui = use_context::<UiContext>().expect("UiContext not provided");
let (collapsed, _set_collapsed) = signal(false);
let ws = use_context::<WsContext>();
let theme_ctx = use_context::<ThemeContext>();
// WS status
let ws_status = ws.as_ref().map(|w| w.status);
let status_text = Memo::new(move |_| match ws_status.map(|s| s.get()) {
Some(WsStatus::Connected) => "Online",
Some(WsStatus::Connecting) => "Menghubungkan...",
Some(WsStatus::Disconnected) => "Offline",
Some(WsStatus::Error(_)) => "Error",
None => "Offline",
});
let status_color = Memo::new(move |_| match ws_status.map(|s| s.get()) {
Some(WsStatus::Connected) => "var(--color-success)",
Some(WsStatus::Connecting) => "var(--color-warning)",
Some(WsStatus::Disconnected) => "var(--text-tertiary)",
Some(WsStatus::Error(_)) => "var(--color-error)",
None => "var(--text-tertiary)",
});
let is_connecting = Memo::new(move |_| matches!(ws_status.map(|s| s.get()), Some(WsStatus::Connecting)));
// Theme toggle
let theme_ctx_for_dark = theme_ctx.clone();
let is_dark = move || {
theme_ctx_for_dark
.as_ref()
.map(|ctx| ctx.theme.get() == "dark")
.unwrap_or(false)
};
let toggle_theme = move |_| {
if let Some(ctx) = theme_ctx.as_ref() {
let next = if ctx.theme.get() == "dark" { "light" } else { "dark" };
ctx.theme.set(next.to_string());
persist_theme(next);
}
};
view! {
<nav style:width=move || if collapsed.get() { "var(--sidebar-collapsed-width)" } else { "var(--sidebar-width)" }
style="
border-right: 1px solid var(--surface-border);
display: flex;
flex-direction: column;
padding: 1rem 0.5rem;
transition: width var(--transition-normal);
overflow: hidden;
flex-shrink: 0;
"
>
<NavItem
icon="message-square"
label="Pesan & Moderasi"
tab=Tab::Messages
ui=ui.clone()
/>
<NavItem
icon="radio"
label="Voice & Media"
tab=Tab::Live
ui=ui.clone()
/>
<NavItem
icon="shield"
label="Dashboard Guild"
tab=Tab::Dashboard
ui=ui.clone()
/>
<nav class="app-sidebar">
{/* Brand */}
<div class="sidebar-brand">
<span class="sidebar-brand-icon">""</span>
<div class="sidebar-brand-text">
<span class="sidebar-brand-name">"IMPHNEN"</span>
<span class="sidebar-brand-subtitle">"Guild Watcher"</span>
</div>
</div>
{/* Navigation */}
<div class="sidebar-nav">
<SidebarNavItem
icon="💬"
label="Pesan & Moderasi"
tab=Tab::Messages
ui=ui.clone()
/>
<SidebarNavItem
icon="🎮"
label="Voice & Media"
tab=Tab::Live
ui=ui.clone()
/>
<SidebarNavItem
icon="📊"
label="Dashboard Guild"
tab=Tab::Dashboard
ui=ui.clone()
/>
</div>
{/* Footer with WS status + Theme Toggle */}
<div class="sidebar-footer">
<div class="sidebar-footer-status">
<span
class="status-dot"
class:is-connecting=is_connecting
style:background=status_color
></span>
<span class="status-text">{move || status_text.get()}</span>
</div>
<button
class="sidebar-theme-btn"
on:click=toggle_theme
aria-label="Toggle theme"
>
{move || if is_dark() { "" } else { "" }}
</button>
</div>
</nav>
}
}
#[component]
fn NavItem(icon: &'static str, label: &'static str, tab: Tab, ui: UiContext) -> impl IntoView {
let tab_bg = tab.clone();
let tab_clr = tab.clone();
let tab_click = tab;
let handle_click = move |_| ui.active_tab.set(tab_click.clone());
let _ = icon;
fn SidebarNavItem(icon: &'static str, label: &'static str, tab: Tab, ui: UiContext) -> impl IntoView {
let tab_for_active = tab.clone();
let tab_for_click = tab;
view! {
<button
class="btn btn-ghost"
style="
justify-content: flex-start; width: 100%;
margin-bottom: 0.25rem;
"
style:background=move || if ui.active_tab.get() == tab_bg { "var(--surface-overlay)" } else { "" }
style:color=move || if ui.active_tab.get() == tab_clr { "var(--color-primary)" } else { "" }
on:click=handle_click
class="sidebar-nav-item"
class:is-active=move || ui.active_tab.get() == tab_for_active
on:click=move |_| ui.active_tab.set(tab_for_click.clone())
>
{label}
<span class="sidebar-nav-icon">{icon}</span>
<span>{label}</span>
</button>
}
}
@@ -0,0 +1,191 @@
/* ── Dashboard Panel ──────────────────────────────────── */
.dashboard-panel {
display: flex;
flex-direction: column;
gap: var(--space-6);
}
.dashboard-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: var(--space-4);
}
/* ── Stats Overview (4-column grid) ──────────────────── */
.dashboard-stats-grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: var(--space-4);
}
.dashboard-metric-card {
padding: var(--space-5);
overflow: hidden;
height: 140px;
display: flex;
flex-direction: column;
justify-content: center;
}
.dashboard-metric-card:hover {
transform: translateY(-2px);
box-shadow: var(--shadow-md);
border-color: rgba(59, 130, 246, 0.15);
}
.dashboard-metric-content {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-3);
}
.dashboard-metric-label {
color: var(--text-secondary);
font-size: 0.75rem;
font-weight: 500;
letter-spacing: 0.02em;
text-transform: uppercase;
}
.dashboard-metric-value {
margin-top: var(--space-1);
font-size: 1.75rem;
font-weight: 800;
letter-spacing: -0.03em;
color: var(--text-primary);
font-family: 'JetBrains Mono', monospace;
}
.dashboard-metric-trend {
font-size: 0.6875rem;
font-weight: 500;
margin-top: var(--space-1);
}
.dashboard-metric-trend.positive { color: var(--color-success); }
.dashboard-metric-trend.negative { color: var(--color-error); }
.dashboard-metric-icon {
display: flex;
align-items: center;
justify-content: center;
width: 2.75rem;
height: 2.75rem;
border-radius: var(--radius-lg);
font-size: 1.25rem;
}
/* ── Summary Lists ───────────────────────────────────── */
.dashboard-wide-card { grid-column: span 2; }
.dashboard-summary-list {
display: flex;
flex-direction: column;
gap: var(--space-2);
}
.dashboard-summary-row {
display: flex;
align-items: flex-start;
gap: var(--space-3);
padding: var(--space-3);
border-bottom: 1px solid var(--surface-border);
transition: all var(--transition-fast);
}
.dashboard-summary-row:hover {
background: var(--surface-hover);
}
.dashboard-summary-avatar {
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
width: 2.5rem;
height: 2.5rem;
border-radius: var(--radius-full);
background: var(--surface-container);
color: var(--text-secondary);
font-weight: 700;
overflow: hidden;
}
.dashboard-summary-avatar-img {
width: 100%;
height: 100%;
object-fit: cover;
}
.dashboard-channel-avatar {
color: var(--color-primary);
background: var(--color-primary-muted);
}
.dashboard-summary-main { min-width: 0; flex: 1; }
.dashboard-summary-title {
color: var(--text-primary);
font-size: 0.875rem;
font-weight: 600;
}
.dashboard-summary-text {
margin-top: var(--space-1);
color: var(--text-secondary);
font-size: 0.8125rem;
line-height: 1.45;
}
.dashboard-summary-meta {
display: flex;
flex-wrap: wrap;
gap: var(--space-2);
margin-top: var(--space-2);
color: var(--text-tertiary);
font-size: 0.75rem;
}
.dashboard-list-empty {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: var(--space-3);
padding: var(--space-10) var(--space-4);
text-align: center;
}
/* ── Skeleton cells ──────────────────────────────────── */
.dashboard-skel-row {
display: flex;
align-items: center;
gap: var(--space-3);
padding: var(--space-3);
}
.dashboard-skel-avatar {
width: 40px;
height: 40px;
border-radius: 50%;
flex-shrink: 0;
background: linear-gradient(90deg, var(--surface-container) 25%, rgba(59, 130, 246, 0.04) 50%, var(--surface-container) 75%);
background-size: 200% 100%;
animation: shimmer 1.5s ease-in-out infinite;
}
.dashboard-skel-lines {
flex: 1;
display: flex;
flex-direction: column;
gap: var(--space-2);
}
.dashboard-skel-line {
height: 16px;
border-radius: var(--radius-sm);
background: linear-gradient(90deg, var(--surface-container) 25%, rgba(59, 130, 246, 0.04) 50%, var(--surface-container) 75%);
background-size: 200% 100%;
animation: shimmer 1.5s ease-in-out infinite;
}
/* ── Responsive ──────────────────────────────────────── */
@media (max-width: 1024px) {
.dashboard-stats-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
}
@media (max-width: 768px) {
.dashboard-stats-grid { grid-template-columns: 1fr; }
.dashboard-wide-card { grid-column: span 1; }
}
@@ -0,0 +1,274 @@
/* ── App Shell & Layout ───────────────────────────────── */
.app-shell {
position: relative;
z-index: 1;
display: flex;
height: 100vh;
background: var(--surface-base);
color: var(--text-primary);
}
/* ── Sidebar ─────────────────────────────────────────── */
.app-sidebar {
width: var(--sidebar-width);
flex-shrink: 0;
height: 100vh;
display: flex;
flex-direction: column;
border-right: 1px solid var(--surface-border);
background: var(--surface-glass);
backdrop-filter: blur(16px);
-webkit-backdrop-filter: blur(16px);
position: sticky;
top: 0;
z-index: var(--z-sidebar);
}
.sidebar-brand {
display: flex;
align-items: center;
gap: var(--space-3);
height: 60px;
padding: 0 var(--space-4);
flex-shrink: 0;
border-bottom: 1px solid var(--surface-border);
}
.sidebar-brand-icon {
font-size: 1.25rem;
line-height: 1;
}
.sidebar-brand-text {
display: flex;
flex-direction: column;
}
.sidebar-brand-name {
font-weight: 800;
font-size: 1rem;
background: var(--gradient-brand);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
letter-spacing: -0.03em;
line-height: 1.2;
}
.sidebar-brand-subtitle {
color: var(--text-tertiary);
font-size: 0.6875rem;
font-weight: 500;
text-transform: uppercase;
letter-spacing: 0.08em;
line-height: 1.3;
}
/* ── Navigation ──────────────────────────────────────── */
.sidebar-nav {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
gap: var(--space-1);
padding: var(--space-3);
overflow-y: auto;
}
.sidebar-nav-item {
display: flex;
align-items: center;
gap: var(--space-3);
height: 40px;
padding: 0 var(--space-3);
border: none;
border-radius: 10px;
background: transparent;
color: var(--text-secondary);
font-family: inherit;
font-size: 14px;
font-weight: 500;
line-height: 20px;
cursor: pointer;
transition: all var(--transition-fast);
text-align: left;
width: 100%;
position: relative;
-webkit-tap-highlight-color: transparent;
}
.sidebar-nav-item:hover {
background: var(--surface-hover);
color: var(--color-primary);
}
.sidebar-nav-item.is-active {
background: var(--surface-overlay);
color: var(--color-primary);
font-weight: 600;
}
.sidebar-nav-item.is-active::after {
content: '';
position: absolute;
left: 0;
top: 50%;
transform: translateY(-50%);
width: 3px;
height: 24px;
border-radius: var(--radius-pill);
background: var(--gradient-primary);
box-shadow: 0 0 12px rgba(59, 130, 246, 0.4);
animation: indicator-grow 250ms cubic-bezier(0.16, 1, 0.3, 1) both;
}
.sidebar-nav-icon {
display: flex;
align-items: center;
justify-content: center;
width: 20px;
height: 20px;
flex-shrink: 0;
font-size: 1rem;
}
/* ── Sidebar Footer ──────────────────────────────────── */
.sidebar-footer {
flex-shrink: 0;
padding: var(--space-3);
border-top: 1px solid var(--surface-border);
display: flex;
align-items: center;
gap: var(--space-2);
}
.sidebar-footer-status {
display: flex;
align-items: center;
gap: var(--space-1);
flex: 1;
min-width: 0;
}
.status-dot {
width: 8px;
height: 8px;
border-radius: 50%;
flex-shrink: 0;
transition: background var(--transition-fast);
}
.status-dot.is-connecting {
animation: pulse-connecting 1s ease-in-out infinite;
}
.status-text {
font-size: 0.75rem;
color: var(--text-tertiary);
white-space: nowrap;
}
/* ── Content Area ────────────────────────────────────── */
.app-content {
flex: 1;
min-width: 0;
overflow-y: auto;
padding: var(--space-6);
max-width: 1400px;
animation: content-enter 500ms cubic-bezier(0.16, 1, 0.3, 1) both;
}
.app-content > * {
animation: card-in 500ms cubic-bezier(0.16, 1, 0.3, 1) both;
}
.app-content > :nth-child(1) { animation-delay: 30ms; }
.app-content > :nth-child(2) { animation-delay: 60ms; }
.app-content > :nth-child(3) { animation-delay: 90ms; }
/* ── Theme Toggle in Sidebar ──────────────────────────── */
.sidebar-theme-btn {
width: 32px;
height: 32px;
border: 1px solid var(--surface-border);
border-radius: var(--radius-sm);
background: var(--surface-overlay);
color: var(--text-tertiary);
cursor: pointer;
font-size: 0.875rem;
display: flex;
align-items: center;
justify-content: center;
transition: all var(--transition-fast);
flex-shrink: 0;
}
.sidebar-theme-btn:hover {
color: var(--color-primary);
border-color: var(--color-primary);
}
/* ── Mobile Tab Bar ──────────────────────────────────── */
.mobile-tab-bar {
display: none;
}
@media (max-width: 768px) {
.app-main { flex-direction: column; }
.app-sidebar { display: none; }
.app-content { padding: var(--space-4); }
.mobile-tab-bar {
display: flex;
position: fixed;
bottom: 0;
left: 0;
right: 0;
background: var(--surface-base);
border-top: 1px solid var(--surface-border);
z-index: var(--z-overlay);
}
.mobile-tab-item {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
gap: 0.25rem;
padding: 0.5rem;
background: none;
border: none;
font-family: inherit;
font-size: 0.625rem;
cursor: pointer;
color: var(--text-tertiary);
transition: color var(--transition-fast);
-webkit-tap-highlight-color: transparent;
}
.mobile-tab-item.is-active {
color: var(--color-primary);
}
.mobile-tab-item-icon {
font-size: 1.25rem;
line-height: 1;
}
}
/* ── Animations ──────────────────────────────────────── */
@keyframes content-enter {
from { opacity: 0; transform: translateY(16px); }
to { opacity: 1; transform: translateY(0); }
}
@keyframes card-in {
from { opacity: 0; transform: translateY(20px); }
to { opacity: 1; transform: translateY(0); }
}
@keyframes indicator-grow {
from { height: 0; opacity: 0; }
to { height: 24px; opacity: 1; }
}
@keyframes pulse-connecting {
0%, 100% { opacity: 1; transform: scale(1); }
50% { opacity: 0.4; transform: scale(0.7); }
}
@@ -0,0 +1,300 @@
/* ── Live Panel (Bento Grid) ──────────────────────────── */
.live-body {
display: flex;
flex-direction: column;
gap: var(--space-6);
}
.live-head {
display: flex;
align-items: center;
justify-content: space-between;
}
.live-title {
font-size: 1.5rem;
font-weight: 700;
letter-spacing: -0.025em;
background: var(--gradient-brand);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
.live-desc {
font-size: 0.875rem;
color: var(--text-secondary);
margin-top: var(--space-1);
}
/* ── Bento Grid ──────────────────────────────────────── */
.live-grid {
display: grid;
gap: var(--space-4);
}
/* 2-column bento layout */
.live-bento {
display: grid;
grid-template-columns: 1fr 1fr;
gap: var(--space-4);
}
.live-bento > .live-span-2 {
grid-column: 1 / -1;
}
@media (max-width: 768px) {
.live-bento { grid-template-columns: 1fr; }
}
/* ── Voice Connection Card ───────────────────────────── */
.voice-connection {
display: flex;
flex-direction: column;
gap: var(--space-3);
}
.voice-connection-header {
display: flex;
align-items: center;
justify-content: space-between;
}
.voice-connection-status {
display: flex;
align-items: center;
gap: var(--space-2);
font-size: 0.875rem;
}
.voice-connection-dot {
width: 10px;
height: 10px;
border-radius: 50%;
}
.voice-connection-dot.connected { background: var(--color-success); box-shadow: 0 0 8px rgba(16, 185, 129, 0.4); }
.voice-connection-dot.connecting { background: var(--color-warning); animation: pulse-connecting 1s ease-in-out infinite; }
.voice-connection-dot.disconnected { background: var(--text-tertiary); }
/* ── Active Speakers ─────────────────────────────────── */
.speak-list {
display: flex;
flex-direction: column;
gap: var(--space-2);
}
.speak-item {
display: flex;
align-items: center;
gap: var(--space-2);
padding: var(--space-2) var(--space-3);
border-radius: var(--radius-sm);
transition: background var(--transition-fast);
}
.speak-item:hover { background: var(--surface-hover); }
.speak-avatar {
width: 32px;
height: 32px;
border-radius: 50%;
background: var(--surface-container);
display: flex;
align-items: center;
justify-content: center;
font-weight: 700;
font-size: 0.75rem;
color: var(--text-secondary);
}
.speak-info { flex: 1; min-width: 0; }
.speak-name { font-size: 0.8125rem; font-weight: 500; color: var(--text-primary); }
.speak-status { font-size: 0.6875rem; color: var(--text-tertiary); }
.speak-indicator {
display: flex;
align-items: center;
gap: 2px;
}
.speak-bar {
width: 3px;
height: 12px;
border-radius: 1px;
background: var(--color-primary);
animation: bar-pulse 0.8s ease-in-out infinite;
}
.speak-bar:nth-child(2) { animation-delay: 0.1s; }
.speak-bar:nth-child(3) { animation-delay: 0.2s; }
.speak-bar:nth-child(4) { animation-delay: 0.3s; }
.speak-bar:nth-child(5) { animation-delay: 0.4s; }
@keyframes bar-pulse {
0%, 100% { transform: scaleY(1); }
50% { transform: scaleY(0.5); }
}
/* ── Audio Visualizer ────────────────────────────────── */
.audio-visualizer-canvas {
width: 100%;
height: 80px;
border-radius: var(--radius-sm);
background: var(--surface-container);
}
/* ── Mic Level Meter ─────────────────────────────────── */
.mic-level {
display: flex;
flex-direction: column;
gap: var(--space-2);
}
.mic-track {
position: relative;
height: var(--space-2);
border-radius: var(--radius-pill);
overflow: hidden;
background: var(--surface-container);
}
.mic-fill {
height: 100%;
border-radius: var(--radius-pill);
background: var(--gradient-primary);
transition: width 100ms ease;
}
.mic-clip {
position: absolute;
height: 100%;
width: 2px;
background: white;
right: 0;
}
/* ── Music Player / Now Playing ──────────────────────── */
.np-body {
display: flex;
flex-direction: column;
gap: var(--space-2);
}
.np-meta {
display: flex;
align-items: center;
justify-content: space-between;
font-size: 0.75rem;
color: var(--text-secondary);
}
.np-tags {
display: flex;
align-items: center;
gap: var(--space-2);
}
.np-sep {
border-top: 1px solid var(--surface-border);
padding-top: var(--space-3);
}
.wave-wrap {
border: 1px solid var(--surface-border);
border-radius: var(--radius-md);
padding: var(--space-3);
}
.wave-progress {
height: var(--space-2);
border-radius: var(--radius-pill);
overflow: hidden;
background: var(--surface-container);
margin-bottom: var(--space-2);
}
.wave-bar {
height: 100%;
border-radius: var(--radius-pill);
background: var(--gradient-primary);
transition: width 200ms ease;
}
.wave-info {
display: flex;
align-items: center;
justify-content: space-between;
}
.wave-title {
font-size: 0.875rem;
font-weight: 500;
max-width: 192px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.wave-time {
display: flex;
align-items: center;
gap: var(--space-2);
font-size: 0.75rem;
font-family: monospace;
}
/* ── Screen Share Panel ──────────────────────────────── */
.scrn-actions {
display: flex;
gap: var(--space-2);
}
.scrn-status {
display: inline-block;
padding: 2px 8px;
border-radius: var(--radius-sm);
font-size: 0.75rem;
color: var(--color-success);
}
/* ── Recordings Panel ────────────────────────────────── */
.rec-list {
display: flex;
flex-direction: column;
gap: var(--space-2);
}
.rec-item {
display: flex;
align-items: center;
gap: var(--space-3);
padding: var(--space-3);
border-radius: var(--radius-md);
border: 1px solid var(--surface-border);
transition: all var(--transition-fast);
}
.rec-item:hover {
background: var(--surface-hover);
border-color: var(--color-primary);
transform: translateX(4px);
}
.rec-info { flex: 1; min-width: 0; }
.rec-name { font-size: 0.875rem; font-weight: 500; }
.rec-meta {
display: flex;
align-items: center;
gap: var(--space-2);
font-size: 0.75rem;
color: var(--text-secondary);
margin-top: var(--space-1);
}
.rec-actions {
display: flex;
align-items: center;
gap: 0.375rem;
flex-shrink: 0;
}
.rec-footer { margin-top: var(--space-3); text-align: center; }
.rec-empty {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: var(--space-8);
gap: var(--space-2);
}
.music-body {
display: flex;
flex-direction: column;
gap: var(--space-2);
}
@@ -0,0 +1,13 @@
/* IMPHNEN Design System Main Import Hub *
* Order is guaranteed by @import sequence. *
* */
@import './tokens.css';
@import './reset.css';
@import './utilities.css';
@import './layout.css';
@import './ui.css';
@import './messages.css';
@import './dashboard.css';
@import './live.css';
@import './polish.css';
@@ -0,0 +1,487 @@
/* ── Messages Panel ───────────────────────────────────── */
.messages-panel { display: flex; flex-direction: column; gap: var(--space-5); }
/* ── Filter Bar ──────────────────────────────────────── */
.filter-bar {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--space-3);
}
.filter-bar-search {
position: relative;
flex: 1;
min-width: 200px;
}
.filter-bar-search-icon {
position: absolute;
left: 0.75rem;
top: 50%;
transform: translateY(-50%);
color: var(--text-tertiary);
pointer-events: none;
}
.filter-bar-input {
width: 100%;
padding: 0.5rem 0.75rem 0.5rem 2.25rem;
height: 36px;
background: var(--surface-container);
border: 1px solid transparent;
border-radius: var(--radius-pill);
color: var(--text-primary);
font-family: inherit;
font-size: 0.875rem;
transition: all var(--transition-fast);
outline: none;
}
.filter-bar-input::placeholder { color: var(--text-tertiary); }
.filter-bar-input:focus {
border-color: var(--color-primary);
box-shadow: 0 0 0 2px var(--color-primary-muted);
background: var(--surface-raised);
}
.filter-bar-chips {
display: flex;
align-items: center;
gap: 0.375rem;
flex-wrap: wrap;
}
.filter-chip {
padding: 0.25rem 0.75rem;
border-radius: var(--radius-pill);
font-size: 0.6875rem;
font-weight: 500;
border: 1px solid transparent;
background: var(--surface-hover);
color: var(--text-tertiary);
cursor: pointer;
transition: all var(--transition-fast);
font-family: inherit;
}
.filter-chip:hover {
color: var(--text-secondary);
background: var(--surface-container);
}
.filter-chip.is-active {
background: var(--gradient-primary);
color: white;
}
.filter-bar-live-count {
font-size: 0.875rem;
color: var(--text-tertiary);
white-space: nowrap;
}
/* ── Stats Bar ───────────────────────────────────────── */
.stats-bar {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--space-1);
}
/* ── Message Card ────────────────────────────────────── */
.msg-card {
background: var(--surface-raised);
border: 1px solid var(--surface-border);
border-radius: var(--radius-md);
overflow: hidden;
transition: all var(--transition-fast);
box-shadow: var(--shadow-sm);
}
.msg-card:hover {
border-color: rgba(59, 130, 246, 0.20);
box-shadow: var(--shadow-md);
transform: translateY(-1px);
}
.msg-card.is-deleted {
opacity: 0.6;
border-color: var(--color-error);
}
.msg-card-inner {
display: flex;
gap: var(--space-3);
padding: var(--space-4);
}
.msg-card-avatar {
width: 40px;
height: 40px;
flex-shrink: 0;
border-radius: 50%;
object-fit: cover;
}
.msg-card-body { min-width: 0; flex: 1; }
.msg-card-meta {
display: flex;
align-items: baseline;
gap: var(--space-2);
margin-bottom: var(--space-2);
font-size: 0.75rem;
color: var(--text-secondary);
font-family: 'JetBrains Mono', monospace;
}
.msg-card-username {
font-size: 0.8125rem;
font-weight: 600;
color: var(--text-primary);
}
.msg-card-channel {
display: inline-flex;
align-items: center;
gap: var(--space-1);
font-size: 0.6875rem;
color: var(--text-tertiary);
background: var(--surface-container);
padding: 1px 8px;
border-radius: var(--radius-pill);
}
.msg-card-channel::before { content: '#'; opacity: 0.5; }
.msg-card-time {
font-size: 0.6875rem;
color: var(--text-tertiary);
font-variant-numeric: tabular-nums;
margin-left: auto;
}
/* ── AI Badge ────────────────────────────────────────── */
.msg-card-ai-badge {
display: inline-flex;
align-items: center;
gap: 0.25rem;
padding: 2px 8px;
border-radius: 6px;
font-size: 0.625rem;
font-weight: 600;
margin-left: var(--space-1);
}
.msg-card-ai-badge.clean {
background: rgba(16, 185, 129, 0.10);
color: var(--color-ai-clean);
}
.msg-card-ai-badge.warn {
background: rgba(245, 158, 11, 0.10);
color: var(--color-ai-warn);
}
.msg-card-ai-badge.flagged {
background: rgba(239, 68, 68, 0.10);
color: var(--color-ai-flagged);
}
.msg-card-ai-badge.processing {
background: var(--color-primary-muted);
color: var(--color-ai-processing);
}
.msg-card-ai-badge.pending {
background: rgba(92, 92, 120, 0.10);
color: var(--color-ai-pending);
}
/* ── Message Content ─────────────────────────────────── */
.msg-card-content {
font-size: 0.875rem;
line-height: 1.5;
white-space: pre-wrap;
word-break: break-word;
color: var(--text-primary);
}
.msg-card-content.is-deleted {
opacity: 0.6;
color: var(--text-secondary);
}
.msg-card-messages.separated > * + * {
margin-top: 0.625rem;
padding-top: 0.625rem;
border-top: 1px solid var(--surface-border);
}
/* ── Actions Bar ─────────────────────────────────────── */
.msg-card-actions {
display: flex;
align-items: center;
gap: var(--space-2);
padding: var(--space-3) var(--space-4);
border-top: 1px solid var(--surface-border);
background: var(--surface-base);
}
.msg-card-actions .btn-icon-sm {
color: var(--text-tertiary);
}
.msg-card-actions .btn-icon-sm:hover {
color: var(--color-primary);
}
/* ── Embed ───────────────────────────────────────────── */
.msg-embed {
margin-top: var(--space-2);
padding: var(--space-3);
border-left: 3px solid var(--color-primary);
border-radius: var(--radius-sm);
background: var(--surface-overlay);
}
.msg-embed-title {
font-weight: 600;
font-size: 0.875rem;
color: var(--text-primary);
margin-bottom: var(--space-1);
}
.msg-embed-description {
font-size: 0.8125rem;
color: var(--text-secondary);
line-height: 1.5;
}
.msg-embed-fields {
display: flex;
flex-direction: column;
gap: var(--space-1);
margin-top: var(--space-2);
}
.msg-embed-field {
display: flex;
flex-direction: column;
}
.msg-embed-field-name {
font-size: 0.75rem;
font-weight: 600;
color: var(--text-secondary);
margin-bottom: 0.125rem;
}
.msg-embed-field-value {
font-size: 0.8125rem;
color: var(--text-primary);
}
.msg-embed-footer {
margin-top: var(--space-2);
font-size: 0.6875rem;
color: var(--text-tertiary);
}
/* ── Image Grid ──────────────────────────────────────── */
.image-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
gap: var(--space-2);
}
.image-grid-item {
aspect-ratio: 1;
border-radius: var(--radius-md);
overflow: hidden;
cursor: pointer;
transition: all var(--transition-fast);
border: 1px solid var(--surface-border);
}
.image-grid-item:hover {
opacity: 0.85;
transform: scale(1.02);
}
.image-grid-item img {
width: 100%;
height: 100%;
object-fit: cover;
}
/* ── Message Media ───────────────────────────────────── */
.msg-media-row {
display: flex;
flex-wrap: wrap;
gap: var(--space-2);
margin-top: var(--space-2);
}
.msg-media-row.is-scroll {
overflow-x: auto;
flex-wrap: nowrap;
}
.msg-thumb {
width: 64px;
height: 64px;
object-fit: cover;
border-radius: var(--radius-sm);
border: 1px solid var(--surface-border);
transition: all var(--transition-fast);
}
.msg-thumb:hover { transform: scale(1.08); }
.msg-thumb-link {
display: block;
flex-shrink: 0;
overflow: hidden;
border-radius: var(--radius-sm);
border: 1px solid var(--surface-border);
transition: all var(--transition-fast);
}
.msg-thumb-link:hover { border-color: var(--color-primary); }
.msg-sticker {
width: 48px;
height: 48px;
object-fit: contain;
}
.msg-video {
height: 112px;
width: 192px;
flex-shrink: 0;
border-radius: var(--radius-sm);
border: 1px solid var(--surface-border);
object-fit: cover;
background: #000;
}
.msg-media-overflow {
display: flex;
width: 64px;
height: 64px;
align-items: center;
justify-content: center;
border-radius: var(--radius-sm);
border: 1px solid var(--surface-border);
font-size: 0.75rem;
color: var(--text-secondary);
background: var(--surface-container);
}
.msg-media-overflow.tall { height: 112px; width: 64px; }
/* ── Message Categories ──────────────────────────────── */
.msg-cats {
display: flex;
flex-wrap: wrap;
gap: var(--space-1);
margin-top: var(--space-2);
}
/* ── AI Analysis ─────────────────────────────────────── */
.msg-analysis {
margin-top: var(--space-2);
padding: var(--space-2) var(--space-3);
border-radius: var(--radius-sm);
font-size: 0.75rem;
border-left: 3px solid;
}
.msg-analysis.flagged {
background: rgba(239, 68, 68, 0.06);
border-color: var(--color-ai-flagged);
}
.msg-analysis.clean {
background: rgba(16, 185, 129, 0.06);
border-color: var(--color-ai-clean);
}
.msg-analysis-row {
display: flex;
align-items: flex-start;
gap: var(--space-2);
}
.msg-analysis-body { min-width: 0; flex: 1; }
.msg-analysis-summary {
display: block;
font-weight: 500;
margin-bottom: var(--space-1);
}
.msg-analysis-text {
font-size: 0.75rem;
line-height: 1.5;
white-space: pre-wrap;
}
/* ── Message Error ───────────────────────────────────── */
.msg-error {
margin-top: var(--space-2);
padding: var(--space-2) var(--space-3);
border-radius: var(--radius-sm);
font-size: 0.75rem;
color: var(--color-warning);
background: rgba(245, 158, 11, 0.06);
}
/* ── Message Skeleton ────────────────────────────────── */
.msg-skel {
display: flex;
gap: var(--space-3);
padding: var(--space-4);
}
.msg-skel-avatar {
width: 40px;
height: 40px;
border-radius: 50%;
background: linear-gradient(90deg, var(--surface-container) 25%, rgba(59, 130, 246, 0.04) 50%, var(--surface-container) 75%);
background-size: 200% 100%;
animation: shimmer 1.5s ease-in-out infinite;
flex-shrink: 0;
}
.msg-skel-lines {
min-width: 0;
flex: 1;
display: flex;
flex-direction: column;
gap: var(--space-3);
}
.msg-skel-line {
height: 20px;
background: linear-gradient(90deg, var(--surface-container) 25%, rgba(59, 130, 246, 0.04) 50%, var(--surface-container) 75%);
background-size: 200% 100%;
animation: shimmer 1.5s ease-in-out infinite;
border-radius: var(--radius-sm);
}
.msg-skel-badges {
display: flex;
gap: var(--space-2);
}
.msg-skel-badge {
height: 24px;
border-radius: var(--radius-pill);
background: linear-gradient(90deg, var(--surface-container) 25%, rgba(59, 130, 246, 0.04) 50%, var(--surface-container) 75%);
background-size: 200% 100%;
animation: shimmer 1.5s ease-in-out infinite;
}
/* ── Feed ────────────────────────────────────────────── */
.feed-wrap {
display: flex;
flex-direction: column;
gap: var(--space-4);
}
.feed-empty {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: var(--space-16) var(--space-4);
text-align: center;
}
.feed-empty-title {
font-size: 0.9375rem;
font-weight: 600;
color: var(--text-secondary);
margin-bottom: var(--space-2);
}
.feed-empty-desc {
font-size: 0.8125rem;
color: var(--text-tertiary);
max-width: 280px;
line-height: 1.5;
}
.feed-sentinel { height: var(--space-4); }
.feed-loader { margin-top: var(--space-4); text-align: center; }
/* ── Misc ────────────────────────────────────────────── */
.search-count {
font-size: 0.875rem;
color: var(--text-secondary);
}
.icon-spin { animation: spin 1s linear infinite; }
.custom-emoji {
display: inline-block;
height: 20px;
width: 20px;
vertical-align: middle;
object-fit: contain;
}
@@ -0,0 +1,298 @@
/* ── Polish: Particles, Theme Toggle, Mascot ──────────── */
/* ── Particle Background ─────────────────────────────── */
.particle-bg {
position: fixed;
inset: 0;
overflow: hidden;
pointer-events: none;
z-index: 0;
}
.particle-orb {
position: absolute;
border-radius: 50%;
opacity: 0.5;
will-change: transform;
}
.particle-orb:nth-child(1) {
width: 600px; height: 600px;
top: -200px; right: -150px;
background: radial-gradient(circle, var(--color-primary) 0%, transparent 70%);
filter: blur(100px);
animation: orb-drift 25s ease-in-out infinite;
}
.particle-orb:nth-child(2) {
width: 500px; height: 500px;
bottom: -150px; left: -150px;
background: radial-gradient(circle, #5865f2 0%, transparent 70%);
filter: blur(100px);
animation: orb-drift 30s ease-in-out infinite reverse;
}
.particle-orb:nth-child(3) {
width: 350px; height: 350px;
top: 40%; left: 60%;
background: radial-gradient(circle, #6366f1 0%, transparent 70%);
filter: blur(100px);
animation: orb-drift 35s ease-in-out infinite;
animation-delay: -8s;
}
.particle-orb:nth-child(4) {
width: 250px; height: 250px;
top: 10%; left: 20%;
background: radial-gradient(circle, var(--color-success) 0%, transparent 70%);
opacity: 0.3;
filter: blur(100px);
animation: orb-drift 20s ease-in-out infinite;
animation-delay: -3s;
}
@keyframes orb-drift {
0% { transform: translate(0, 0); }
25% { transform: translate(40px, -30px); }
50% { transform: translate(-30px, 20px); }
75% { transform: translate(20px, -40px); }
100% { transform: translate(0, 0); }
}
@media (max-width: 768px) { .particle-bg { display: none; } }
/* ── Theme Toggle ────────────────────────────────────── */
.theme-toggle-btn {
width: 32px;
height: 32px;
border: 1px solid var(--surface-border);
border-radius: var(--radius-sm);
background: var(--surface-overlay);
color: var(--text-tertiary);
cursor: pointer;
font-size: 0.875rem;
display: flex;
align-items: center;
justify-content: center;
transition: all var(--transition-fast);
flex-shrink: 0;
-webkit-tap-highlight-color: transparent;
}
.theme-toggle-btn:hover {
color: var(--color-primary);
border-color: var(--color-primary);
}
/* ── Mascot Chatbot ──────────────────────────────────── */
.mascot-widget {
position: fixed;
right: var(--space-6);
bottom: var(--space-6);
z-index: var(--z-toast);
}
.mascot-launcher {
width: 3.75rem;
height: 3.75rem;
border: none;
border-radius: 50%;
background: var(--gradient-primary);
color: white;
box-shadow: var(--shadow-lg), 0 0 24px rgba(59, 130, 246, 0.25);
cursor: pointer;
font-size: 1.5rem;
transition: all var(--transition-fast);
animation: float 4s ease-in-out infinite;
}
.mascot-launcher:hover {
transform: translateY(-3px) scale(1.05);
animation: none;
}
.mascot-panel {
width: min(24rem, calc(100vw - 2rem));
height: min(32.5rem, calc(100vh - 7rem));
display: flex;
flex-direction: column;
overflow: hidden;
border: 1px solid var(--surface-border);
border-radius: var(--radius-lg);
background: var(--surface-raised);
box-shadow: var(--shadow-lg);
animation: fade-in-up var(--transition-bounce);
}
.mascot-panel.minimized { height: 3.75rem; }
.mascot-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-3);
padding: var(--space-3);
background: var(--gradient-primary);
color: white;
flex-shrink: 0;
}
.mascot-header-icon {
display: flex;
align-items: center;
justify-content: center;
width: 2rem;
height: 2rem;
border-radius: var(--radius-sm);
background: rgba(255,255,255,0.15);
}
.mascot-title {
font-size: 0.875rem;
font-weight: 700;
line-height: 1.15;
letter-spacing: 0.02em;
}
.mascot-subtitle { margin-top: 0.125rem; font-size: 0.6875rem; opacity: 0.7; }
.mascot-icon-button {
width: 2rem;
height: 2rem;
border: none;
border-radius: var(--radius-sm);
background: transparent;
color: white;
cursor: pointer;
transition: background var(--transition-fast);
}
.mascot-icon-button:hover { background: rgba(255,255,255,0.20); }
.mascot-messages {
flex: 1;
min-height: 0;
overflow-y: auto;
display: flex;
flex-direction: column;
gap: var(--space-3);
padding: var(--space-4);
}
.mascot-message-row {
display: flex;
gap: var(--space-2);
animation: fade-in-up var(--transition-fast) both;
}
.mascot-message-row.user { justify-content: flex-end; }
.mascot-avatar {
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
width: 1.5rem;
height: 1.5rem;
border-radius: 50%;
background: var(--surface-container);
font-size: 0.875rem;
}
.mascot-bubble {
max-width: 17.5rem;
padding: var(--space-2) var(--space-3);
border-radius: var(--radius-lg);
font-size: 0.875rem;
line-height: 1.5;
overflow-wrap: anywhere;
}
.mascot-bubble.user {
color: white;
background: var(--gradient-primary);
border-bottom-right-radius: var(--radius-sm);
}
.mascot-bubble.mascot {
color: var(--text-primary);
background: var(--surface-container);
border: 1px solid var(--surface-border);
border-bottom-left-radius: var(--radius-sm);
}
.mascot-bubble.typing {
display: flex;
gap: 0.25rem;
padding-top: 0.7rem;
padding-bottom: 0.7rem;
}
.mascot-bubble.typing span {
width: 0.5rem;
height: 0.5rem;
border-radius: 50%;
background: var(--text-tertiary);
animation: notification-pulse 0.8s infinite;
}
.mascot-bubble.typing span:nth-child(2) { animation-delay: 0.1s; }
.mascot-bubble.typing span:nth-child(3) { animation-delay: 0.2s; }
@keyframes notification-pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.3; }
}
.mascot-form {
display: flex;
gap: var(--space-2);
padding: var(--space-3);
border-top: 1px solid var(--surface-border);
}
.mascot-input {
flex: 1;
min-width: 0;
border: 1.5px solid transparent;
border-radius: var(--radius-sm);
padding: var(--space-2) var(--space-3);
background: var(--surface-container);
color: var(--text-primary);
font-size: 0.875rem;
transition: all var(--transition-fast);
}
.mascot-input:focus {
outline: none;
border-color: var(--color-primary);
box-shadow: 0 0 0 2px var(--color-primary-muted);
background: var(--surface-raised);
}
.mascot-send {
width: 2.25rem;
border: none;
border-radius: var(--radius-sm);
background: var(--gradient-primary);
color: white;
cursor: pointer;
transition: all var(--transition-fast);
}
.mascot-send:hover { box-shadow: 0 0 12px rgba(59, 130, 246, 0.25); }
.mascot-send:disabled, .mascot-input:disabled { cursor: not-allowed; opacity: 0.55; }
/* ── Float animation ─────────────────────────────────── */
@keyframes float {
0%, 100% { transform: translateY(0); }
50% { transform: translateY(-8px); }
}
/* ── Auth ────────────────────────────────────────────── */
.auth-box { width: 400px; text-align: center; }
.auth-lock { font-size: 3rem; margin-bottom: var(--space-4); }
.auth-title {
font-size: 1.375rem;
font-weight: 700;
margin-bottom: var(--space-2);
background: var(--gradient-brand);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
.auth-desc {
font-size: 0.875rem;
color: var(--text-secondary);
margin-bottom: var(--space-6);
}
.auth-form { display: flex; flex-direction: column; gap: var(--space-3); }
.auth-error {
font-size: 0.75rem;
color: var(--color-error);
padding: var(--space-2) var(--space-3);
background: rgba(239, 68, 68, 0.08);
border-radius: var(--radius-sm);
}
.auth-close-btn {
position: absolute;
top: 0;
right: 0;
background: none;
border: none;
color: var(--text-tertiary);
font-size: 1.25rem;
cursor: pointer;
padding: 0.25rem;
line-height: 1;
transition: color var(--transition-fast);
}
.auth-close-btn:hover { color: var(--text-primary); }
@@ -0,0 +1,48 @@
/* ── Reset & Base ─────────────────────────────────────── */
*, *::before, *::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
html {
font-size: 16px;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
body {
font-family: 'Poppins', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
line-height: 1.6;
min-height: 100vh;
overflow-x: hidden;
background: var(--surface-base);
color: var(--text-primary);
}
a {
color: var(--color-primary);
text-decoration: none;
transition: color var(--transition-fast);
}
a:hover { color: var(--color-primary-hover); }
img { max-width: 100%; height: auto; }
svg { display: inline-block; vertical-align: middle; }
/* ── Scrollbar ────────────────────────────────────────── */
::-webkit-scrollbar { width: 6px; height: 6px; }
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb {
background: var(--color-primary);
border-radius: var(--radius-pill);
}
::selection {
background: rgba(59, 130, 246, 0.25);
color: var(--text-primary);
}
[data-theme="light"] ::selection {
background: rgba(37, 99, 235, 0.25);
}
@@ -0,0 +1,128 @@
/* Design Tokens IMPHNEN Neuform Dark *
* Dark default (no attribute selector) *
* Light: [data-theme="light"] *
* */
:root {
/* ── Surfaces ──────────────────────────────────── */
--surface-base: #050510;
--surface-raised: #0a0a1a;
--surface-overlay: #12122a;
--surface-container: #1a1a35;
--surface-border: rgba(255, 255, 255, 0.06);
--surface-hover: rgba(59, 130, 246, 0.06);
--surface-glass: rgba(5, 5, 16, 0.78);
/* ── Text ──────────────────────────────────────── */
--text-primary: #f1f1f9;
--text-secondary: #9d9db5;
--text-tertiary: #5c5c78;
--text-inverse: #050510;
/* ── Brand ─────────────────────────────────────── */
--color-primary: #3b82f6;
--color-primary-hover: #60a5fa;
--color-primary-active: #2563eb;
--color-primary-muted: rgba(59, 130, 246, 0.12);
--gradient-primary: linear-gradient(135deg, #3b82f6 0%, #6366f1 100%);
--gradient-brand: linear-gradient(135deg, #3b82f6 0%, #5865f2 50%, #6366f1 100%);
/* ── Semantics ─────────────────────────────────── */
--color-success: #10b981;
--color-warning: #f59e0b;
--color-error: #ef4444;
--color-info: #3b82f6;
--color-destructive: #ef4444;
/* ── AI Status ─────────────────────────────────── */
--color-ai-flagged: #ef4444;
--color-ai-clean: #10b981;
--color-ai-warn: #f59e0b;
--color-ai-pending: #5c5c78;
--color-ai-processing: #3b82f6;
--color-ai-error: #dc2626;
--color-ai-deleted: #6b7280;
/* ── Shadows ───────────────────────────────────── */
--shadow-sm: 0 1px 3px rgba(0, 0, 0, 0.3);
--shadow-md: 0 4px 12px rgba(0, 0, 0, 0.35);
--shadow-lg: 0 12px 40px rgba(0, 0, 0, 0.4);
/* ── Radii ─────────────────────────────────────── */
--radius-sm: 8px;
--radius-md: 12px;
--radius-lg: 16px;
--radius-xl: 23px;
--radius-pill: 9999px;
/* ── Spacing ───────────────────────────────────── */
--space-0: 0px;
--space-1: 4px;
--space-2: 8px;
--space-3: 12px;
--space-4: 16px;
--space-5: 20px;
--space-6: 24px;
--space-8: 32px;
--space-10: 40px;
--space-12: 48px;
--space-16: 64px;
/* ── Layout ────────────────────────────────────── */
--sidebar-width: 240px;
--header-height: 0px;
/* ── Transitions ───────────────────────────────── */
--transition-fast: 150ms cubic-bezier(0.4, 0, 0.2, 1);
--transition-normal: 300ms cubic-bezier(0.4, 0, 0.2, 1);
--transition-slow: 500ms cubic-bezier(0.4, 0, 0.2, 1);
--transition-bounce: 400ms cubic-bezier(0.34, 1.56, 0.64, 1);
/* ── Z-index ───────────────────────────────────── */
--z-sidebar: 30;
--z-header: 40;
--z-overlay: 50;
--z-modal: 60;
--z-toast: 70;
}
/* ── Light Theme ──────────────────────────────────────── */
[data-theme="light"] {
--surface-base: #f4f6fb;
--surface-raised: #ffffff;
--surface-overlay: #eeeff4;
--surface-container: #dde0e8;
--surface-border: rgba(0, 0, 0, 0.06);
--surface-hover: rgba(37, 99, 235, 0.05);
--surface-glass: rgba(255, 255, 255, 0.72);
--text-primary: #0f172a;
--text-secondary: #475569;
--text-tertiary: #94a3b8;
--text-inverse: #ffffff;
--color-primary: #2563eb;
--color-primary-hover: #3b82f6;
--color-primary-active: #1d4ed8;
--color-primary-muted: rgba(37, 99, 235, 0.1);
--gradient-primary: linear-gradient(135deg, #2563eb 0%, #6366f1 100%);
--gradient-brand: linear-gradient(135deg, #2563eb 0%, #5865f2 50%, #6366f1 100%);
--color-success: #10b981;
--color-warning: #f59e0b;
--color-error: #ef4444;
--color-info: #2563eb;
--color-destructive: #ef4444;
--color-ai-flagged: #ef4444;
--color-ai-clean: #10b981;
--color-ai-warn: #f59e0b;
--color-ai-pending: #94a3b8;
--color-ai-processing: #2563eb;
--color-ai-error: #dc2626;
--color-ai-deleted: #6b7280;
--shadow-sm: 0 1px 3px rgba(0, 0, 0, 0.04);
--shadow-md: 0 4px 12px rgba(0, 0, 0, 0.06);
--shadow-lg: 0 12px 40px rgba(0, 0, 0, 0.08);
}
@@ -0,0 +1,411 @@
/* ── UI Primitives ────────────────────────────────────── */
/* ── Button ──────────────────────────────────────────── */
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: var(--space-2);
padding: 12px 24px;
height: 44px;
border: 1px solid transparent;
border-radius: var(--radius-pill);
font-family: inherit;
font-size: 14px;
font-weight: 600;
line-height: 20px;
letter-spacing: 0.02em;
cursor: pointer;
transition: all var(--transition-fast);
white-space: nowrap;
user-select: none;
-webkit-tap-highlight-color: transparent;
text-decoration: none;
}
.btn:active:not(:disabled) { transform: scale(0.97); }
.btn:disabled { opacity: 0.4; cursor: not-allowed; pointer-events: none; }
.btn-primary {
background: var(--gradient-primary);
color: white;
border: none;
}
.btn-primary:hover:not(:disabled) {
box-shadow: 0 4px 12px rgba(59, 130, 246, 0.25);
transform: translateY(-1px);
}
.btn-secondary {
background: transparent;
color: var(--color-primary);
border: 1.5px solid var(--color-primary);
}
.btn-secondary:hover:not(:disabled) {
background: var(--color-primary-muted);
transform: translateY(-1px);
}
.btn-destructive {
background: var(--color-error);
color: white;
border: none;
}
.btn-destructive:hover:not(:disabled) {
filter: brightness(1.1);
transform: translateY(-1px);
}
.btn-outline {
background: var(--surface-glass);
border: 1px solid var(--surface-border);
color: var(--text-secondary);
}
.btn-outline:hover:not(:disabled) {
background: var(--surface-raised);
border-color: var(--color-primary);
color: var(--color-primary);
}
.btn-ghost {
background: transparent;
color: var(--text-secondary);
border: none;
height: auto;
padding: 0.5rem 0.75rem;
border-radius: var(--radius-md);
}
.btn-ghost:hover:not(:disabled) {
background: var(--surface-hover);
color: var(--text-primary);
}
.btn-link {
background: transparent;
color: var(--color-primary);
border: none;
height: auto;
padding: 0;
font-weight: 500;
}
.btn-link:hover:not(:disabled) { text-decoration: underline; }
/* Sizes */
.btn-sm { padding: 8px 16px; height: 36px; font-size: 12px; border-radius: var(--radius-md); }
.btn-lg { padding: 14px 28px; height: 48px; font-size: 16px; }
.btn-icon { width: 44px; height: 44px; padding: 0; display: inline-flex; align-items: center; justify-content: center; font-size: 1.25rem; border-radius: var(--radius-pill); }
.btn-icon-sm { width: 36px; height: 36px; padding: 0; display: inline-flex; align-items: center; justify-content: center; font-size: 1rem; border-radius: var(--radius-md); }
/* ── Card ────────────────────────────────────────────── */
.card {
background: var(--surface-raised);
border: 1px solid var(--surface-border);
border-radius: var(--radius-md);
overflow: hidden;
box-shadow: var(--shadow-sm);
transition: all var(--transition-normal);
}
.card:hover {
box-shadow: var(--shadow-md);
}
.card-interactive:hover {
border-color: var(--color-primary);
box-shadow: var(--shadow-md);
transform: translateY(-1px);
}
.card-header {
padding: var(--space-4) var(--space-6);
border-bottom: 1px solid var(--surface-border);
}
.card-title {
font-size: 1rem;
font-weight: 600;
color: var(--text-primary);
}
.card-description {
font-size: 0.875rem;
color: var(--text-secondary);
margin-top: var(--space-1);
}
.card-content { padding: var(--space-6); }
.card-footer {
padding: var(--space-4) var(--space-6);
border-top: 1px solid var(--surface-border);
}
/* ── Badge ───────────────────────────────────────────── */
.badge {
display: inline-flex;
align-items: center;
padding: 4px 8px;
border-radius: var(--radius-sm);
font-size: 12px;
font-weight: 500;
line-height: 16px;
letter-spacing: 0.03em;
background: var(--surface-container);
color: var(--text-secondary);
border: none;
transition: all var(--transition-fast);
}
.badge-primary { background: var(--color-primary-muted); color: var(--color-primary); }
.badge-success { background: rgba(16, 185, 129, 0.12); color: var(--color-success); }
.badge-warning { background: rgba(245, 158, 11, 0.12); color: var(--color-warning); }
.badge-destructive { background: rgba(239, 68, 68, 0.12); color: var(--color-error); }
.badge-outline { background: transparent; border: 1px solid var(--surface-border); color: var(--text-tertiary); }
.badge-info { background: var(--color-primary-muted); color: var(--color-primary); }
/* ── Status Badge ────────────────────────────────────── */
.status-badge {
display: inline-flex;
align-items: center;
gap: var(--space-1);
padding: 3px 10px;
border-radius: var(--radius-pill);
font-size: 11px;
font-weight: 600;
letter-spacing: 0.02em;
transition: all var(--transition-fast);
}
.status-badge::before {
content: '';
width: 6px;
height: 6px;
border-radius: 50%;
flex-shrink: 0;
}
.status-badge-flagged { background: rgba(239, 68, 68, 0.12); color: var(--color-ai-flagged); }
.status-badge-flagged::before { background: var(--color-ai-flagged); box-shadow: 0 0 8px rgba(239, 68, 68, 0.6); }
.status-badge-clean { background: rgba(16, 185, 129, 0.12); color: var(--color-ai-clean); }
.status-badge-clean::before { background: var(--color-ai-clean); box-shadow: 0 0 8px rgba(16, 185, 129, 0.4); }
.status-badge-warn { background: rgba(245, 158, 11, 0.12); color: var(--color-ai-warn); }
.status-badge-warn::before { background: var(--color-ai-warn); box-shadow: 0 0 8px rgba(245, 158, 11, 0.4); }
.status-badge-pending { background: rgba(92, 92, 120, 0.12); color: var(--color-ai-pending); }
.status-badge-pending::before { background: var(--color-ai-pending); }
.status-badge-processing { background: var(--color-primary-muted); color: var(--color-ai-processing); }
.status-badge-processing::before { background: var(--color-ai-processing); animation: pulse-dot 1.5s ease-in-out infinite; }
.status-badge-error { background: rgba(220, 38, 38, 0.12); color: var(--color-ai-error); }
.status-badge-error::before { background: var(--color-ai-error); box-shadow: 0 0 8px rgba(220, 38, 38, 0.4); }
@keyframes pulse-dot {
0%, 100% { opacity: 1; }
50% { opacity: 0.3; }
}
/* ── Input ───────────────────────────────────────────── */
.input {
display: block;
width: 100%;
padding: var(--space-3);
background: var(--surface-container);
border: 1.5px solid transparent;
border-radius: var(--radius-sm);
color: var(--text-primary);
font-family: inherit;
font-size: 16px;
font-weight: 400;
line-height: 24px;
transition: all var(--transition-fast);
outline: none;
}
.input::placeholder { color: var(--text-tertiary); }
.input:focus {
border-color: var(--color-primary);
box-shadow: 0 0 0 2px var(--color-primary-muted), 0 0 16px rgba(59, 130, 246, 0.08);
background: var(--surface-raised);
}
.input[aria-invalid="true"] {
border-color: var(--color-error);
box-shadow: 0 0 0 2px rgba(239, 68, 68, 0.12);
}
/* ── Select ──────────────────────────────────────────── */
.select {
display: block;
width: 100%;
padding: var(--space-3) 2rem var(--space-3) var(--space-3);
background: var(--surface-container);
border: 1.5px solid transparent;
border-radius: var(--radius-sm);
color: var(--text-primary);
font-family: inherit;
font-size: 0.875rem;
cursor: pointer;
transition: all var(--transition-fast);
outline: none;
appearance: none;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24' fill='none' stroke='%235c5c78' stroke-width='2'%3E%3Cpolyline points='6 9 12 15 18 9'%3E%3C/polyline%3E%3C/svg%3E");
background-repeat: no-repeat;
background-position: right 0.75rem center;
}
.select:focus {
border-color: var(--color-primary);
box-shadow: 0 0 0 2px var(--color-primary-muted);
background: var(--surface-raised);
}
/* ── Skeleton ────────────────────────────────────────── */
.skeleton {
background: linear-gradient(110deg, var(--surface-container) 30%, rgba(59, 130, 246, 0.04) 50%, var(--surface-container) 70%);
background-size: 200% 100%;
animation: shimmer 1.8s ease-in-out infinite;
border-radius: var(--radius-sm);
}
.skeleton-circular { border-radius: 50%; }
@keyframes shimmer {
0% { background-position: 200% 0; }
100% { background-position: -200% 0; }
}
/* ── Empty State ─────────────────────────────────────── */
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: var(--space-16) var(--space-4);
text-align: center;
}
.empty-state-title {
font-size: 0.9375rem;
font-weight: 600;
color: var(--text-secondary);
margin-bottom: var(--space-2);
}
.empty-state-description {
font-size: 0.8125rem;
color: var(--text-tertiary);
max-width: 280px;
line-height: 1.5;
}
/* ── Tabs ────────────────────────────────────────────── */
.tabs { display: flex; flex-direction: column; }
.tab-list {
display: flex;
gap: var(--space-2);
border-bottom: 2px solid var(--surface-border);
}
.tab-trigger {
padding: 8px 16px;
background: transparent;
border: none;
border-bottom: 2px solid transparent;
margin-bottom: -2px;
color: var(--text-secondary);
font-family: inherit;
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: all var(--transition-fast);
}
.tab-trigger:hover { color: var(--text-primary); }
.tab-trigger.active,
.tab-trigger[aria-selected="true"] {
color: var(--color-primary);
font-weight: 600;
border-bottom-color: var(--color-primary);
}
.tab-content {
padding-top: var(--space-4);
animation: fade-in-up var(--transition-normal) ease-out;
}
/* ── Modal ───────────────────────────────────────────── */
.modal-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.55);
backdrop-filter: blur(8px);
-webkit-backdrop-filter: blur(8px);
display: flex;
align-items: center;
justify-content: center;
z-index: var(--z-modal);
animation: fade-in var(--transition-fast) ease-out;
}
.modal-content {
background: var(--surface-raised);
border-radius: var(--radius-lg);
border: 1px solid var(--surface-border);
box-shadow: var(--shadow-lg);
max-width: 90vw;
max-height: 85vh;
overflow: auto;
animation: scale-in var(--transition-bounce);
}
.modal-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: var(--space-4) var(--space-6);
border-bottom: 1px solid var(--surface-border);
}
.modal-body { padding: var(--space-6); }
.modal-footer {
display: flex;
justify-content: flex-end;
gap: var(--space-2);
padding: var(--space-4) var(--space-6);
border-top: 1px solid var(--surface-border);
}
/* ── Toast ───────────────────────────────────────────── */
.toast-container {
position: fixed;
bottom: var(--space-4);
right: var(--space-4);
z-index: var(--z-toast);
display: flex;
flex-direction: column;
gap: var(--space-2);
pointer-events: none;
}
.toast {
display: flex;
align-items: center;
gap: var(--space-3);
padding: 0.75rem 1rem;
background: var(--surface-raised);
border: 1px solid var(--surface-border);
border-radius: var(--radius-md);
box-shadow: var(--shadow-lg);
min-width: 300px;
max-width: 420px;
pointer-events: auto;
animation: slide-in-right var(--transition-bounce);
}
.toast-success { border-left: 3px solid var(--color-success); }
.toast-error { border-left: 3px solid var(--color-error); }
.toast-warning { border-left: 3px solid var(--color-warning); }
.toast-info { border-left: 3px solid var(--color-primary); }
.toast-close {
margin-left: auto;
background: none;
border: none;
color: var(--text-tertiary);
cursor: pointer;
padding: 0.25rem;
transition: color var(--transition-fast);
}
.toast-close:hover { color: var(--text-primary); }
/* ── Animations ──────────────────────────────────────── */
@keyframes fade-in { from { opacity: 0; } to { opacity: 1; } }
@keyframes fade-in-up { from { opacity: 0; transform: translateY(10px); } to { opacity: 1; transform: translateY(0); } }
@keyframes scale-in { from { opacity: 0; transform: scale(0.92); } to { opacity: 1; transform: scale(1); } }
@keyframes slide-in-right { from { opacity: 0; transform: translateX(120%); } to { opacity: 1; transform: translateX(0); } }
.animate-fade-in { animation: fade-in var(--transition-normal) ease-out; }
.animate-fade-in-up { animation: fade-in-up var(--transition-normal) ease-out; }
.animate-scale-in { animation: scale-in var(--transition-normal) ease-out; }
.animate-slide-in-right { animation: slide-in-right var(--transition-normal) ease-out; }
/* Reduced motion */
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
}
}
@@ -0,0 +1,220 @@
/* ── Utility Classes ──────────────────────────────────── */
/* Layout */
.flex { display: flex; }
.inline-flex { display: inline-flex; }
.grid { display: grid; }
.block { display: block; }
.hidden { display: none; }
.flex-col { flex-direction: column; }
.flex-row { flex-direction: row; }
.flex-wrap { flex-wrap: wrap; }
.flex-1 { flex: 1 1 0%; }
.flex-shrink-0, .shrink-0 { flex-shrink: 0; }
.items-center { align-items: center; }
.items-start { align-items: flex-start; }
.items-end { align-items: flex-end; }
.items-baseline { align-items: baseline; }
.justify-center { justify-content: center; }
.justify-between { justify-content: space-between; }
.justify-end { justify-content: flex-end; }
.gap-0 { gap: 0; }
.gap-1 { gap: var(--space-1); }
.gap-1\.5 { gap: 6px; }
.gap-2 { gap: var(--space-2); }
.gap-3 { gap: var(--space-3); }
.gap-4 { gap: var(--space-4); }
.gap-6 { gap: var(--space-6); }
.gap-8 { gap: var(--space-8); }
.grid-cols-2 { grid-template-columns: repeat(2, 1fr); }
.grid-cols-3 { grid-template-columns: repeat(3, 1fr); }
/* Width / Height */
.w-full { width: 100%; }
.w-auto { width: auto; }
.h-full { height: 100%; }
.h-auto { height: auto; }
.min-w-0 { min-width: 0; }
.min-h-0 { min-height: 0; }
/* Sizing helpers */
.h-3 { height: 12px; }
.h-4 { height: 16px; }
.h-5 { height: 20px; }
.h-6 { height: 24px; }
.h-8 { height: 32px; }
.h-10 { height: 40px; }
.h-12 { height: 48px; }
.h-16 { height: 64px; }
.h-28 { height: 112px; }
.w-3 { width: 12px; }
.w-4 { width: 16px; }
.w-5 { width: 20px; }
.w-6 { width: 24px; }
.w-8 { width: 32px; }
.w-10 { width: 40px; }
.w-12 { width: 48px; }
.w-16 { width: 64px; }
.w-48 { width: 192px; }
/* Position */
.relative { position: relative; }
.absolute { position: absolute; }
.fixed { position: fixed; }
.sticky { position: sticky; }
.inset-0 { inset: 0; }
.top-0 { top: 0; }
.right-0 { right: 0; }
.bottom-0 { bottom: 0; }
.left-0 { left: 0; }
/* Overflow */
.overflow-auto { overflow: auto; }
.overflow-hidden { overflow: hidden; }
.overflow-y-auto { overflow-y: auto; }
.overflow-x-auto { overflow-x: auto; }
/* Z-index */
.z-0 { z-index: 0; }
.z-10 { z-index: 10; }
.z-50 { z-index: 50; }
/* Margin */
.m-0 { margin: 0; }
.mx-auto { margin-left: auto; margin-right: auto; }
.ml-auto { margin-left: auto; }
.mr-auto { margin-right: auto; }
.mt-0 { margin-top: 0; }
.mt-1 { margin-top: var(--space-1); }
.mt-2 { margin-top: var(--space-2); }
.mt-3 { margin-top: var(--space-3); }
.mt-4 { margin-top: var(--space-4); }
.mt-6 { margin-top: var(--space-6); }
.mb-0 { margin-bottom: 0; }
.mb-1 { margin-bottom: var(--space-1); }
.mb-2 { margin-bottom: var(--space-2); }
.mb-3 { margin-bottom: var(--space-3); }
.mb-4 { margin-bottom: var(--space-4); }
.mb-6 { margin-bottom: var(--space-6); }
.ml-0 { margin-left: 0; }
.ml-1 { margin-left: var(--space-1); }
.ml-2 { margin-left: var(--space-2); }
.mr-1 { margin-right: var(--space-1); }
.mr-2 { margin-right: var(--space-2); }
.mr-1\.5 { margin-right: 6px; }
/* Padding */
.p-0 { padding: 0; }
.p-1 { padding: var(--space-1); }
.p-2 { padding: var(--space-2); }
.p-3 { padding: var(--space-3); }
.p-4 { padding: var(--space-4); }
.p-5 { padding: var(--space-5); }
.p-6 { padding: var(--space-6); }
.px-1 { padding-left: var(--space-1); padding-right: var(--space-1); }
.px-2 { padding-left: var(--space-2); padding-right: var(--space-2); }
.px-3 { padding-left: var(--space-3); padding-right: var(--space-3); }
.px-4 { padding-left: var(--space-4); padding-right: var(--space-4); }
.px-6 { padding-left: var(--space-6); padding-right: var(--space-6); }
.py-0 { padding-top: 0; padding-bottom: 0; }
.py-1 { padding-top: var(--space-1); padding-bottom: var(--space-1); }
.py-2 { padding-top: var(--space-2); padding-bottom: var(--space-2); }
.py-3 { padding-top: var(--space-3); padding-bottom: var(--space-3); }
.py-4 { padding-top: var(--space-4); padding-bottom: var(--space-4); }
.pt-2 { padding-top: var(--space-2); }
.pt-3 { padding-top: var(--space-3); }
.pt-4 { padding-top: var(--space-4); }
.pb-2 { padding-bottom: var(--space-2); }
.pb-3 { padding-bottom: var(--space-3); }
.pb-4 { padding-bottom: var(--space-4); }
.pl-2 { padding-left: var(--space-2); }
.pr-2 { padding-right: var(--space-2); }
/* Border */
.border { border: 1px solid var(--surface-border); }
.border-0 { border: none; }
.border-t { border-top: 1px solid var(--surface-border); }
.border-b { border-bottom: 1px solid var(--surface-border); }
.border-l { border-left: 1px solid var(--surface-border); }
.border-r { border-right: 1px solid var(--surface-border); }
.border-l-3 { border-left-width: 3px; }
.border-destructive { border-color: var(--color-error); }
.border-border { border-color: var(--surface-border); }
.border-primary { border-color: var(--color-primary); }
.rounded-sm { border-radius: var(--radius-sm); }
.rounded { border-radius: var(--radius-md); }
.rounded-md { border-radius: var(--radius-md); }
.rounded-lg { border-radius: var(--radius-lg); }
.rounded-xl { border-radius: var(--radius-xl); }
.rounded-full { border-radius: var(--radius-pill); }
/* Typography */
.text-left { text-align: left; }
.text-center { text-align: center; }
.text-right { text-align: right; }
.whitespace-nowrap { white-space: nowrap; }
.whitespace-pre-wrap { white-space: pre-wrap; }
.break-words { word-break: break-word; }
.truncate { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.text-xs { font-size: 0.75rem; line-height: 1rem; }
.text-sm { font-size: 0.875rem; line-height: 1.25rem; }
.text-base { font-size: 1rem; line-height: 1.5rem; }
.text-lg { font-size: 1.125rem; line-height: 1.75rem; }
.text-xl { font-size: 1.25rem; line-height: 1.75rem; }
.text-2xl { font-size: 1.5rem; line-height: 2rem; }
.text-3xl { font-size: 1.875rem; line-height: 2.25rem; }
.font-normal { font-weight: 400; }
.font-medium { font-weight: 500; }
.font-semibold { font-weight: 600; }
.font-bold { font-weight: 700; }
.font-extrabold { font-weight: 800; }
.text-primary { color: var(--text-primary); }
.text-secondary { color: var(--text-secondary); }
.text-tertiary { color: var(--text-tertiary); }
.text-primary-color { color: var(--color-primary); }
.text-error { color: var(--color-error); }
.text-success { color: var(--color-success); }
.text-warning { color: var(--color-warning); }
.text-inverse { color: var(--text-inverse); }
/* Background */
.bg-surface { background: var(--surface-raised); }
.bg-overlay { background: var(--surface-overlay); }
.bg-base { background: var(--surface-base); }
.bg-primary { background: var(--color-primary); }
.bg-transparent { background: transparent; }
/* Opacity / Misc */
.opacity-0 { opacity: 0; }
.opacity-50 { opacity: 0.5; }
.opacity-60 { opacity: 0.6; }
.opacity-70 { opacity: 0.7; }
.opacity-80 { opacity: 0.8; }
.opacity-85 { opacity: 0.85; }
.object-contain { object-fit: contain; }
.object-cover { object-fit: cover; }
.cursor-pointer { cursor: pointer; }
.cursor-default { cursor: default; }
.cursor-not-allowed { cursor: not-allowed; }
.select-none { user-select: none; }
.pointer-events-none { pointer-events: none; }
.pointer-events-auto { pointer-events: auto; }
/* Transitions */
.transition-all { transition: all var(--transition-fast); }
.transition-transform { transition: transform var(--transition-fast); }
.transition-opacity { transition: opacity var(--transition-fast); }
.transition-colors { transition: background-color var(--transition-fast), border-color var(--transition-fast), color var(--transition-fast); }
.transition-shadow { transition: box-shadow var(--transition-fast); }
.hover\:scale-105:hover { transform: scale(1.05); }
.hover\:opacity-80:hover { opacity: 0.8; }
/* Animation */
.animate-spin { animation: spin 1s linear infinite; }
@keyframes spin { from { transform: rotate(0deg); } to { transform: rotate(360deg); } }
/* Responsive */
@media (max-width: 768px) {
.grid-cols-2 { grid-template-columns: 1fr; }
.grid-cols-3 { grid-template-columns: 1fr; }
}
@@ -1,4 +1,5 @@
// services/frontend-leptos/frontend/src/ws/socket.rs
// services/frontend-leptos/frontend/src/ws/connection.rs
use crate::ws::handlers::{WsEvent, WsStatus};
use leptos::prelude::*;
use wasm_bindgen::prelude::*;
use wasm_bindgen::JsCast;
@@ -7,20 +8,6 @@ use crate::{log_debug, log_error, log_info, log_trace, log_warn, make_logger};
make_logger!();
#[derive(Debug, Clone, PartialEq)]
pub enum WsStatus {
Disconnected,
Connecting,
Connected,
Error(String),
}
#[derive(Debug, Clone)]
pub enum WsEvent {
Text(String),
Binary(Vec<u8>),
}
#[allow(clippy::type_complexity)]
pub struct WsHandle {
pub status: ReadSignal<WsStatus>,
+2 -1
View File
@@ -1,5 +1,6 @@
// services/frontend-leptos/frontend/src/ws/context.rs
use crate::ws::socket::{WsEvent, WsHandle, WsStatus};
use crate::ws::connection::WsHandle;
use crate::ws::handlers::{WsEvent, WsStatus};
use leptos::prelude::*;
use shared_types::media::MediaState;
use shared_types::message::MessageRecord;
@@ -0,0 +1,16 @@
// services/frontend-leptos/frontend/src/ws/handlers.rs
use leptos::prelude::*;
#[derive(Debug, Clone, PartialEq)]
pub enum WsStatus {
Disconnected,
Connecting,
Connected,
Error(String),
}
#[derive(Debug, Clone)]
pub enum WsEvent {
Text(String),
Binary(Vec<u8>),
}
+2 -1
View File
@@ -1,3 +1,4 @@
// services/frontend-leptos/frontend/src/ws/mod.rs
pub mod connection;
pub mod context;
pub mod socket;
pub mod handlers;