chore: remove React frontend, rename frontend-leptos to frontend
This commit is contained in:
+1
-1
@@ -13,5 +13,5 @@ logs/
|
||||
.moon/docker
|
||||
worktrees/
|
||||
.worktrees/
|
||||
frontend-leptos/frontend/dist/
|
||||
services/frontend/frontend/dist/
|
||||
target/
|
||||
|
||||
@@ -12,7 +12,7 @@ Built with **pnpm workspace monorepo** with 3 services and 1 shared library:
|
||||
|---------|------|-------------|
|
||||
| `discord-moderation-backend` | `services/backend` | Express HTTP/WS server, REST API, Redis bridge |
|
||||
| `@bete/discord-gateway` | `services/discord-gateway` | Discord client, voice recording, message capture, AI moderation |
|
||||
| `@gmw/frontend` | `services/frontend` | React 19 + Vite + Tailwind web dashboard |
|
||||
| `frontend` | `services/frontend/frontend` | Leptos 0.7 CSR WASM dashboard |
|
||||
| `@bete/shared` | `packages/shared` | Shared types, errors, logger, utilities |
|
||||
|
||||
**Database:** PostgreSQL (Drizzle ORM) — NOT SQLite.
|
||||
@@ -28,7 +28,7 @@ Discord
|
||||
|
|
||||
v
|
||||
discord-gateway ---- Redis ---- backend ---- WebSocket ---- frontend
|
||||
| pub/sub (broadcast) (React)
|
||||
| pub/sub (broadcast) (Leptos WASM)
|
||||
| |
|
||||
| |
|
||||
<------------------+
|
||||
@@ -194,19 +194,18 @@ The core service that connects to Discord using `discord.js-selfbot-v13`.
|
||||
- `src/shared/database/voiceRecordingRepo.ts` — Voice recording queries
|
||||
- `src/shared/discord/clientOptions.ts` — Discord client configuration
|
||||
|
||||
### frontend (`services/frontend`)
|
||||
### frontend (`services/frontend/frontend`)
|
||||
|
||||
React 19 + Vite 8 + Tailwind CSS 4 + TypeScript dashboard.
|
||||
Leptos 0.7 CSR WASM + TypeScript + CSS dashboard.
|
||||
|
||||
**Tech stack:**
|
||||
- React 19 with hooks
|
||||
- Vite 8 (rolldown) for bundling
|
||||
- Tailwind CSS 4 with PostCSS
|
||||
- Three.js + React Three Fiber + Drei for 3D visualizations
|
||||
- GSAP + Framer Motion for animations
|
||||
- Radix UI primitives (ScrollArea, Slot, Tabs)
|
||||
- TanStack React Query for data fetching
|
||||
- Lucide React for icons
|
||||
- Leptos 0.7 CSR (WASM via `#[wasm_bindgen(start)]`)
|
||||
- Trunk for bundling
|
||||
- Plain CSS with design tokens
|
||||
- Canvas 2D via `web-sys` for audio visualization
|
||||
- CSS animations (GSAP/Framer Motion dihapus — unused)
|
||||
- `web-sys` primitives (WebSocket, AudioContext, IntersectionObserver)
|
||||
- `lucide-leptos` 3 for icons
|
||||
|
||||
**Feature structure (entity + feature slices):**
|
||||
- `entities/` — Type exports re-exported from shared API client
|
||||
@@ -389,15 +388,16 @@ pnpm install
|
||||
# Run each service in development mode (separate terminal each)
|
||||
pnpm run dev:backend # Backend on port 3001
|
||||
pnpm run dev:discord-gateway # Discord client + all features
|
||||
pnpm run dev:web # Frontend on Vite dev server
|
||||
pnpm run dev:web # Frontend via trunk serve
|
||||
|
||||
# Build
|
||||
pnpm run build:backend
|
||||
pnpm run build:discord-gateway
|
||||
pnpm run build:web
|
||||
pnpm run build:web # trunk build --release
|
||||
|
||||
# Type checking across all packages
|
||||
pnpm run typecheck
|
||||
# Type checking
|
||||
pnpm run typecheck # Node services (pnpm -r)
|
||||
pnpm run typecheck:web # Leptos frontend (cargo check)
|
||||
|
||||
# Lint (Biome)
|
||||
pnpm run lint
|
||||
|
||||
+3
-2
@@ -7,10 +7,11 @@
|
||||
"scripts": {
|
||||
"dev:backend": "pnpm --filter './services/backend' run dev",
|
||||
"dev:discord-gateway": "pnpm --filter './services/discord-gateway' run dev",
|
||||
"dev:web": "pnpm --filter './services/frontend' run dev",
|
||||
"dev:web": "cd services/frontend/frontend && trunk serve --address 0.0.0.0",
|
||||
"build:web": "cd services/frontend/frontend && trunk build --release",
|
||||
"typecheck:web": "cd services/frontend/frontend && cargo check",
|
||||
"build:backend": "pnpm --filter './services/backend' run build",
|
||||
"build:discord-gateway": "pnpm --filter './services/discord-gateway' run build",
|
||||
"build:web": "pnpm --filter './services/frontend' run build",
|
||||
"typecheck": "pnpm -r run typecheck",
|
||||
"lint": "biome check --diagnostic-level=error .",
|
||||
"format": "biome format --write .",
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
packages:
|
||||
- services/frontend
|
||||
- services/backend
|
||||
- services/discord-gateway
|
||||
- packages/shared
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
# API/WS endpoints — set these before building
|
||||
VITE_BE_API_URL=http://localhost:3001
|
||||
VITE_BE_WS_URL=ws://localhost:3001/ws
|
||||
@@ -1,5 +1,3 @@
|
||||
# Backend API URL (default: http://localhost:3001)
|
||||
# API/WS endpoints — set these before building
|
||||
VITE_BE_API_URL=http://localhost:3001
|
||||
|
||||
# Backend WebSocket URL (default: ws://localhost:3001)
|
||||
VITE_BE_WS_URL=ws://localhost:3001
|
||||
VITE_BE_WS_URL=ws://localhost:3001/ws
|
||||
|
||||
@@ -1,753 +0,0 @@
|
||||
# Design Tokens — Bete Frontend
|
||||
|
||||
## Table of Contents
|
||||
|
||||
1. [CSS Custom Properties (OKLCH)](#1-css-custom-properties-oklch)
|
||||
2. [Z-Index Registry](#2-z-index-registry)
|
||||
3. [Spacing & Layout Patterns](#3-spacing--layout-patterns)
|
||||
4. [Typography](#4-typography)
|
||||
5. [Animation Keyframes](#5-animation-keyframes)
|
||||
6. [Animation Utility Classes](#6-animation-utility-classes)
|
||||
7. [Framer Motion Presets](#7-framer-motion-presets)
|
||||
8. [GSAP Page Transitions](#8-gsap-page-transitions)
|
||||
9. [Card Pattern](#9-card-pattern)
|
||||
10. [Stagger Animation Pattern](#10-stagger-animation-pattern)
|
||||
11. [Component Patterns & Variants](#11-component-patterns--variants)
|
||||
12. [Reduced-Motion Handling](#12-reduced-motion-handling)
|
||||
13. [Known Issues & Technical Debt](#13-known-issues--technical-debt)
|
||||
|
||||
---
|
||||
|
||||
## 1. CSS Custom Properties (OKLCH)
|
||||
|
||||
Defined in `styles.css` on `:root`. All use the OKLCH color space (`lightness chroma hue`). Consumed via `oklch(var(--name))` in CSS and `oklch(var(--name))` in the Tailwind config (`tailwind.config.js`).
|
||||
|
||||
### Surface Colors
|
||||
|
||||
| Variable | OKLCH Value | Description |
|
||||
|------------------------|--------------------------|------------------------------------|
|
||||
| `--background` | `1 0 0` | Page background (white) |
|
||||
| `--foreground` | `0.141 0.005 285.823` | Body text (near-black) |
|
||||
| `--card` | `1 0 0` | Card surface (white) |
|
||||
| `--card-foreground` | `0.141 0.005 285.823` | Card text (near-black) |
|
||||
| `--border` | `0.92 0.004 286.32` | Default border |
|
||||
| `--input` | `0.92 0.004 286.32` | Input border (same as `--border`) |
|
||||
|
||||
### Interaction Colors
|
||||
|
||||
| Variable | OKLCH Value | Description |
|
||||
|------------------------|--------------------------|------------------------------------|
|
||||
| `--primary` | `0.623 0.214 259.815` | Primary accent (blue) |
|
||||
| `--primary-soft` | `0.92 0.04 259.815` | Soft primary background |
|
||||
| `--primary-foreground` | `0.97 0.014 254.604` | Text on primary (near-white) |
|
||||
| `--secondary` | `0.967 0.001 286.375` | Secondary surface (light gray) |
|
||||
| `--secondary-foreground`| `0.21 0.006 285.885` | Text on secondary |
|
||||
| `--muted` | `0.967 0.001 286.375` | Muted surface (same as secondary) |
|
||||
| `--muted-foreground` | `0.552 0.016 285.938` | Muted text (medium gray) |
|
||||
| `--accent` | `0.967 0.001 286.375` | Accent surface |
|
||||
| `--accent-foreground` | `0.21 0.006 285.885` | Text on accent |
|
||||
| `--destructive` | `0.577 0.245 27.325` | Destructive action (reddish) |
|
||||
| `--destructive-foreground`| `0.97 0.014 254.604` | Text on destructive |
|
||||
|
||||
### Semantic / Effect Colors
|
||||
|
||||
| Variable | OKLCH Value | Description |
|
||||
|--------------------------|-------------------------------|--------------------------------------|
|
||||
| `--ring` | `0.623 0.214 259.815` | Focus ring (same as primary) |
|
||||
| `--radius` | `1rem` | Border radius base |
|
||||
| `--primary-glow` | `0.623 0.214 259.815 / 0.15` | Primary glow backdrop-filter orb |
|
||||
| `--accent-glow` | `0.552 0.016 285.938 / 0.15` | Accent glow |
|
||||
| `--card-shadow` | `0.92 0.004 286.32 / 0.3` | Card box-shadow color |
|
||||
|
||||
### Tailwind Config Mapping
|
||||
|
||||
All custom properties are wired into the Tailwind theme under `theme.extend.colors`:
|
||||
|
||||
```js
|
||||
colors: {
|
||||
border: "oklch(var(--border))",
|
||||
input: "oklch(var(--input))",
|
||||
ring: "oklch(var(--ring))",
|
||||
background: "oklch(var(--background))",
|
||||
foreground: "oklch(var(--foreground))",
|
||||
"primary-soft":"oklch(var(--primary-soft))",
|
||||
"primary-glow":"oklch(var(--primary-glow))",
|
||||
"accent-glow": "oklch(var(--accent-glow))",
|
||||
primary: { DEFAULT: "oklch(var(--primary))", foreground: "oklch(var(--primary-foreground))" },
|
||||
secondary: { DEFAULT: "oklch(var(--secondary))", foreground: "oklch(var(--secondary-foreground))" },
|
||||
muted: { DEFAULT: "oklch(var(--muted))", foreground: "oklch(var(--muted-foreground))" },
|
||||
accent: { DEFAULT: "oklch(var(--accent))", foreground: "oklch(var(--accent-foreground))" },
|
||||
destructive: { DEFAULT: "oklch(var(--destructive))", foreground: "oklch(var(--destructive-foreground))" },
|
||||
card: { DEFAULT: "oklch(var(--card))", foreground: "oklch(var(--card-foreground))" },
|
||||
},
|
||||
borderRadius: {
|
||||
lg: "var(--radius)", // 1rem
|
||||
md: "calc(var(--radius) - 2px)", // 0.875rem
|
||||
sm: "calc(var(--radius) - 4px)", // 0.75rem
|
||||
},
|
||||
```
|
||||
|
||||
### Color vs. Variable Hardcoding
|
||||
|
||||
**Prefer CSS variables**. The following are supposed to use CSS vars but are **hardcoded to Tailwind utility colors** — see [Known Issues §13.1](#131-hardcoded-utility-colors--no-css-var).
|
||||
|
||||
---
|
||||
|
||||
## 2. Z-Index Registry
|
||||
|
||||
All stacking contexts in the application, inventoried from `*.tsx` and `*.css` files.
|
||||
|
||||
| Value | Owner | Context / Location |
|
||||
|-----------|--------------------------|-------------------------------------------------|
|
||||
| `-1` | `ParticleBackground.tsx` | Inline `style={{ zIndex: -1 }}` — decorative orbs sit behind all content |
|
||||
| `10` | `Header.tsx` | Class `sticky top-0 z-10` — sticky page header |
|
||||
| `50` | `Sidebar.tsx` line 110 | Class `relative z-50` — mascot chatbot toggle button |
|
||||
| `50` | `MobileTabBar.tsx` | Class `fixed bottom-0 left-0 right-0 z-50` — mobile bottom nav |
|
||||
| `50` | `toast.tsx` | Class `fixed bottom-4 right-4 z-50` — toast notification container |
|
||||
| `[9999]` | `Sidebar.tsx` line 122 | Class `fixed bottom-[170px] left-[80px] z-[9999]` — MascotChatbot floating panel |
|
||||
|
||||
### Stacking Order (bottom to top)
|
||||
|
||||
1. **Layer -1**: Particle background orbs (`ParticleBackground`)
|
||||
2. **Layer 0**: Main page content, sidebar, cards
|
||||
3. **Layer 10**: Sticky header (`Header`)
|
||||
4. **Layer 50**: Toast container, mobile tab bar, mascot toggle button
|
||||
5. **Layer 9999**: Mascot chatbot floating panel
|
||||
|
||||
**Note**: There is no established z-index scale. Values 20, 30, 40 are unused. The arbitrary `z-[9999]` for the chatbot is an outlier — future additions should use a defined scale (e.g., 10/20/30/40/50/100) rather than arbitrary large numbers.
|
||||
|
||||
---
|
||||
|
||||
## 3. Spacing & Layout Patterns
|
||||
|
||||
### Page Layout
|
||||
|
||||
```
|
||||
DashboardLayout
|
||||
├── ParticleBackground (fixed inset-0)
|
||||
├── grid-pattern overlay (fixed inset-0, pointer-events-none)
|
||||
│
|
||||
└── flex container (relative flex min-h-screen)
|
||||
├── Sidebar (shrink-0, w-16 collapsed / w-64 expanded, hidden on <md)
|
||||
│
|
||||
└── main (flex-1, min-w-0)
|
||||
├── Header (sticky top-0 z-10, px-4 md:px-8)
|
||||
│ └── flex items-center justify-between
|
||||
│ ├── h1 + subtitle (left)
|
||||
│ └── WS/Voice badges (right)
|
||||
│
|
||||
│ (under md) MobileTabBar (fixed bottom-0 z-50)
|
||||
│
|
||||
└── main content (flex-1 overflow-auto, p-4 md:p-6 lg:p-8)
|
||||
```
|
||||
|
||||
### Common Padding Values
|
||||
|
||||
| Context | Class Pattern | Notes |
|
||||
|------------------|-----------------------------|------------------------------|
|
||||
| Card container | `p-6` | CardContent / CardHeader |
|
||||
| Card content | `p-6 pt-0` (or `p-4`) | `pt-0` when following header |
|
||||
| Card footer | `p-6 pt-0` + `flex` | |
|
||||
| Page content | `p-4 md:p-6 lg:p-8` | Responsive scaling |
|
||||
| Header | `px-4 py-4 md:px-8` | |
|
||||
| Sidebar | `px-2` (nav items) | |
|
||||
| Sub-panels | `p-4` | Music, Screen, etc. |
|
||||
| Mascot chat | `p-4` (messages), `p-3` (input) | |
|
||||
|
||||
### Common Gap Values
|
||||
|
||||
| Pattern | Usage |
|
||||
|----------------|--------------------------------------------|
|
||||
| `gap-3` | Sidebar nav items, message card avatar+text, recording items, feed groups |
|
||||
| `gap-4` | Stat card grid (>sm), user card 2-col, user detail layout |
|
||||
| `gap-6` | Top-level panel sections (dashboard, live) |
|
||||
| `gap-2` | Filter badges, button groups, message row indicators, toast container |
|
||||
| `space-y-1` | Compact text+value pairs |
|
||||
| `space-y-2` | Active speakers list, recording list, message metadata blocks |
|
||||
| `space-y-3` | Message feed items, skeleton groups |
|
||||
| `space-y-4` | Auth form, music panel |
|
||||
| `space-y-6` | User profile detail sections |
|
||||
|
||||
### Responsive Grid Breakpoints
|
||||
|
||||
- `sm:grid-cols-2` — stat cards, user cards, recordings cards
|
||||
- `lg:grid-cols-4` — stat summary cards
|
||||
- `xl:grid-cols-3`, `2xl:grid-cols-4` — image grid
|
||||
- `xl:grid-cols-[1fr_320px]` — live audio + sidebar layout
|
||||
- `md:grid-cols-2` — voice connection guild/channel selects
|
||||
- `md:grid-cols-3` — moderation queue stat cards
|
||||
|
||||
---
|
||||
|
||||
## 4. Typography
|
||||
|
||||
### Font Family
|
||||
|
||||
```css
|
||||
font-family: Poppins, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
```
|
||||
|
||||
Defined in `styles.css` and `tailwind.config.js` under `theme.extend.fontFamily.sans`.
|
||||
|
||||
### Type Scale
|
||||
|
||||
| Class | Used For |
|
||||
|----------------|--------------------------------------------|
|
||||
| `text-[10px]` | Badge text, filter labels, status chips |
|
||||
| `text-[11px]` | Message timestamps, edited/deleted indicators, action hints |
|
||||
| `text-[12px]` | AI analysis body text |
|
||||
| `text-xs` | Captions, descriptions, metadata, secondary info, badge text |
|
||||
| `text-sm` | Body text, card descriptions, message content, form labels |
|
||||
| `text-base` | Card titles (sometimes), live audio heading |
|
||||
| `text-lg` | Section headings, stat values |
|
||||
| `text-xl` | Page title (`h1`) |
|
||||
| `text-2xl` | Stat card values, moderation queue numbers |
|
||||
|
||||
### Font Weights
|
||||
|
||||
| Weight | Usage |
|
||||
|-----------------|--------------------------------------------|
|
||||
| `font-medium` | Sidebar items, timestamps, badge text, section labels, queue items |
|
||||
| `font-semibold` | CardTitle, user names, tracking-tight headings |
|
||||
| `font-bold` | Page titles, stat values, key numbers |
|
||||
|
||||
### Tracking
|
||||
|
||||
- `tracking-tight`: CardTitle, page `h1`
|
||||
- `tracking-wider`: Image grid kind badges
|
||||
|
||||
### Font Mono
|
||||
|
||||
- `font-mono`: Channel IDs, user IDs
|
||||
|
||||
---
|
||||
|
||||
## 5. Animation Keyframes
|
||||
|
||||
### Defined in `styles.css`
|
||||
|
||||
```css
|
||||
@keyframes bar-pulse {
|
||||
0%, 100% { transform: scaleY(0.8); }
|
||||
50% { transform: scaleY(1.2); }
|
||||
}
|
||||
|
||||
@keyframes shimmer {
|
||||
0% { background-position: -200% 0; }
|
||||
100% { background-position: 200% 0; }
|
||||
}
|
||||
|
||||
@keyframes fadeInUp {
|
||||
from { opacity: 0; transform: translateY(20px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from { opacity: 0; }
|
||||
to { opacity: 1; }
|
||||
}
|
||||
```
|
||||
|
||||
### Defined in `tailwind.config.js` only (`glowPulse`)
|
||||
|
||||
```js
|
||||
keyframes: {
|
||||
glowPulse: {
|
||||
"0%, 100%": { opacity: "0.4" },
|
||||
"50%": { opacity: "0.8" },
|
||||
},
|
||||
},
|
||||
```
|
||||
|
||||
**Note**: `glowPulse` is NOT defined in `styles.css`. It only exists in `tailwind.config.js` and is used by `ParticleBackground.tsx` via the class `animate-glow-pulse`. This works because Tailwind 4 uses the JS config for keyframes, but the CSS file is the canonical keyframe source. This is a **latent inconsistency** — see Known Issues §13.3.
|
||||
|
||||
### Animation Timings Summary
|
||||
|
||||
| Keyframe | Duration | Timing Function | Used In |
|
||||
|----------------|----------|------------------------|--------------------------------|
|
||||
| bar-pulse | 0.4s | ease-in-out | AudioVisualizer (via CSS class)|
|
||||
| shimmer | 1.5s (CSS) / 2s (TW config) | ease-in-out | Skeleton loading components |
|
||||
| fadeInUp | 0.5s | ease-out | Utility class `.animate-fade-in-up` |
|
||||
| fadeIn | 0.3s | ease-out | Utility class `.animate-fade-in` |
|
||||
| glowPulse | 3s | ease-in-out | ParticleBackground glow orbs |
|
||||
|
||||
**Mismatch**: `shimmer` duration is `1.5s` in `styles.css` but `2s` in `tailwind.config.js`. The CSS class `.animate-shimmer` is used by `Skeleton.tsx`, so the CSS definition wins.
|
||||
|
||||
---
|
||||
|
||||
## 6. Animation Utility Classes
|
||||
|
||||
Defined in `styles.css` under `@layer utilities`:
|
||||
|
||||
| Class | Animation | Purpose |
|
||||
|----------------------|------------------------------|--------------------------------|
|
||||
| `.animate-fade-in-up`| `fadeInUp 0.5s ease-out` | Entry animation for elements |
|
||||
| `.animate-fade-in` | `fadeIn 0.3s ease-out` | Simple fade-in |
|
||||
| `.animate-bar-pulse` | `bar-pulse 0.4s ease-in-out infinite` | Audio visualizer bars (transform-origin: bottom) |
|
||||
| `.animate-shimmer` | `shimmer 1.5s ease-in-out infinite` | Skeleton loading placeholder (gradient sweep) |
|
||||
|
||||
Tailwind config also registers `animate-glow-pulse` (`glowPulse 3s ease-in-out infinite`).
|
||||
|
||||
**Usage**: `Skeleton` component uses `animate-shimmer`. Inline `animate-spin` (Tailwind built-in) is used for loading spinners on re-analyze buttons.
|
||||
|
||||
---
|
||||
|
||||
## 7. Framer Motion Presets
|
||||
|
||||
All defined in `shared/hooks/useFramerStagger.ts`.
|
||||
|
||||
### `cardStagger` — Parent container variant
|
||||
```ts
|
||||
{
|
||||
initial: { opacity: 0 },
|
||||
animate: {
|
||||
opacity: 1,
|
||||
transition: { staggerChildren: 0.08, delayChildren: 0.1 },
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
### `cardItem` — Child item (fade + slide up)
|
||||
```ts
|
||||
{
|
||||
initial: { opacity: 0, y: 20 },
|
||||
animate: {
|
||||
opacity: 1, y: 0,
|
||||
transition: { duration: 0.4, ease: "easeOut" },
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
### `fadeSlideUp` — Single element (cubic-bezier)
|
||||
```ts
|
||||
{
|
||||
initial: { opacity: 0, y: 24 },
|
||||
animate: {
|
||||
opacity: 1, y: 0,
|
||||
transition: { duration: 0.5, ease: [0.25, 0.46, 0.45, 0.94] },
|
||||
},
|
||||
exit: { opacity: 0, y: -12, transition: { duration: 0.2 } },
|
||||
}
|
||||
```
|
||||
Used by `Header.tsx` (key=activeTab) and `DashboardLayout.tsx` (main content area, key=activeTab).
|
||||
|
||||
### `fadeIn` — Simple opacity
|
||||
```ts
|
||||
{
|
||||
initial: { opacity: 0 },
|
||||
animate: { opacity: 1, transition: { duration: 0.3 } },
|
||||
exit: { opacity: 0, transition: { duration: 0.2 } },
|
||||
}
|
||||
```
|
||||
|
||||
### `scaleIn` — Badge/pill entrance
|
||||
```ts
|
||||
{
|
||||
initial: { opacity: 0, scale: 0.8 },
|
||||
animate: { opacity: 1, scale: 1, transition: { duration: 0.3, ease: "backOut" } },
|
||||
}
|
||||
```
|
||||
|
||||
### `springUp` — Emphasized entrance
|
||||
```ts
|
||||
{
|
||||
initial: { opacity: 0, y: 30 },
|
||||
animate: { opacity: 1, y: 0, transition: { type: "spring", stiffness: 200, damping: 20 } },
|
||||
}
|
||||
```
|
||||
|
||||
### Usage Pattern
|
||||
|
||||
```tsx
|
||||
<motion.div variants={cardStagger} initial="initial" animate="animate">
|
||||
<motion.div variants={cardItem}>...</motion.div>
|
||||
<motion.div variants={cardItem}>...</motion.div>
|
||||
</motion.div>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 8. GSAP Page Transitions
|
||||
|
||||
### `useGsapTransition` (`shared/hooks/useGsapTransition.ts`)
|
||||
|
||||
A custom hook for tab-level page transitions, used by `AuthOverlay.tsx`.
|
||||
|
||||
**Page enter animation**:
|
||||
- Container: opacity 0→1, y 20→0, `duration: 0.4`, `ease: "power2.out"`
|
||||
- Stagger children: querySelectorAll `[data-stagger]`, `duration: 0.3`, `stagger: 0.05`
|
||||
- Reduced motion: all durations set to 0
|
||||
|
||||
**Page exit animation** (returns Promise):
|
||||
- Container: opacity→0, y→20, `duration: 0.3`, `ease: "power2.in"`
|
||||
|
||||
**Card hover helper** (`gsapCardHover`):
|
||||
```ts
|
||||
onMouseEnter: gsap.to(target, { y: -4, boxShadow: "0 8px 25px rgba(0,0,0,0.15)", duration: 0.2 })
|
||||
onMouseLeave: gsap.to(target, { y: 0, boxShadow: "0 2px 8px rgba(0,0,0,0.08)", duration: 0.2 })
|
||||
```
|
||||
(Exported but unused in current components — kept as utility.)
|
||||
|
||||
---
|
||||
|
||||
## 9. Card Pattern
|
||||
|
||||
### Base `Card` component (`shared/ui/card.tsx`)
|
||||
|
||||
```
|
||||
rounded-xl border border-border bg-card text-card-foreground shadow-sm hover:shadow-md transition-shadow
|
||||
```
|
||||
|
||||
### Composition
|
||||
|
||||
| Sub-component | Classes | Notes |
|
||||
|----------------|------------------------------|--------------------------------|
|
||||
| `Card` | See above | `transition-shadow` for hover |
|
||||
| `CardHeader` | `flex flex-col space-y-1.5 p-6` | |
|
||||
| `CardTitle` | `font-semibold leading-none tracking-tight` | `h3` element |
|
||||
| `CardDescription` | `text-sm text-muted-foreground` | `p` element |
|
||||
| `CardContent` | `p-6 pt-0` | `pt-0` to collapse with header |
|
||||
| `CardFooter` | `flex items-center p-6 pt-0` | |
|
||||
|
||||
### Common Card Variants
|
||||
|
||||
| Context | Additional Classes |
|
||||
|------------------|-----------------------------------------------------|
|
||||
| Stat card | `CardContent p-4` (compact) |
|
||||
| Dashboard stat | `overflow-hidden` |
|
||||
| Message card | `group hover:border-primary/30 hover:shadow-md` |
|
||||
| Auth card | `w-full max-w-md border-primary/30 shadow-lg shadow-primary/10` |
|
||||
| Error/empty card | `border-dashed border-destructive` (error state) |
|
||||
| User card | `hover:ring-1 hover:ring-primary/30 cursor-pointer` |
|
||||
| Media queue item | `rounded-lg border-l-2 border-l-primary border-border` |
|
||||
|
||||
### Empty State Pattern
|
||||
|
||||
```tsx
|
||||
<div className="flex flex-col items-center gap-4 py-12">
|
||||
<MascotImage size="md" className="opacity-60" />
|
||||
<p className="text-sm text-muted-foreground">No data to display</p>
|
||||
</div>
|
||||
```
|
||||
Wrapped in `EmptyStateMascot` component. Also used via standalone icon pattern:
|
||||
```tsx
|
||||
<div className="flex flex-col items-center gap-4 py-20 text-muted-foreground">
|
||||
<BarChart3 className="h-10 w-10" />
|
||||
<p className="text-sm">No data available yet.</p>
|
||||
</div>
|
||||
```
|
||||
|
||||
### Skeleton Loading Pattern
|
||||
|
||||
```tsx
|
||||
<Skeleton className="rounded-lg bg-muted animate-shimmer" />
|
||||
```
|
||||
Used in `.animate-shimmer` containers matching real card layout. See `MessageCardSkeleton`, `StatsSkeleton`, `UserListSkeleton`, `DetailSkeleton`, `ActiveSpeakersSkeleton`.
|
||||
|
||||
---
|
||||
|
||||
## 10. Stagger Animation Pattern
|
||||
|
||||
### Framer Motion (primary, used in panels)
|
||||
|
||||
Applied as `cardStagger` / `cardItem` pair on every major panel:
|
||||
|
||||
| Panel | File |
|
||||
|----------------------|----------------------------------|
|
||||
| LivePanel | `features/live/index.tsx` |
|
||||
| MessagesPanel | `features/messages/index.tsx` |
|
||||
| DashboardStatsContent| `features/dashboard/components/DashboardStats.tsx` |
|
||||
| UserSummaryList | `features/dashboard/components/UserSummaryList.tsx` |
|
||||
| UserProfileDetail | `features/dashboard/components/UserProfileDetail.tsx` |
|
||||
| MessageFeed | `features/messages/components/MessageFeed.tsx` |
|
||||
|
||||
**Timing**: stagger 80ms, delayChildren 100ms, item duration 400ms.
|
||||
|
||||
### GSAP (used in auth flow)
|
||||
|
||||
`useGsapTransition` with `[data-stagger]` attributes. Stagger 50ms, duration 300ms.
|
||||
|
||||
---
|
||||
|
||||
## 11. Component Patterns & Variants
|
||||
|
||||
### Button (`shared/ui/button.tsx`)
|
||||
|
||||
**Variants**: `default`, `secondary`, `destructive`, `outline`, `ghost`
|
||||
**Sizes**: `default` (h-10), `sm` (h-9), `lg` (h-11), `icon` (h-10 w-10)
|
||||
|
||||
Base classes:
|
||||
```
|
||||
inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-lg text-sm font-medium
|
||||
transition-all duration-200 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring
|
||||
active:scale-[0.97] disabled:pointer-events-none disabled:opacity-50
|
||||
```
|
||||
|
||||
| Variant | Background |
|
||||
|--------------|---------------------------------------|
|
||||
| default | `bg-primary text-primary-foreground shadow-sm hover:bg-primary/90` |
|
||||
| secondary | `bg-secondary text-secondary-foreground hover:bg-secondary/80` |
|
||||
| destructive | `bg-destructive text-destructive-foreground hover:bg-destructive/90` |
|
||||
| outline | `border border-input bg-background hover:bg-accent hover:text-accent-foreground` |
|
||||
| ghost | `hover:bg-accent hover:text-accent-foreground` |
|
||||
|
||||
### Badge (`shared/ui/badge.tsx`)
|
||||
|
||||
**Variants**: `default`, `secondary`, `destructive`, `outline`, `success`, `warning`
|
||||
|
||||
Base classes:
|
||||
```
|
||||
inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-medium transition-colors
|
||||
```
|
||||
|
||||
| Variant | Classes (uses CSS vars where possible) |
|
||||
|------------|------------------------------------------------------|
|
||||
| default | `bg-primary text-primary-foreground` |
|
||||
| secondary | `bg-muted text-muted-foreground` |
|
||||
| destructive| `bg-destructive/15 text-destructive` |
|
||||
| outline | `border-border text-foreground` |
|
||||
| success | `bg-emerald-100 text-emerald-700` **[HARDCODED]** |
|
||||
| warning | `bg-amber-100 text-amber-700` **[HARDCODED]** |
|
||||
|
||||
### Input (`shared/ui/input.tsx`)
|
||||
|
||||
```
|
||||
flex h-10 w-full rounded-lg border border-input bg-background px-3 py-2 text-sm text-foreground
|
||||
ring-offset-background placeholder:text-muted-foreground
|
||||
focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2
|
||||
disabled:cursor-not-allowed disabled:opacity-50
|
||||
```
|
||||
|
||||
### Select (`shared/ui/select.tsx`)
|
||||
|
||||
Same visual treatment as Input (`flex h-10 w-full rounded-lg border border-input bg-background ...`).
|
||||
|
||||
### Skeleton (`shared/ui/skeleton.tsx`)
|
||||
|
||||
```
|
||||
rounded-lg bg-muted animate-shimmer
|
||||
```
|
||||
|
||||
### Tabs (`shared/ui/tabs.tsx`)
|
||||
|
||||
Wraps Radix `TabsPrimitive`:
|
||||
|
||||
| Component | Key Classes |
|
||||
|---------------|-----------------------------------------------------------|
|
||||
| `TabsList` | `inline-flex h-10 items-center justify-center rounded-lg bg-muted p-1 text-muted-foreground` |
|
||||
| `TabsTrigger` | `inline-flex items-center justify-center whitespace-nowrap rounded-lg px-3 py-1.5 text-sm font-medium transition-all ... data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow-sm` |
|
||||
| `TabsContent` | `mt-6 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring` |
|
||||
|
||||
### Toast (`shared/ui/toast.tsx`)
|
||||
|
||||
Context-based system with `addToast` / `removeToast`. Auto-dismiss after 4s.
|
||||
|
||||
Container: `fixed bottom-4 right-4 z-50 flex flex-col gap-2`
|
||||
|
||||
Type styles:
|
||||
|
||||
| Type | Border-left class | Icon color |
|
||||
|---------|----------------------------------|--------------------|
|
||||
| info | `border-l-primary` | `text-primary` |
|
||||
| success | `border-l-emerald-500` | `text-emerald-500` |
|
||||
| error | `border-l-destructive` | `text-destructive` |
|
||||
| warning | `border-l-amber-500` | `text-amber-500` |
|
||||
|
||||
**Note**: `emerald-500` and `amber-500` are hardcoded Tailwind utilities — see Known Issues.
|
||||
|
||||
### Glass Card (utility, `styles.css`)
|
||||
|
||||
```css
|
||||
.glass-card {
|
||||
@apply bg-white/70 backdrop-blur-sm border border-[oklch(0.92_0.004_286.32)] rounded-xl;
|
||||
}
|
||||
```
|
||||
|
||||
### Grid Pattern (utility, `styles.css`)
|
||||
|
||||
```css
|
||||
.grid-pattern {
|
||||
background-image:
|
||||
linear-gradient(oklch(0.92 0.004 286.32 / 0.3) 1px, transparent 1px),
|
||||
linear-gradient(90deg, oklch(0.92 0.004 286.32 / 0.3) 1px, transparent 1px);
|
||||
background-size: 40px 40px;
|
||||
}
|
||||
```
|
||||
Applied in `DashboardLayout.tsx` at `opacity-[0.03]`.
|
||||
|
||||
### Gradient Text
|
||||
|
||||
```css
|
||||
.gradient-text {
|
||||
@apply bg-clip-text text-transparent bg-gradient-to-r from-primary to-blue-400;
|
||||
}
|
||||
```
|
||||
Used in `Header.tsx` for the "IMPHNEN" brand text.
|
||||
|
||||
---
|
||||
|
||||
## 12. Reduced-Motion Handling
|
||||
|
||||
Three layers of protection:
|
||||
|
||||
### 1. CSS Level (`styles.css`)
|
||||
|
||||
```css
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*, *::before, *::after {
|
||||
animation-duration: 0.01ms !important;
|
||||
animation-iteration-count: 1 !important;
|
||||
transition-duration: 0.01ms !important;
|
||||
scroll-behavior: auto !important;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 2. GSAP Hook (`useGsapTransition`)
|
||||
|
||||
```ts
|
||||
function prefersReducedMotion(): boolean {
|
||||
if (typeof window === "undefined") return false;
|
||||
return window.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
||||
}
|
||||
```
|
||||
Passes `instant` flag → all durations set to 0, stagger set to 0.
|
||||
|
||||
### 3. Particle Background (`ParticleBackground.tsx`)
|
||||
|
||||
```tsx
|
||||
const [reducedMotion, setReducedMotion] = useState(false);
|
||||
useEffect(() => {
|
||||
const mq = window.matchMedia("(prefers-reduced-motion: reduce)");
|
||||
setReducedMotion(mq.matches);
|
||||
// listener for changes...
|
||||
}, []);
|
||||
if (reducedMotion) return null; // Entire particle layer removed
|
||||
```
|
||||
|
||||
### Coverage Gap
|
||||
|
||||
Framer Motion `AnimatePresence` and `motion` components (used extensively in MessageFeed, LivePanel, DashboardStats, UserSummaryList, UserProfileDetail, MascotChatbot) do **not** check `prefers-reduced-motion`. They rely solely on the CSS override. While the CSS `!important` rules do suppress Framer Motion animations (since they run via inline styles that get overridden), this is a fragile approach — some Framer Motion `transition` properties (spring physics, stagger delays) may not be fully neutralized by the CSS blanket rule.
|
||||
|
||||
---
|
||||
|
||||
## 13. Known Issues & Technical Debt
|
||||
|
||||
### 13.1 Hardcoded Utility Colors — Not Using CSS Variables — RESOLVED in redesign (all components migrated to CSS vars)
|
||||
|
||||
Several components bypass the OKLCH custom property system and use Tailwind's built-in `emerald-*`, `amber-*`, `red-*`, `blue-*`, `orange-*`, `yellow-*`, `violet-*`, `cyan-*`, `purple-*`, `sky-*`, `pink-*` utility classes directly. These will not respond to theme changes.
|
||||
|
||||
**Locations**:
|
||||
|
||||
| File(s) | Hardcoded Colors Used |
|
||||
|-------------------------------------------|--------------------------------------------|
|
||||
| `shared/ui/badge.tsx` | `bg-emerald-100 text-emerald-700` (success variant), `bg-amber-100 text-amber-700` (warning variant) |
|
||||
| `shared/ui/toast.tsx` | `border-l-emerald-500`, `text-emerald-500`, `border-l-amber-500`, `text-amber-500` |
|
||||
| `shared/ui/button.tsx` | None (uses CSS vars correctly) |
|
||||
| `features/dashboard/components/DashboardStats.tsx` | `text-emerald-500`, `bg-emerald-100`, `text-blue-500`, `bg-blue-100`, `text-violet-500`, `bg-violet-100`, `text-emerald-600`, `bg-cyan-100`, `text-cyan-500`, `text-amber-500`, `bg-amber-100` |
|
||||
| `features/dashboard/components/UserSummaryList.tsx` | `bg-emerald-100 text-emerald-700`, `bg-amber-100 text-amber-700`, `bg-red-100 text-red-700` |
|
||||
| `features/dashboard/components/UserProfileDetail.tsx` | `bg-red-100 text-red-700`, `bg-emerald-100 text-emerald-700`, `bg-amber-100 text-amber-700`, `text-emerald-600` |
|
||||
| `features/messages/components/MessageCard.tsx` | `bg-red-100 text-red-700 border-red-200`, `bg-orange-100 text-orange-700 border-orange-200`, `bg-yellow-100 text-yellow-700 border-yellow-200`, `bg-blue-100 text-blue-700 border-blue-200`, `bg-emerald-50/40`, `border-l-emerald-400`, `bg-pink-50/40`, `border-l-pink-400`, `text-pink-600`, `text-pink-600/70` |
|
||||
| `features/messages/components/MessagesPanel.tsx` | `bg-emerald-100 text-emerald-700 border-emerald-200`, `bg-orange-100 text-orange-700 border-orange-200`, `bg-red-100 text-red-700 border-red-200`, `text-emerald-600` |
|
||||
| `features/messages/components/ImageGrid.tsx` | `bg-purple-100 text-purple-700 border-purple-200` |
|
||||
| `features/live/components/ActiveSpeakers.tsx` | `text-emerald-700` |
|
||||
| `features/live/components/RecordingsSubPanel.tsx` | `border-sky-200 bg-white` |
|
||||
| `features/live/components/NowPlaying.tsx` | (uses Badge `success`/`warning` variants, which are hardcoded) |
|
||||
| `widgets/Header.tsx` | `bg-emerald-400`, `bg-red-400`, `bg-gray-400`, `text-emerald-400`, `text-red-400` |
|
||||
|
||||
**Fix**: Replace with CSS variable-based tokens, e.g.:
|
||||
- `emerald-100` → `oklch(var(--success-bg))` (would need new custom property)
|
||||
- `text-emerald-700` → `oklch(var(--success-fg))`
|
||||
- `border-emerald-200` → `oklch(var(--success-border))`
|
||||
|
||||
### 13.2 `formatTimeAgo` Forces Re-render on Every Tick
|
||||
|
||||
In `features/messages/components/MessageCard.tsx`:
|
||||
|
||||
```ts
|
||||
function formatTimeAgo(ts: number): string {
|
||||
const seconds = Math.floor((Date.now() - ts) / 1000);
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
`Date.now()` is called on every render, not via a periodic timer. This means the "X s ago" / "X m ago" labels are only accurate at the moment of render and do not update reactively. No `useEffect` interval keeps them fresh. Once rendered, a "5s ago" label will stay stale until a parent re-render. This is acceptable for a message feed that re-renders on new data, but inaccurate for pinned/static views.
|
||||
|
||||
**Fix**: Either (a) accept staleness (current behavior, simplest), (b) add a `useEffect` interval that forces re-render every ~30s, or (c) use `useSyncExternalStore` with a global ticker.
|
||||
|
||||
### 13.3 `AudioVisualizer.tsx` — Hardcoded IMPHNEN Gradient
|
||||
|
||||
```ts
|
||||
const gradient = ctx.createLinearGradient(0, 0, 0, height);
|
||||
gradient.addColorStop(0, "#23a1eb");
|
||||
gradient.addColorStop(1, "#3eb0f2");
|
||||
```
|
||||
|
||||
These hex values (IMPHNEN blue `#23a1eb` → `#3eb0f2`) are hardcoded and do not reference the OKLCH `--primary` CSS variable. A theme change would not affect the visualizer.
|
||||
|
||||
**Fix**: Read CSS custom property at paint time:
|
||||
```ts
|
||||
const primaryColor = getComputedStyle(document.documentElement)
|
||||
.getPropertyValue("--primary").trim();
|
||||
// Convert OKLCH to hex or use Canvas oklch() if available
|
||||
```
|
||||
|
||||
### 13.4 `glow-pulse` Keyframe Fragmentation — RESOLVED in T02 (moved to styles.css)
|
||||
|
||||
The `glowPulse` keyframes are defined in `tailwind.config.js` but NOT in `styles.css`. The class `animate-glow-pulse` is referenced by `ParticleBackground.tsx`. Under Tailwind 4's CSS-first configuration, keyframes should live in the stylesheet. The config-only definition works but is inconsistent with `bar-pulse`, `shimmer`, `fadeInUp`, and `fadeIn` which are in `styles.css`.
|
||||
|
||||
**Fix**: Move `@keyframes glowPulse { ... }` into `styles.css`.
|
||||
|
||||
### 13.5 `shimmer` Duration Mismatch — RESOLVED in T02 (CSS canonical at 1.5s)
|
||||
|
||||
`styles.css`: `animation: shimmer 1.5s ease-in-out infinite`
|
||||
`tailwind.config.js`: `shimmer: "shimmer 2s linear infinite"` (also uses `linear` vs. `ease-in-out`)
|
||||
|
||||
The CSS class `.animate-shimmer` (used by `Skeleton.tsx`) references the CSS-based keyframe, so `1.5s ease-in-out` is the actual runtime value. The Tailwind config value is dead unless used via `animate-shimmer` as a Tailwind class name — which maps to `shimmer 2s linear infinite`.
|
||||
|
||||
**Fix**: Align both sources. Pick one canonical definition.
|
||||
|
||||
### 13.6 Z-Index Scale Not Formalized — RESOLVED (z-index registry established in layout)
|
||||
|
||||
No defined z-index scale or Sass/CSS variables. Values jump from 50 to 9999. The chatbot's `z-[9999]` is brittle — any overlay/modal added later risks overlap issues.
|
||||
|
||||
**Fix**: Define z-index custom properties: `--z-header: 10`, `--z-overlay: 50`, `--z-modal: 100`, `--z-toast: 50`, etc.
|
||||
|
||||
### 13.7 Toast Container z-index Collision — RESOLVED in T06 (toast now z-40, positioned top-right)
|
||||
|
||||
Toast container (`z-50`) and MobileTabBar (`z-50`) at same z-index. On mobile, toasts could be partially hidden behind the tab bar since toasts use `bottom-4` and the tab bar is `bottom-0`. In practice the toast gap prevents overlap, but this is fragile.
|
||||
|
||||
### 13.8 Retro `bg-white` Usage
|
||||
|
||||
`RecordingsSubPanel.tsx` uses `bg-white` (line 86) instead of `bg-card`. This will not respect a dark theme if one is added.
|
||||
|
||||
---
|
||||
|
||||
## 14. Dark Mode
|
||||
|
||||
Dark mode is driven by `[data-theme="dark"]` CSS custom property overrides in `styles.css`.
|
||||
The theme is managed by `useTheme()` hook (`shared/hooks/useTheme.ts`).
|
||||
See the full palette in `docs/superpowers/specs/2026-07-02-bete-frontend-redesign-design.md`.
|
||||
|
||||
---
|
||||
|
||||
## Appendix: File Map
|
||||
|
||||
| Token / Concern | Canonical Source |
|
||||
|---------------------------|----------------------------------------------|
|
||||
| CSS custom properties | `styles.css` |
|
||||
| Tailwind theme extension | `tailwind.config.js` |
|
||||
| Framer Motion variants | `shared/hooks/useFramerStagger.ts` |
|
||||
| GSAP transition hook | `shared/hooks/useGsapTransition.ts` |
|
||||
| `cn()` utility | `shared/lib/utils.ts` |
|
||||
| Card component | `shared/ui/card.tsx` |
|
||||
| Button component | `shared/ui/button.tsx` |
|
||||
| Badge component | `shared/ui/badge.tsx` |
|
||||
| Input component | `shared/ui/input.tsx` |
|
||||
| Select component | `shared/ui/select.tsx` |
|
||||
| Skeleton component | `shared/ui/skeleton.tsx` |
|
||||
| Tabs component | `shared/ui/tabs.tsx` |
|
||||
| Toast component | `shared/ui/toast.tsx` |
|
||||
| ScrollArea component | `shared/ui/scroll-area.tsx` |
|
||||
| ParticleBackground | `widgets/particles/ParticleBackground.tsx` |
|
||||
| DashboardLayout | `widgets/DashboardLayout.tsx` |
|
||||
@@ -1,17 +0,0 @@
|
||||
<!doctype html>
|
||||
<html lang="id">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="theme-color" content="#23a1eb" />
|
||||
<title>IMPHNEN — Discord Moderation</title>
|
||||
<link rel="icon" type="image/svg+xml" href="https://raw.githubusercontent.com/IMPHNEN/imphnen-frontend-service/develop/docs/logo.svg" />
|
||||
<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" />
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,45 +0,0 @@
|
||||
# Frontend web application
|
||||
language: typescript
|
||||
|
||||
tasks:
|
||||
dev:
|
||||
command: vite --host 0.0.0.0
|
||||
preset: server
|
||||
|
||||
build:
|
||||
script: 'tsc --noEmit && NODE_NO_WARNINGS=1 vite build'
|
||||
options:
|
||||
runFromWorkspaceRoot: false
|
||||
inputs:
|
||||
- src
|
||||
- tsconfig.json
|
||||
- vite.config.mts
|
||||
- index.html
|
||||
outputs:
|
||||
- dist
|
||||
|
||||
preview:
|
||||
command: vite preview --host 0.0.0.0 --port 3000
|
||||
preset: server
|
||||
|
||||
typecheck:
|
||||
command: tsc --noEmit
|
||||
options:
|
||||
runFromWorkspaceRoot: false
|
||||
inputs:
|
||||
- src
|
||||
- tsconfig.json
|
||||
|
||||
lint:
|
||||
command: biome check --diagnostic-level=error src/
|
||||
options:
|
||||
runFromWorkspaceRoot: false
|
||||
inputs:
|
||||
- src
|
||||
|
||||
format:
|
||||
command: biome format --write src/
|
||||
options:
|
||||
runFromWorkspaceRoot: false
|
||||
inputs:
|
||||
- src
|
||||
@@ -1,38 +0,0 @@
|
||||
{
|
||||
"name": "@gmw/frontend",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite --host 0.0.0.0",
|
||||
"build": "tsc --noEmit && NODE_NO_WARNINGS=1 vite build",
|
||||
"preview": "vite preview --host 0.0.0.0 --port 3000",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"lint": "biome check --diagnostic-level=error src/",
|
||||
"format": "biome format --write src/"
|
||||
},
|
||||
"dependencies": {
|
||||
"@bete/shared": "workspace:*",
|
||||
"@radix-ui/react-scroll-area": "^1.2.10",
|
||||
"@radix-ui/react-slot": "^1.2.4",
|
||||
"@radix-ui/react-tabs": "^1.1.13",
|
||||
"clsx": "^2.1.1",
|
||||
"framer-motion": "^12.4.0",
|
||||
"lucide-react": "^1.16.0",
|
||||
"react": "^19.2.6",
|
||||
"react-dom": "^19.2.6",
|
||||
"tailwind-merge": "^3.6.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "latest",
|
||||
"@tailwindcss/postcss": "^4.3.0",
|
||||
"@types/react": "^19.2.14",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitejs/plugin-react": "^6.0.2",
|
||||
"autoprefixer": "^10.5.0",
|
||||
"postcss": "^8.5.14",
|
||||
"tailwindcss": "^4.3.0",
|
||||
"typescript": "^5.9.3",
|
||||
"vite": "^8.0.13"
|
||||
}
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
export default {
|
||||
plugins: {
|
||||
"@tailwindcss/postcss": {},
|
||||
},
|
||||
};
|
||||
@@ -1,44 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128" width="128" height="128">
|
||||
<defs>
|
||||
<linearGradient id="bg" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" stop-color="#1a1a2e"/>
|
||||
<stop offset="100%" stop-color="#16213e"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="shield" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" stop-color="#0ea5e9"/>
|
||||
<stop offset="100%" stop-color="#06b6d4"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="eye" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" stop-color="#22d3ee"/>
|
||||
<stop offset="100%" stop-color="#67e8f9"/>
|
||||
</linearGradient>
|
||||
<filter id="glow">
|
||||
<feGaussianBlur stdDeviation="2" result="blur"/>
|
||||
<feMerge>
|
||||
<feMergeNode in="blur"/>
|
||||
<feMergeNode in="SourceGraphic"/>
|
||||
</feMerge>
|
||||
</filter>
|
||||
</defs>
|
||||
<!-- Background -->
|
||||
<rect width="128" height="128" rx="24" fill="url(#bg)"/>
|
||||
<!-- Shield shape -->
|
||||
<path d="M64 18 L102 32 L102 64 C102 88 84 106 64 114 C44 106 26 88 26 64 L26 32 Z"
|
||||
fill="url(#shield)" opacity="0.9"/>
|
||||
<!-- Shield inner border -->
|
||||
<path d="M64 24 L96 36 L96 63 C96 84 80 100 64 108 C48 100 32 84 32 63 L32 36 Z"
|
||||
fill="none" stroke="#38bdf8" stroke-width="1.5" opacity="0.6"/>
|
||||
<!-- Eye outer -->
|
||||
<ellipse cx="64" cy="60" rx="22" ry="14" fill="none" stroke="#0f172a" stroke-width="2.5" opacity="0.8"/>
|
||||
<!-- Eye inner -->
|
||||
<ellipse cx="64" cy="60" rx="20" ry="12" fill="#0f172a" opacity="0.7"/>
|
||||
<!-- Pupil -->
|
||||
<circle cx="64" cy="60" r="7" fill="url(#eye)" filter="url(#glow)"/>
|
||||
<!-- Pupil inner dot -->
|
||||
<circle cx="64" cy="60" r="3" fill="#ffffff" opacity="0.9"/>
|
||||
<!-- Eyebrow / scan line -->
|
||||
<line x1="42" y1="60" x2="86" y2="60" stroke="#0ea5e9" stroke-width="0.5" opacity="0.4"/>
|
||||
<!-- GMW text -->
|
||||
<text x="64" y="92" text-anchor="middle" font-family="system-ui, -apple-system, sans-serif"
|
||||
font-weight="900" font-size="16" fill="#ffffff" letter-spacing="3" opacity="0.95">GMW</text>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 2.0 KiB |
@@ -1,300 +0,0 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import type { ActiveSpeaker } from "./entities/voice/types.js";
|
||||
import { AuthOverlay } from "./features/auth";
|
||||
import { DashboardPanel } from "./features/dashboard";
|
||||
import { LivePanel } from "./features/live";
|
||||
import { useMediaControl } from "./features/live/hooks/useMediaControl";
|
||||
import { useVoiceControl } from "./features/live/hooks/useVoiceControl";
|
||||
import { MessagesPanel } from "./features/messages";
|
||||
import { ModerationAlertListener } from "./features/messages/components/ModerationAlertListener";
|
||||
import {
|
||||
mergeMessages,
|
||||
useMessages,
|
||||
} from "./features/messages/hooks/useMessages";
|
||||
import { getAppConfig } from "./shared/api/client";
|
||||
import { useAudioPlayback } from "./shared/hooks/useAudioPlayback";
|
||||
import { useAudioTransmit } from "./shared/hooks/useAudioTransmit";
|
||||
import { useTheme } from "./shared/hooks/useTheme";
|
||||
import { useUIState } from "./shared/hooks/useUIState";
|
||||
import { MobileTabBar } from "./shared/ui/MobileTabBar";
|
||||
import { useDashboardSocket } from "./shared/ws/socket";
|
||||
import { DashboardLayout } from "./widgets/DashboardLayout";
|
||||
|
||||
export default function App() {
|
||||
const { uiState, patchUIState } = useUIState();
|
||||
const [authenticated, setAuthenticated] = useState(() => {
|
||||
return sessionStorage.getItem("admin-password") !== null;
|
||||
});
|
||||
useTheme();
|
||||
|
||||
const handleAuthenticated = useCallback(() => {
|
||||
setAuthenticated(true);
|
||||
}, []);
|
||||
|
||||
const voice = useVoiceControl();
|
||||
const media = useMediaControl();
|
||||
const messages = useMessages();
|
||||
const [activeSpeakers, setActiveSpeakers] = useState<
|
||||
(ActiveSpeaker & { heardAt?: number })[]
|
||||
>([]);
|
||||
const [monitorGuildId, setMonitorGuildId] = useState("");
|
||||
|
||||
const audio = useAudioPlayback();
|
||||
const isPublicDashboard = import.meta.env.VITE_DASHBOARD_IS_PUBLIC === "true";
|
||||
const activeTab = uiState.activeTab || "messages";
|
||||
|
||||
// Reset persisted tab to "messages" once on mount if not authenticated
|
||||
// Prevents localStorage carryover from a prior session on the Live tab
|
||||
useEffect(() => {
|
||||
if (!authenticated && !isPublicDashboard && uiState.activeTab === "live") {
|
||||
patchUIState({ activeTab: "messages" });
|
||||
}
|
||||
// Run only on mount
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
const selectedVoiceGuild =
|
||||
uiState.selectedVoiceGuild || uiState.selectedGuild || "";
|
||||
|
||||
// Resolve monitor guild name from the full guild list (has real names now)
|
||||
const monitorGuildName = useMemo(
|
||||
() =>
|
||||
monitorGuildId
|
||||
? (voice.guilds.find((g) => g.id === monitorGuildId)?.name ?? null)
|
||||
: null,
|
||||
[monitorGuildId, voice.guilds],
|
||||
);
|
||||
|
||||
// Update speaker list from incremental voice_active_user events
|
||||
const updateSpeakerList = (
|
||||
prev: (ActiveSpeaker & { heardAt?: number })[],
|
||||
data: Partial<ActiveSpeaker> & {
|
||||
userId?: string;
|
||||
id?: string;
|
||||
speaking: boolean;
|
||||
},
|
||||
): (ActiveSpeaker & { heardAt?: number })[] => {
|
||||
const key = data.userId ?? data.id;
|
||||
if (!key) return prev;
|
||||
const now = Date.now();
|
||||
const idx = prev.findIndex((s) => (s.userId ?? s.id) === key);
|
||||
if (idx >= 0) {
|
||||
const next = [...prev];
|
||||
next[idx] = { ...next[idx], ...data, heardAt: now };
|
||||
return next;
|
||||
}
|
||||
return [
|
||||
...prev,
|
||||
{ ...data, heardAt: now } as ActiveSpeaker & { heardAt?: number },
|
||||
];
|
||||
};
|
||||
|
||||
const socket = useDashboardSocket({
|
||||
onBinary: (d) => audio.handleIncomingBinary(d),
|
||||
onUserState: (users) =>
|
||||
setActiveSpeakers(
|
||||
users.map((u) => ({
|
||||
...u,
|
||||
heardAt: Date.now(),
|
||||
})),
|
||||
),
|
||||
onVoiceActiveUser: (data) => {
|
||||
if (data.userId) audio.registerUserId(data.userId);
|
||||
setActiveSpeakers((prev) =>
|
||||
updateSpeakerList(prev, {
|
||||
userId: data.userId,
|
||||
username: data.username,
|
||||
avatar: data.avatar,
|
||||
speaking: data.speaking,
|
||||
}),
|
||||
);
|
||||
},
|
||||
onVoiceRecordingStarted: () =>
|
||||
window.dispatchEvent(new CustomEvent("voice_recording_uploaded")),
|
||||
onVoiceRecordingStopped: () =>
|
||||
window.dispatchEvent(new CustomEvent("voice_recording_uploaded")),
|
||||
onMessageCreated: (m) =>
|
||||
messages.setMessages((prev) => mergeMessages(prev, [m])),
|
||||
onMessageUpdated: (m) =>
|
||||
messages.setMessages((prev) =>
|
||||
prev.map((i) => (i.id === m.id ? { ...i, ...m } : i)),
|
||||
),
|
||||
onMessageDeleted: (m) =>
|
||||
messages.setMessages((prev) =>
|
||||
prev.map((i) =>
|
||||
i.id === m.id ? { ...i, type: "deleted" as const } : i,
|
||||
),
|
||||
),
|
||||
onMessageAnalyzed: (msg) => {
|
||||
messages.setMessages((prev) => mergeMessages(prev, [msg]));
|
||||
const status = msg.ai_status;
|
||||
if (status === "flagged") {
|
||||
const username = msg.username || msg.user_id || "unknown";
|
||||
const severity = msg.ai_severity || "";
|
||||
const categories = msg.ai_categories || "";
|
||||
const brief = msg.ai_analysis?.slice(0, 80) ?? "Message flagged by AI";
|
||||
window.dispatchEvent(
|
||||
new CustomEvent("moderation_alert", {
|
||||
detail: { type: status, username, severity, categories, brief },
|
||||
}),
|
||||
);
|
||||
}
|
||||
},
|
||||
onAttachmentUploaded: () =>
|
||||
messages
|
||||
.fetchMessages(monitorGuildId || undefined)
|
||||
.catch(() => undefined),
|
||||
onAttachmentCreated: () =>
|
||||
messages
|
||||
.fetchMessages(monitorGuildId || undefined)
|
||||
.catch(() => undefined),
|
||||
onMediaState: (state) => media.setMediaState(state),
|
||||
onVoiceRecordingUploaded: (d) =>
|
||||
window.dispatchEvent(
|
||||
new CustomEvent("voice_recording_uploaded", { detail: d }),
|
||||
),
|
||||
});
|
||||
|
||||
const transmit = useAudioTransmit(socket.socketRef);
|
||||
|
||||
// Load app config on mount
|
||||
useEffect(() => {
|
||||
getAppConfig()
|
||||
.then((c) => {
|
||||
if (c.monitorGuildId) {
|
||||
setMonitorGuildId(c.monitorGuildId);
|
||||
}
|
||||
})
|
||||
.catch(() => undefined);
|
||||
}, []);
|
||||
|
||||
// Load voice channels when guild changes (Live tab)
|
||||
useEffect(() => {
|
||||
if (selectedVoiceGuild)
|
||||
voice.loadVoiceChannels(selectedVoiceGuild).catch(() => undefined);
|
||||
}, [selectedVoiceGuild, voice.loadVoiceChannels]);
|
||||
|
||||
// Auto-fetch messages for the monitor guild
|
||||
useEffect(() => {
|
||||
if (monitorGuildId)
|
||||
messages.fetchMessages(monitorGuildId).catch(() => undefined);
|
||||
}, [monitorGuildId, messages.fetchMessages]);
|
||||
|
||||
// Periodic refetch — keeps dashboard in sync even if WS events missed
|
||||
useEffect(() => {
|
||||
if (!monitorGuildId) return;
|
||||
const interval = setInterval(() => {
|
||||
messages.fetchMessages(monitorGuildId).catch(() => undefined);
|
||||
}, 15_000);
|
||||
return () => clearInterval(interval);
|
||||
}, [monitorGuildId, messages.fetchMessages]);
|
||||
|
||||
// Stale speaker pruning — remove speakers not heard from in 30s
|
||||
useEffect(() => {
|
||||
const interval = setInterval(() => {
|
||||
setActiveSpeakers((prev) => {
|
||||
const now = Date.now();
|
||||
const pruned = prev.filter(
|
||||
(s) => s.speaking || (s.heardAt && now - s.heardAt < 30_000),
|
||||
);
|
||||
return pruned.length < prev.length ? pruned : prev;
|
||||
});
|
||||
}, 30_000);
|
||||
return () => clearInterval(interval);
|
||||
}, []);
|
||||
|
||||
// Push-to-Talk — hold Space to transmit
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (
|
||||
e.target instanceof HTMLInputElement ||
|
||||
e.target instanceof HTMLTextAreaElement ||
|
||||
e.target instanceof HTMLSelectElement
|
||||
)
|
||||
return;
|
||||
if (e.code === "Space" && !transmit.isStreaming && e.repeat === false) {
|
||||
e.preventDefault();
|
||||
transmit.startTransmit().catch(() => undefined);
|
||||
}
|
||||
};
|
||||
const handleKeyUp = (e: KeyboardEvent) => {
|
||||
if (e.code === "Space" && transmit.isStreaming) {
|
||||
transmit.stopTransmit();
|
||||
}
|
||||
};
|
||||
window.addEventListener("keydown", handleKeyDown);
|
||||
window.addEventListener("keyup", handleKeyUp);
|
||||
return () => {
|
||||
window.removeEventListener("keydown", handleKeyDown);
|
||||
window.removeEventListener("keyup", handleKeyUp);
|
||||
};
|
||||
}, [transmit]);
|
||||
|
||||
return (
|
||||
<DashboardLayout
|
||||
activeTab={activeTab}
|
||||
wsStatus={socket.status}
|
||||
voiceStatus={voice.voiceStatus}
|
||||
onTabChange={(tab) => patchUIState({ activeTab: tab })}
|
||||
recentMessages={messages.messages}
|
||||
guildId={monitorGuildId}
|
||||
channelId={
|
||||
uiState.selectedTextChannel || uiState.selectedVoiceChannel || undefined
|
||||
}
|
||||
>
|
||||
{activeTab === "live" && !authenticated && !isPublicDashboard ? (
|
||||
<AuthOverlay onAuthenticated={handleAuthenticated} />
|
||||
) : activeTab === "live" ? (
|
||||
<LivePanel
|
||||
guilds={voice.guilds}
|
||||
voiceChannels={voice.voiceChannels}
|
||||
selectedGuild={selectedVoiceGuild}
|
||||
selectedChannel={uiState.selectedVoiceChannel || ""}
|
||||
micLevel={0}
|
||||
status={voice.voiceStatus}
|
||||
voiceLoading={voice.loading}
|
||||
activeSpeakers={activeSpeakers}
|
||||
levels={audio.levels}
|
||||
isListening={audio.isListening}
|
||||
isStreaming={transmit.isStreaming}
|
||||
mediaState={media.mediaState}
|
||||
mediaLoading={media.loading}
|
||||
onGuildChange={(id) =>
|
||||
patchUIState({ selectedVoiceGuild: id, selectedVoiceChannel: "" })
|
||||
}
|
||||
onChannelChange={(id) => patchUIState({ selectedVoiceChannel: id })}
|
||||
onJoin={() =>
|
||||
voice.joinVoice(
|
||||
selectedVoiceGuild,
|
||||
uiState.selectedVoiceChannel || "",
|
||||
)
|
||||
}
|
||||
onDisconnect={() => voice.leaveVoice()}
|
||||
onListenToggle={audio.toggleListening}
|
||||
onStreamingToggle={transmit.toggle}
|
||||
onQueueMusic={(s) => media.enqueue(s, "music")}
|
||||
onStartScreen={(s) => media.enqueue(s, "screen")}
|
||||
onSkip={media.skip}
|
||||
onStop={media.stop}
|
||||
onVolumeChange={media.setVolume}
|
||||
/>
|
||||
) : activeTab === "dashboard" ? (
|
||||
<DashboardPanel />
|
||||
) : (
|
||||
<MessagesPanel
|
||||
guildName={monitorGuildName}
|
||||
messages={messages.messages}
|
||||
onReanalyze={messages.reanalyze}
|
||||
onReanalyzeAllErrors={messages.reanalyzeAllErrors}
|
||||
onLoadMore={messages.loadMore}
|
||||
hasMore={messages.hasMore}
|
||||
loadingMore={messages.loadingMore}
|
||||
/>
|
||||
)}
|
||||
<MobileTabBar
|
||||
activeTab={activeTab}
|
||||
onTabChange={(tab) => patchUIState({ activeTab: tab })}
|
||||
/>
|
||||
<ModerationAlertListener />
|
||||
</DashboardLayout>
|
||||
);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user