chore: remove React frontend, rename frontend-leptos to frontend

This commit is contained in:
asepharyana
2026-07-03 23:11:00 +07:00
parent 4390a3da5e
commit 8166023f91
175 changed files with 22 additions and 9817 deletions
+2 -4
View File
@@ -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
+2548
View File
File diff suppressed because it is too large Load Diff
+3
View File
@@ -0,0 +1,3 @@
[workspace]
resolver = "2"
members = ["shared-types", "frontend"]
-753
View File
@@ -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` |
+63
View File
@@ -0,0 +1,63 @@
[package]
name = "frontend"
version = "0.1.0"
edition = "2021"
[lib]
crate-type = ["cdylib", "rlib"]
[dependencies]
leptos = { version = "0.7", features = ["csr"] }
leptos-use = "0.14"
lucide-leptos = "3"
shared-types = { path = "../shared-types" }
wasm-bindgen = "0.2"
wasm-bindgen-futures = "0.4"
js-sys = "0.3"
web-sys = { version = "0.3", features = [
"WebSocket",
"MessageEvent",
"CloseEvent",
"ErrorEvent",
"CanvasRenderingContext2d",
"AudioContext",
"AudioBuffer",
"AudioBufferSourceNode",
"AudioDestinationNode",
"AudioNode",
"AudioProcessingEvent",
"MediaStreamAudioSourceNode",
"ScriptProcessorNode",
"Window",
"Document",
"Element",
"HtmlElement",
"HtmlSelectElement",
"KeyboardEvent",
"Storage",
"IntersectionObserver",
"ResizeObserver",
"Url",
"Headers",
"Request",
"RequestInit",
"RequestMode",
"Response",
"HtmlInputElement",
"HtmlAudioElement",
"HtmlCanvasElement",
"MediaDevices",
"MediaStream",
"MediaStreamConstraints",
"MediaStreamTrack",
"Navigator",
"console",
] }
gloo-net = "0.6"
gloo-timers = "0.3"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
serde-wasm-bindgen = "0.6"
wasm-logger = "0.2"
console_error_panic_hook = "0.1"
regex = "1"
+7
View File
@@ -0,0 +1,7 @@
[build]
target = "index.html"
dist = "dist"
[serve]
port = 8080
open = false
+21
View File
@@ -0,0 +1,21 @@
<!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 data-trunk rel="rust" data-crate="frontend" data-wasm="frontend.wasm" />
<link data-trunk rel="css" href="src/app.css" />
<link data-trunk rel="copy-dir" href="public/" />
<!-- Poppins font -->
<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 rel="preload" href="/frontend.wasm" as="fetch" crossorigin="anonymous" />
</head>
<body>
<!-- Leptos CSR mounts to document.body by default -->
</body>
</html>
@@ -0,0 +1 @@
# Trunk copies this directory to dist/
@@ -0,0 +1,21 @@
use crate::api::client::{request, ApiError};
use serde::{Deserialize, Serialize};
#[derive(Serialize)]
struct LoginPayload {
password: String,
}
#[derive(Deserialize)]
struct LoginResponse {
ok: bool,
}
pub async fn login(password: &str) -> Result<bool, ApiError> {
let payload = LoginPayload {
password: password.to_string(),
};
let body = serde_json::to_string(&payload).unwrap();
let resp: LoginResponse = request("POST", "/api/auth/login", Some(&body)).await?;
Ok(resp.ok)
}
@@ -0,0 +1,134 @@
use serde::de::DeserializeOwned;
use wasm_bindgen::prelude::*;
use web_sys::{Request, RequestInit, RequestMode, Headers, Response};
use wasm_bindgen_futures::JsFuture;
#[derive(Debug)]
pub struct ApiError {
pub message: String,
pub status_code: u16,
}
impl std::fmt::Display for ApiError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "API error {}: {}", self.status_code, self.message)
}
}
impl std::error::Error for ApiError {}
fn get_base_url() -> String {
if let Some(window) = web_sys::window() {
let location = window.location();
let protocol = location.protocol().unwrap_or_else(|_| "http:".to_string());
let protocol = protocol.trim_end_matches(':');
let host = location.host().unwrap_or_else(|_| "localhost:3001".to_string());
format!("{}://{}", protocol, host)
} else {
"http://localhost:3001".to_string()
}
}
fn get_auth_header() -> Option<String> {
// Read password from sessionStorage
let storage = web_sys::window()?.local_storage().ok()??;
storage.get_item("admin-password").ok()?
}
pub async fn request<T: DeserializeOwned>(
method: &str,
path: &str,
body: Option<&str>,
) -> Result<T, ApiError> {
let url = format!("{}{}", get_base_url(), path);
let headers = Headers::new().map_err(|_| ApiError {
message: "Failed to create headers".to_string(),
status_code: 0,
})?;
if let Some(password) = get_auth_header() {
headers.set("X-Admin-Password", &password).ok();
}
if body.is_some() {
headers.set("Content-Type", "application/json").ok();
}
let opts = RequestInit::new();
opts.set_method(method);
opts.set_headers(&headers);
opts.set_mode(RequestMode::Cors);
if let Some(json_body) = body {
opts.set_body(&JsValue::from_str(json_body));
}
let request = Request::new_with_str_and_init(&url, &opts).map_err(|e| ApiError {
message: format!("Failed to create request: {:?}", e),
status_code: 0,
})?;
let window = web_sys::window().ok_or(ApiError {
message: "No window".to_string(),
status_code: 0,
})?;
let resp_value = JsFuture::from(window.fetch_with_request(&request))
.await
.map_err(|e| ApiError {
message: format!("Fetch failed: {:?}", e),
status_code: 0,
})?;
let response: Response = resp_value.dyn_into().map_err(|_| ApiError {
message: "Invalid response".to_string(),
status_code: 0,
})?;
let status = response.status();
if status >= 400 {
let text = JsFuture::from(
response.text().map_err(|_| ApiError {
message: "Failed to read error body".to_string(),
status_code: status,
})?
)
.await
.ok()
.and_then(|v| v.as_string())
.unwrap_or_default();
return Err(ApiError {
message: text,
status_code: status,
});
}
let text = JsFuture::from(
response.text().map_err(|_| ApiError {
message: "Failed to read response body".to_string(),
status_code: status,
})?
)
.await
.map_err(|_| ApiError {
message: "Failed to await response".to_string(),
status_code: status,
})?
.as_string()
.ok_or(ApiError {
message: "Response is not text".to_string(),
status_code: status,
})?;
serde_json::from_str(&text).map_err(|e| ApiError {
message: format!("JSON parse error: {} — body: {}", e, &text[..text.len().min(200)]),
status_code: status,
})
}
pub async fn request_no_body(method: &str, path: &str) -> Result<(), ApiError> {
request::<serde_json::Value>(method, path, None).await?;
Ok(())
}
@@ -0,0 +1,63 @@
use crate::api::client::{request, ApiError};
use shared_types::dashboard::*;
/// GET /api/dashboard/stats
pub async fn get_dashboard_stats() -> Result<DashboardStats, ApiError> {
request("GET", "/api/dashboard/stats", None).await
}
/// GET /api/dashboard/users?limit=&cursor=&search=
pub async fn get_dashboard_users(
limit: Option<u32>,
cursor: Option<&str>,
search: Option<&str>,
) -> Result<PaginatedUsers, ApiError> {
let mut path = "/api/dashboard/users".to_string();
let mut params = vec![];
if let Some(l) = limit { params.push(format!("limit={}", l)); }
if let Some(c) = cursor { params.push(format!("cursor={}", c)); }
if let Some(s) = search { params.push(format!("search={}", s)); }
if !params.is_empty() { path.push_str(&format!("?{}", params.join("&"))); }
request("GET", &path, None).await
}
#[derive(serde::Deserialize)]
pub struct PaginatedUsers {
pub data: Vec<DashboardUser>,
#[serde(rename = "nextCursor")]
pub next_cursor: Option<String>,
}
/// GET /api/dashboard/users/{userId}
pub async fn get_dashboard_user_detail(user_id: &str) -> Result<DashboardUserDetail, ApiError> {
request("GET", &format!("/api/dashboard/users/{}", user_id), None).await
}
/// GET /api/dashboard/channels?limit=&cursor=&search=&guild_id=
pub async fn get_dashboard_channels(
limit: Option<u32>,
cursor: Option<&str>,
search: Option<&str>,
guild_id: Option<&str>,
) -> Result<PaginatedChannels, ApiError> {
let mut path = "/api/dashboard/channels".to_string();
let mut params = vec![];
if let Some(l) = limit { params.push(format!("limit={}", l)); }
if let Some(c) = cursor { params.push(format!("cursor={}", c)); }
if let Some(s) = search { params.push(format!("search={}", s)); }
if let Some(g) = guild_id { params.push(format!("guild_id={}", g)); }
if !params.is_empty() { path.push_str(&format!("?{}", params.join("&"))); }
request("GET", &path, None).await
}
#[derive(serde::Deserialize)]
pub struct PaginatedChannels {
pub data: Vec<DashboardChannel>,
#[serde(rename = "nextCursor")]
pub next_cursor: Option<String>,
}
/// GET /api/dashboard/channels/{channelId}
pub async fn get_dashboard_channel_detail(channel_id: &str) -> Result<DashboardChannelDetail, ApiError> {
request("GET", &format!("/api/dashboard/channels/{}", channel_id), None).await
}
@@ -0,0 +1,21 @@
use crate::api::client::{request, ApiError};
use serde::{Deserialize, Serialize};
#[derive(Debug, Serialize)]
struct MascotChatRequest<'a> {
message: &'a str,
}
#[derive(Debug, Deserialize)]
pub struct MascotChatResponse {
pub response: String,
pub timestamp: String,
}
pub async fn send_mascot_message(message: &str) -> Result<MascotChatResponse, ApiError> {
let body = serde_json::to_string(&MascotChatRequest { message }).map_err(|err| ApiError {
message: format!("Failed to serialize mascot request: {}", err),
status_code: 0,
})?;
request("POST", "/api/mascot/chat", Some(&body)).await
}
@@ -0,0 +1,57 @@
use crate::api::client::{request, ApiError};
use shared_types::message::{MessageRecord, PageResult};
/// GET /api/messages?guildId=&limit=&channelId=&cursor=
pub async fn get_messages(
guild_id: &str,
limit: Option<u32>,
channel_id: Option<&str>,
cursor: Option<&str>,
) -> Result<PageResult<MessageRecord>, ApiError> {
let mut path = format!("/api/messages?guildId={}", guild_id);
if let Some(l) = limit { path.push_str(&format!("&limit={}", l)); }
if let Some(c) = channel_id { path.push_str(&format!("&channelId={}", c)); }
if let Some(c) = cursor { path.push_str(&format!("&cursor={}", c)); }
request("GET", &path, None).await
}
/// GET /api/review?params
pub async fn get_review_messages(
guild_id: &str,
limit: Option<u32>,
channel_id: Option<&str>,
) -> Result<PageResult<MessageRecord>, ApiError> {
let mut path = format!("/api/review?guildId={}", guild_id);
if let Some(l) = limit { path.push_str(&format!("&limit={}", l)); }
if let Some(c) = channel_id { path.push_str(&format!("&channelId={}", c)); }
request("GET", &path, None).await
}
/// GET /api/messages/detail/{id}
pub async fn get_message_detail(id: &str) -> Result<Option<MessageRecord>, ApiError> {
request("GET", &format!("/api/messages/detail/{}", id), None).await
}
/// POST /api/messages/{id}/reanalyze
pub async fn reanalyze_message(id: &str) -> Result<(), ApiError> {
let _: serde_json::Value = request("POST", &format!("/api/messages/{}/reanalyze", id), Some("{}")).await?;
Ok(())
}
/// POST /api/messages/reanalyze-batch
pub async fn reanalyze_batch() -> Result<u64, ApiError> {
#[derive(serde::Deserialize)]
struct BatchResp { ok: bool, count: u64 }
let resp: BatchResp = request("POST", "/api/messages/reanalyze-batch", Some("{}")).await?;
Ok(resp.count)
}
/// GET /api/analysis/search?q=&limit=
pub async fn search_messages(query: &str, limit: Option<u32>) -> Result<Vec<MessageRecord>, ApiError> {
#[derive(serde::Deserialize)]
struct SearchResult { results: Vec<MessageRecord> }
let mut path = format!("/api/analysis/search?q={}", query);
if let Some(l) = limit { path.push_str(&format!("&limit={}", l)); }
let resp: SearchResult = request("GET", &path, None).await?;
Ok(resp.results)
}
@@ -0,0 +1,7 @@
pub mod client;
pub mod auth;
pub mod messages;
pub mod voice;
pub mod dashboard;
pub mod mascot;
pub mod recordings;
@@ -0,0 +1,20 @@
use crate::api::client::{request, request_no_body, ApiError};
use shared_types::recording::VoiceRecordingListResponse;
/// GET /api/recordings?limit=&cursor=
pub async fn get_recordings(
limit: Option<u32>,
cursor: Option<&str>,
) -> Result<VoiceRecordingListResponse, ApiError> {
let mut path = "/api/recordings".to_string();
let mut params = vec![];
if let Some(l) = limit { params.push(format!("limit={}", l)); }
if let Some(c) = cursor { params.push(format!("cursor={}", c)); }
if !params.is_empty() { path.push_str(&format!("?{}", params.join("&"))); }
request("GET", &path, None).await
}
/// DELETE /api/recordings/{id}
pub async fn delete_recording(id: &str) -> Result<(), ApiError> {
request_no_body("DELETE", &format!("/api/recordings/{}", id)).await
}
@@ -0,0 +1,82 @@
use crate::api::client::{request, request_no_body, ApiError};
use shared_types::voice::VoiceStatus;
use shared_types::media::MediaState;
use shared_types::guild::{Guild, Channel};
use serde::Serialize;
/// GET /api/guilds
pub async fn get_guilds() -> Result<Vec<Guild>, ApiError> {
request("GET", "/api/guilds", None).await
}
/// GET /api/guilds/{guildId}/voice-channels
pub async fn get_voice_channels(guild_id: &str) -> Result<Vec<Channel>, ApiError> {
request("GET", &format!("/api/guilds/{}/voice-channels", guild_id), None).await
}
/// GET /api/guilds/{guildId}/channels
pub async fn get_text_channels(guild_id: &str) -> Result<Vec<Channel>, ApiError> {
request("GET", &format!("/api/guilds/{}/channels", guild_id), None).await
}
/// GET /api/voice/status
pub async fn get_voice_status() -> Result<VoiceStatus, ApiError> {
request("GET", "/api/voice/status", None).await
}
/// POST /api/voice/connect { guildId, channelId }
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct ConnectPayload {
guild_id: String,
channel_id: String,
}
pub async fn connect_voice(guild_id: &str, channel_id: &str) -> Result<VoiceStatus, ApiError> {
let body = serde_json::to_string(&ConnectPayload {
guild_id: guild_id.to_string(),
channel_id: channel_id.to_string(),
}).unwrap();
request("POST", "/api/voice/connect", Some(&body)).await
}
/// POST /api/voice/disconnect
pub async fn disconnect_voice() -> Result<VoiceStatus, ApiError> {
request("POST", "/api/voice/disconnect", Some("{}")).await
}
/// GET /api/media/status
pub async fn get_media_status() -> Result<MediaState, ApiError> {
request("GET", "/api/media/status", None).await
}
/// POST /api/media/queue { source, mode }
#[derive(Serialize)]
struct MediaQueuePayload {
source: String,
mode: String,
}
pub async fn media_queue(source: &str, mode: &str) -> Result<MediaState, ApiError> {
let body = serde_json::to_string(&MediaQueuePayload {
source: source.to_string(),
mode: mode.to_string(),
}).unwrap();
request("POST", "/api/media/queue", Some(&body)).await
}
/// POST /api/media/skip
pub async fn media_skip() -> Result<MediaState, ApiError> {
request("POST", "/api/media/skip", Some("{}")).await
}
/// POST /api/media/stop
pub async fn media_stop() -> Result<MediaState, ApiError> {
request("POST", "/api/media/stop", Some("{}")).await
}
/// POST /api/media/volume { volume }
#[derive(Serialize)]
struct VolumePayload { volume: f64 }
pub async fn media_volume(volume: f64) -> Result<MediaState, ApiError> {
let body = serde_json::to_string(&VolumePayload { volume }).unwrap();
request("POST", "/api/media/volume", Some(&body)).await
}
File diff suppressed because it is too large Load Diff
+147
View File
@@ -0,0 +1,147 @@
use leptos::prelude::*;
use shared_types::ui_state::Tab;
use crate::auth::AuthOverlay;
use crate::ws::context::WsContext;
use crate::features::dashboard::DashboardPanel;
use crate::features::live::LivePanel;
use crate::features::messages::MessagesPanel;
use crate::features::polish::{initial_theme, ThemeContext};
use crate::features::polish::components::{MascotChatbot, ParticleBackground, ThemeToggle};
#[derive(Clone)]
pub struct AppConfig {
pub monitor_guild_id: Option<String>,
}
// ── Contexts ────────────────────────────────────────────
#[derive(Clone)]
pub struct AuthContext {
pub authenticated: RwSignal<bool>,
pub password: RwSignal<String>,
}
#[derive(Clone)]
pub struct UiContext {
pub active_tab: RwSignal<Tab>,
pub selected_guild: RwSignal<Option<String>>,
}
// ── App ─────────────────────────────────────────────────
#[component]
pub fn App() -> impl IntoView {
// Initialize contexts
let auth = AuthContext {
authenticated: create_rw_signal(false),
password: create_rw_signal(String::new()),
};
let ui = UiContext {
active_tab: create_rw_signal(Tab::Messages),
selected_guild: create_rw_signal(None),
};
let theme = ThemeContext {
theme: create_rw_signal(initial_theme()),
};
provide_context(auth.clone());
provide_context(ui.clone());
provide_context(theme.clone());
let config = AppConfig {
monitor_guild_id: None,
};
provide_context(config);
let ws = WsContext::new("ws://localhost:3001/ws");
provide_context(ws.clone());
// Auth check: redirect "live" tab to "messages" if not authenticated
create_effect(move |_| {
if !auth.authenticated.get() && ui.active_tab.get() == Tab::Live {
ui.active_tab.set(Tab::Messages);
}
});
{
let ws = ws.clone();
let auth = auth.clone();
create_effect(move |_| {
if auth.authenticated.get() {
ws.connect();
}
});
}
view! {
<div data-theme=move || theme.theme.get()>
<ParticleBackground />
// Auth overlay
{move || (!auth.authenticated.get()).then(|| {
view! { <AuthOverlay /> }
})}
// 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>
<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::Live ui=ui.clone() label="Voice & Media" />
<TabButton tab=Tab::Dashboard ui=ui.clone() label="Dashboard Guild" />
</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>
{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.clone();
let tab1 = tab.clone();
let tab2 = tab.clone();
let tab3 = tab.clone();
let tab4 = tab;
view! {
<button
class:btn=true
class:btn-ghost=true
class:btn-active=move || active_tab.get() == tab1
on:click=move |_| active_tab.set(tab4.clone())
style:background=move || if active_tab.get() == tab2 { "var(--surface-overlay)" } else { "" }
style:color=move || if active_tab.get() == tab3 { "var(--color-primary)" } else { "" }
style:width="100%"
style:justify-content="flex-start"
>
{label}
</button>
}
}
+92
View File
@@ -0,0 +1,92 @@
// services/frontend-leptos/frontend/src/auth.rs
use leptos::prelude::*;
use wasm_bindgen_futures::spawn_local;
use crate::app::AuthContext;
use crate::api::auth as auth_api;
#[component]
pub fn AuthOverlay() -> impl IntoView {
let auth = use_context::<AuthContext>().expect("AuthContext not provided");
let (password, set_password) = create_signal(String::new());
let (error, set_error) = create_signal(Option::<String>::None);
let (loading, set_loading) = create_signal(false);
let handle_submit = move |ev: leptos::ev::SubmitEvent| {
ev.prevent_default();
let pwd = password.get();
if pwd.is_empty() {
set_error.set(Some("Password diperlukan".to_string()));
return;
}
set_loading.set(true);
set_error.set(None);
let auth_clone = auth.clone();
let pwd_clone = pwd.clone();
let set_loading_clone = set_loading.clone();
let set_error_clone = set_error.clone();
spawn_local(async move {
match auth_api::login(&pwd_clone).await {
Ok(true) => {
// Store password in sessionStorage
if let Some(storage) = web_sys::window()
.and_then(|w| w.local_storage().ok())
.flatten()
{
let _ = storage.set_item("admin-password", &pwd_clone);
}
auth_clone.authenticated.set(true);
auth_clone.password.set(pwd_clone);
}
Ok(false) => {
set_error_clone.set(Some("Login gagal — password salah".to_string()));
}
Err(e) => {
set_error_clone.set(Some(format!("Error: {}", e.message)));
}
}
set_loading_clone.set(false);
});
};
view! {
<div class="modal-overlay">
<div class="modal-content" style="width: 380px;">
<div class="modal-body" style="text-align: center;">
<div style="font-size: 3rem; margin-bottom: 1rem;">
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="var(--color-primary)" stroke-width="2">
<rect x="3" y="11" width="18" height="11" rx="2" ry="2"/>
<path d="M7 11V7a5 5 0 0 1 10 0v4"/>
</svg>
</div>
<h2 style="font-size: 1.25rem; font-weight: 600; margin-bottom: 0.5rem;">
"Akses Dashboard"
</h2>
<p style="font-size: 0.875rem; color: var(--text-secondary); margin-bottom: 1.5rem;">
"Masukkan password admin untuk melanjutkan"
</p>
<form on:submit=handle_submit style="display: flex; flex-direction: column; gap: 0.75rem;">
<input
type="password"
class="input"
placeholder="Password"
prop:value=password
on:input=move |ev| set_password.set(event_target_value(&ev))
/>
{move || error.get().map(|e| view! {
<p style="color: var(--color-error); font-size: 0.75rem;">{e}</p>
})}
<button
type="submit"
class="btn btn-primary w-full btn-lg"
disabled=move || loading.get()
>
{move || if loading.get() { "Memproses..." } else { "Masuk" }}
</button>
</form>
</div>
</div>
</div>
}
}
@@ -0,0 +1,137 @@
use leptos::prelude::*;
use shared_types::dashboard::DashboardChannel;
#[component]
pub fn ChannelSummaryList(
channels: Vec<DashboardChannel>,
loading: bool,
error: Option<String>,
search: String,
has_more: bool,
on_search_change: Box<dyn Fn(String) + Send + Sync + 'static>,
on_load_more: Box<dyn Fn() + Send + Sync + 'static>,
on_retry: Box<dyn Fn() + Send + Sync + 'static>,
) -> impl IntoView {
let search_cb = StoredValue::new(on_search_change);
let load_more_cb = StoredValue::new(on_load_more);
let retry_cb = StoredValue::new(on_retry);
view! {
<div class="card dashboard-list-card">
<div class="card-header">
<div class="card-title">"Kanal"</div>
<p class="card-description">"Ringkasan aktivitas, flagged message, dan budaya kanal."</p>
</div>
<div class="card-content">
<div class="dashboard-list-toolbar">
<input
class="input w-full"
placeholder="Search channels..."
prop:value=search
on:input=move |ev| search_cb.with_value(|cb| cb(event_target_value(&ev)))
/>
</div>
{move || {
if loading && channels.is_empty() {
view! { <ListSkeleton /> }.into_any()
} else if let Some(err) = error.clone() {
view! {
<div class="dashboard-list-empty">
<div class="text-error text-xl">""</div>
<p class="text-sm text-secondary">{err}</p>
<button class="btn btn-outline btn-sm" on:click=move |_| retry_cb.with_value(|cb| cb())>
"Retry"
</button>
</div>
}.into_any()
} else if channels.is_empty() {
view! {
<div class="dashboard-list-empty">
<div class="text-2xl">"#"</div>
<p class="text-sm text-secondary">"No channels found."</p>
</div>
}.into_any()
} else {
view! {
<div class="dashboard-summary-list">
{channels.clone().into_iter().map(|channel| view! {
<ChannelRow channel=channel />
}).collect::<Vec<_>>()}
</div>
}.into_any()
}
}}
{move || {
(has_more && !loading).then(|| view! {
<div class="mt-4 text-center">
<button class="btn btn-outline btn-sm" on:click=move |_| load_more_cb.with_value(|cb| cb())>
"Load more channels"
</button>
</div>
})
}}
</div>
</div>
}
}
#[component]
fn ChannelRow(channel: DashboardChannel) -> impl IntoView {
let name = channel.channel_name.clone().unwrap_or_else(|| channel.channel_id.clone());
let summary = channel
.culture_summary
.clone()
.unwrap_or_else(|| format!("{} messages", format_number(channel.total_messages)));
let last_seen = channel.last_message_at.map(format_timestamp);
view! {
<div class="dashboard-summary-row">
<div class="dashboard-summary-avatar dashboard-channel-avatar">
<span>"#"</span>
</div>
<div class="dashboard-summary-main">
<div class="dashboard-summary-title">{format!("#{}", name)}</div>
<div class="dashboard-summary-text">{summary}</div>
<div class="dashboard-summary-meta">
<span>{format!("{} messages", format_number(channel.total_messages))}</span>
<span>{format!("{} flagged", format_number(channel.flagged_count))}</span>
{last_seen.map(|t| view! { <span>{format!("Last: {}", t)}</span> })}
</div>
</div>
</div>
}
}
#[component]
fn ListSkeleton() -> impl IntoView {
view! {
<div class="dashboard-summary-list">
{(0..5).map(|_| view! {
<div class="dashboard-summary-row">
<div class="skeleton skeleton-circular" style="width:40px;height:40px"></div>
<div class="dashboard-summary-main">
<div class="skeleton" style="height:16px;width:160px"></div>
<div class="skeleton mt-2" style="height:14px;width:240px"></div>
</div>
</div>
}).collect::<Vec<_>>()}
</div>
}
}
fn format_number(value: u64) -> String {
let raw = value.to_string();
let mut out = String::new();
for (idx, ch) in raw.chars().rev().enumerate() {
if idx > 0 && idx % 3 == 0 { out.push(','); }
out.push(ch);
}
out.chars().rev().collect()
}
fn format_timestamp(ts: i64) -> String {
let d = js_sys::Date::new(&wasm_bindgen::JsValue::from_f64((ts as f64) * 1000.0));
d.to_locale_date_string("en-US", &wasm_bindgen::JsValue::UNDEFINED).into()
}
@@ -0,0 +1,7 @@
pub mod stats_overview;
pub mod user_summary_list;
pub mod channel_summary_list;
pub use stats_overview::StatsOverview;
pub use user_summary_list::UserSummaryList;
pub use channel_summary_list::ChannelSummaryList;
@@ -0,0 +1,145 @@
use leptos::prelude::*;
use shared_types::dashboard::{DashboardStats, TopChannel};
#[component]
pub fn StatsOverview(
stats: Option<DashboardStats>,
loading: bool,
error: Option<String>,
on_retry: Box<dyn Fn() + Send + Sync + 'static>,
) -> impl IntoView {
let retry = StoredValue::new(on_retry);
view! {
<div class="dashboard-stats">
{move || {
if loading {
view! { <StatsSkeleton /> }.into_any()
} else if let Some(err) = error.clone() {
view! {
<div class="card p-6 text-center">
<div class="text-error text-2xl mb-2">""</div>
<p class="text-sm text-secondary mb-4">{err}</p>
<button class="btn btn-outline btn-sm" on:click=move |_| retry.with_value(|cb| cb())>
"Retry"
</button>
</div>
}.into_any()
} else if let Some(stats) = stats.clone() {
view! {
<div class="dashboard-stats-grid">
<MetricCard label="Total Messages" value=stats.total_messages icon="💬" tone="primary" />
<MetricCard label="Today's Messages" value=stats.today_messages icon="📅" tone="success" />
<MetricCard label="Total Users" value=stats.total_users icon="👥" tone="primary" />
<MetricCard label="Active 24h" value=stats.active_users_24h icon="🟢" tone="success" />
<MetricCard label="Flagged" value=stats.total_flagged icon="🚩" tone="error" />
<MetricCard label="Clean" value=stats.total_clean icon="" tone="success" />
<MetricCard label="Voice Recordings" value=stats.total_voice_recordings icon="🎙" tone="info" />
<MetricCard label="AI Profiles" value=stats.total_profiles icon="🧠" tone="warning" />
<div class="card dashboard-wide-card">
<div class="card-header">
<div class="card-title">"Top Channels"</div>
</div>
<div class="card-content">
<TopChannels channels=stats.top_channels.clone() />
</div>
</div>
<div class="card dashboard-wide-card">
<div class="card-header">
<div class="card-title">"Moderation Queue"</div>
</div>
<div class="card-content">
<div class="dashboard-moderation-grid">
<QueueMetric label="Pending" value=stats.moderation_overview.pending tone="secondary" />
<QueueMetric label="Processing" value=stats.moderation_overview.processing tone="warning" />
<QueueMetric label="Errors" value=stats.moderation_overview.error tone="error" />
</div>
</div>
</div>
</div>
}.into_any()
} else {
view! {
<div class="card p-6 text-center">
<p class="text-sm text-secondary">"No dashboard data available yet."</p>
</div>
}.into_any()
}
}}
</div>
}
}
#[component]
fn MetricCard(label: &'static str, value: u64, icon: &'static str, tone: &'static str) -> impl IntoView {
view! {
<div class="card dashboard-metric-card">
<div class="dashboard-metric-content">
<div>
<div class="dashboard-metric-label">{label}</div>
<div class="dashboard-metric-value">{format_number(value)}</div>
</div>
<div class=format!("dashboard-metric-icon tone-{}", tone)>{icon}</div>
</div>
</div>
}
}
#[component]
fn QueueMetric(label: &'static str, value: u64, tone: &'static str) -> impl IntoView {
view! {
<div class=format!("dashboard-queue-card tone-{}", tone)>
<div class="dashboard-queue-value">{format_number(value)}</div>
<div class="dashboard-queue-label">{label}</div>
</div>
}
}
#[component]
fn TopChannels(channels: Vec<TopChannel>) -> impl IntoView {
if channels.is_empty() {
return view! { <p class="text-sm text-secondary">"No channel data yet."</p> }.into_any();
}
view! {
<div class="dashboard-top-channels">
{channels.into_iter().map(|ch| {
let name = ch.channel_name.unwrap_or_else(|| ch.channel_id.clone());
view! {
<div class="dashboard-top-channel-row">
<span class="truncate">{format!("#{}", name)}</span>
<span class="font-semibold">{format_number(ch.message_count)}</span>
</div>
}
}).collect::<Vec<_>>()}
</div>
}.into_any()
}
#[component]
fn StatsSkeleton() -> impl IntoView {
view! {
<div class="dashboard-stats-grid">
{(0..8).map(|_| view! {
<div class="card dashboard-metric-card">
<div class="skeleton" style="height:14px;width:96px"></div>
<div class="skeleton mt-2" style="height:32px;width:72px"></div>
</div>
}).collect::<Vec<_>>()}
</div>
}
}
fn format_number(value: u64) -> String {
let raw = value.to_string();
let mut out = String::new();
for (idx, ch) in raw.chars().rev().enumerate() {
if idx > 0 && idx % 3 == 0 {
out.push(',');
}
out.push(ch);
}
out.chars().rev().collect()
}
@@ -0,0 +1,142 @@
use leptos::prelude::*;
use shared_types::dashboard::DashboardUser;
#[component]
pub fn UserSummaryList(
users: Vec<DashboardUser>,
loading: bool,
error: Option<String>,
search: String,
has_more: bool,
on_search_change: Box<dyn Fn(String) + Send + Sync + 'static>,
on_load_more: Box<dyn Fn() + Send + Sync + 'static>,
on_retry: Box<dyn Fn() + Send + Sync + 'static>,
) -> impl IntoView {
let search_cb = StoredValue::new(on_search_change);
let load_more_cb = StoredValue::new(on_load_more);
let retry_cb = StoredValue::new(on_retry);
view! {
<div class="card dashboard-list-card">
<div class="card-header">
<div class="card-title">"Pengguna"</div>
<p class="card-description">"Ringkasan aktivitas dan trust score pengguna."</p>
</div>
<div class="card-content">
<div class="dashboard-list-toolbar">
<input
class="input w-full"
placeholder="Search users..."
prop:value=search
on:input=move |ev| search_cb.with_value(|cb| cb(event_target_value(&ev)))
/>
</div>
{move || {
if loading && users.is_empty() {
view! { <ListSkeleton /> }.into_any()
} else if let Some(err) = error.clone() {
view! {
<div class="dashboard-list-empty">
<div class="text-error text-xl">""</div>
<p class="text-sm text-secondary">{err}</p>
<button class="btn btn-outline btn-sm" on:click=move |_| retry_cb.with_value(|cb| cb())>
"Retry"
</button>
</div>
}.into_any()
} else if users.is_empty() {
view! {
<div class="dashboard-list-empty">
<div class="text-2xl">"👤"</div>
<p class="text-sm text-secondary">"No users found."</p>
</div>
}.into_any()
} else {
view! {
<div class="dashboard-summary-list">
{users.clone().into_iter().map(|user| view! {
<UserRow user=user />
}).collect::<Vec<_>>()}
</div>
}.into_any()
}
}}
{move || {
(has_more && !loading).then(|| view! {
<div class="mt-4 text-center">
<button class="btn btn-outline btn-sm" on:click=move |_| load_more_cb.with_value(|cb| cb())>
"Load more users"
</button>
</div>
})
}}
</div>
</div>
}
}
#[component]
fn UserRow(user: DashboardUser) -> impl IntoView {
let name = user.username.clone().unwrap_or_else(|| user.user_id.clone());
let summary = user
.profile_summary
.clone()
.unwrap_or_else(|| format!("{} messages", format_number(user.total_messages)));
let trust = user.trust_score.map(|score| format!("Trust: {:.2}", score));
let last_seen = user.last_message_at.map(format_timestamp);
view! {
<div class="dashboard-summary-row">
<div class="dashboard-summary-avatar">
{if let Some(url) = user.avatar_url.clone() {
view! { <img src=url alt="" class="dashboard-summary-avatar-img" /> }.into_any()
} else {
view! { <span>"👤"</span> }.into_any()
}}
</div>
<div class="dashboard-summary-main">
<div class="dashboard-summary-title">{name}</div>
<div class="dashboard-summary-text">{summary}</div>
<div class="dashboard-summary-meta">
<span>{format!("{} flagged", format_number(user.flagged_count))}</span>
{trust.map(|t| view! { <span>{t}</span> })}
{last_seen.map(|t| view! { <span>{format!("Last: {}", t)}</span> })}
</div>
</div>
</div>
}
}
#[component]
fn ListSkeleton() -> impl IntoView {
view! {
<div class="dashboard-summary-list">
{(0..5).map(|_| view! {
<div class="dashboard-summary-row">
<div class="skeleton skeleton-circular" style="width:40px;height:40px"></div>
<div class="dashboard-summary-main">
<div class="skeleton" style="height:16px;width:160px"></div>
<div class="skeleton mt-2" style="height:14px;width:240px"></div>
</div>
</div>
}).collect::<Vec<_>>()}
</div>
}
}
fn format_number(value: u64) -> String {
let raw = value.to_string();
let mut out = String::new();
for (idx, ch) in raw.chars().rev().enumerate() {
if idx > 0 && idx % 3 == 0 { out.push(','); }
out.push(ch);
}
out.chars().rev().collect()
}
fn format_timestamp(ts: i64) -> String {
let d = js_sys::Date::new(&wasm_bindgen::JsValue::from_f64((ts as f64) * 1000.0));
d.to_locale_date_string("en-US", &wasm_bindgen::JsValue::UNDEFINED).into()
}
@@ -0,0 +1,223 @@
pub mod components;
use components::{ChannelSummaryList, StatsOverview, UserSummaryList};
use leptos::prelude::*;
use shared_types::dashboard::{DashboardChannel, DashboardStats, DashboardUser};
use std::sync::Arc;
use wasm_bindgen_futures::spawn_local;
#[derive(Clone, PartialEq)]
enum DashboardTab {
Stats,
Users,
Channels,
}
#[component]
pub fn DashboardPanel() -> impl IntoView {
let active_tab = RwSignal::new(DashboardTab::Stats);
let stats = RwSignal::new(None::<DashboardStats>);
let stats_loading = RwSignal::new(false);
let stats_error = RwSignal::new(None::<String>);
let users = RwSignal::new(Vec::<DashboardUser>::new());
let users_loading = RwSignal::new(false);
let users_error = RwSignal::new(None::<String>);
let users_search = RwSignal::new(String::new());
let users_cursor = RwSignal::new(None::<String>);
let channels = RwSignal::new(Vec::<DashboardChannel>::new());
let channels_loading = RwSignal::new(false);
let channels_error = RwSignal::new(None::<String>);
let channels_search = RwSignal::new(String::new());
let channels_cursor = RwSignal::new(None::<String>);
let fetch_stats: Arc<dyn Fn() + Send + Sync + 'static> = Arc::new(move || {
stats_loading.set(true);
stats_error.set(None);
spawn_local(async move {
match crate::api::dashboard::get_dashboard_stats().await {
Ok(data) => stats.set(Some(data)),
Err(err) => stats_error.set(Some(format!("Failed to load stats: {}", err))),
}
stats_loading.set(false);
});
});
let fetch_users: Arc<dyn Fn(bool) + Send + Sync + 'static> = Arc::new(move |reset: bool| {
if users_loading.get() {
return;
}
users_loading.set(true);
users_error.set(None);
let cursor = if reset { None } else { users_cursor.get() };
let search = users_search.get();
spawn_local(async move {
let search_ref = (!search.trim().is_empty()).then_some(search.trim());
match crate::api::dashboard::get_dashboard_users(Some(20), cursor.as_deref(), search_ref).await {
Ok(page) => {
if reset {
users.set(page.data);
} else {
let mut current = users.get();
current.extend(page.data);
users.set(current);
}
users_cursor.set(page.next_cursor);
}
Err(err) => users_error.set(Some(format!("Failed to load users: {}", err))),
}
users_loading.set(false);
});
});
let fetch_channels: Arc<dyn Fn(bool) + Send + Sync + 'static> = Arc::new(move |reset: bool| {
if channels_loading.get() {
return;
}
channels_loading.set(true);
channels_error.set(None);
let cursor = if reset { None } else { channels_cursor.get() };
let search = channels_search.get();
spawn_local(async move {
let search_ref = (!search.trim().is_empty()).then_some(search.trim());
match crate::api::dashboard::get_dashboard_channels(Some(20), cursor.as_deref(), search_ref, None).await {
Ok(page) => {
if reset {
channels.set(page.data);
} else {
let mut current = channels.get();
current.extend(page.data);
channels.set(current);
}
channels_cursor.set(page.next_cursor);
}
Err(err) => channels_error.set(Some(format!("Failed to load channels: {}", err))),
}
channels_loading.set(false);
});
});
{
let fetch_stats = fetch_stats.clone();
let fetch_users = fetch_users.clone();
let fetch_channels = fetch_channels.clone();
create_effect(move |_| {
fetch_stats();
fetch_users(true);
fetch_channels(true);
});
}
view! {
<div class="dashboard-panel">
<div class="dashboard-header">
<div>
<h2 class="text-2xl font-bold">"Dashboard Guild"</h2>
<p class="text-sm text-secondary mt-2">
"Pantau statistik, profil pengguna, dan aktivitas kanal komunitas IMPHNEN."
</p>
</div>
</div>
<div class="tabs">
<div class="tab-list mb-6">
<DashboardTabButton tab=DashboardTab::Stats active_tab=active_tab label="Statistik" icon="📊" />
<DashboardTabButton tab=DashboardTab::Users active_tab=active_tab label="Pengguna" icon="👥" />
<DashboardTabButton tab=DashboardTab::Channels active_tab=active_tab label="Kanal" icon="#" />
</div>
<div class="tab-content" style:display=move || if active_tab.get() == DashboardTab::Stats { "block" } else { "none" }>
<StatsOverview
stats=stats.get()
loading=stats_loading.get()
error=stats_error.get()
on_retry=Box::new({
let fetch_stats = fetch_stats.clone();
move || fetch_stats()
})
/>
</div>
<div class="tab-content" style:display=move || if active_tab.get() == DashboardTab::Users { "block" } else { "none" }>
<UserSummaryList
users=users.get()
loading=users_loading.get()
error=users_error.get()
search=users_search.get()
has_more=users_cursor.get().is_some()
on_search_change=Box::new({
let fetch_users = fetch_users.clone();
move |value| {
users_search.set(value);
users_cursor.set(None);
fetch_users(true);
}
})
on_load_more=Box::new({
let fetch_users = fetch_users.clone();
move || fetch_users(false)
})
on_retry=Box::new({
let fetch_users = fetch_users.clone();
move || fetch_users(true)
})
/>
</div>
<div class="tab-content" style:display=move || if active_tab.get() == DashboardTab::Channels { "block" } else { "none" }>
<ChannelSummaryList
channels=channels.get()
loading=channels_loading.get()
error=channels_error.get()
search=channels_search.get()
has_more=channels_cursor.get().is_some()
on_search_change=Box::new({
let fetch_channels = fetch_channels.clone();
move |value| {
channels_search.set(value);
channels_cursor.set(None);
fetch_channels(true);
}
})
on_load_more=Box::new({
let fetch_channels = fetch_channels.clone();
move || fetch_channels(false)
})
on_retry=Box::new({
let fetch_channels = fetch_channels.clone();
move || fetch_channels(true)
})
/>
</div>
</div>
</div>
}
}
#[component]
fn DashboardTabButton(
tab: DashboardTab,
active_tab: RwSignal<DashboardTab>,
label: &'static str,
icon: &'static str,
) -> impl IntoView {
let tab_for_class = tab.clone();
let tab_for_aria = tab.clone();
let tab_for_click = tab;
view! {
<button
class="tab-trigger"
class:active=move || active_tab.get() == tab_for_class
aria-selected=move || if active_tab.get() == tab_for_aria { "true" } else { "false" }
on:click=move |_| active_tab.set(tab_for_click.clone())
>
<span>{icon}</span>
<span>{label}</span>
</button>
}
}
@@ -0,0 +1,2 @@
pub mod ring_buffer;
pub mod pcm_decoder;
@@ -0,0 +1,69 @@
/// PCM Frame decoded from binary WebSocket data
/// Format: [u32 userId (4 bytes)][i16 samples (N bytes)]
pub struct PcmFrame {
pub user_id: u32,
pub samples: Vec<f32>, // Normalized to [-1.0, 1.0]
}
/// Decode a binary WebSocket message into PCM frames
/// Returns None if data is too short or malformed
pub fn decode_pcm_frame(data: &[u8]) -> Option<PcmFrame> {
if data.len() < 4 {
return None;
}
let user_id = u32::from_le_bytes([data[0], data[1], data[2], data[3]]);
let sample_bytes = &data[4..];
let sample_count = sample_bytes.len() / 2;
if sample_count == 0 {
return None;
}
let samples = decode_i16_samples(sample_bytes);
Some(PcmFrame { user_id, samples })
}
/// Decode raw i16 PCM bytes to normalized f32 samples [-1.0, 1.0]
pub fn decode_i16_samples(data: &[u8]) -> Vec<f32> {
let count = data.len() / 2;
let mut out = Vec::with_capacity(count);
for i in 0..count {
let offset = i * 2;
if offset + 1 < data.len() {
let sample = i16::from_le_bytes([data[offset], data[offset + 1]]);
out.push((sample as f32) / 32768.0);
}
}
out
}
/// Encode f32 samples [-1.0, 1.0] to base64 for WebSocket transmission
/// Uses JavaScript btoa for encoding
pub fn encode_samples_to_base64(samples: &[f32]) -> String {
// Convert f32 samples to i16 bytes
let mut bytes = Vec::with_capacity(samples.len() * 2);
for &sample in samples {
let clamped = sample.max(-1.0).min(1.0);
let int_sample = (clamped * 32767.0) as i16;
bytes.extend_from_slice(&int_sample.to_le_bytes());
}
encode_bytes_base64(&bytes)
}
/// Encode raw bytes to base64 using JavaScript's btoa
fn encode_bytes_base64(data: &[u8]) -> String {
// Build binary string for btoa
let binary: String = data.iter().map(|&b| b as char).collect();
// Call btoa from JavaScript via js_sys::eval
let js_code = format!("btoa('{}')", binary.replace('\'', "\\'"));
js_sys::eval(&js_code)
.ok()
.and_then(|r| r.as_string())
.unwrap_or_default()
}
use wasm_bindgen::prelude::*;
@@ -0,0 +1,124 @@
use std::sync::{Arc, Mutex};
/// AudioRingBuffer — Fixed-size circular buffer for real-time PCM streaming
/// Provides thread-safe write/read with automatic overwrite protection
pub struct AudioRingBuffer {
buffer: Vec<f32>,
capacity: usize,
write_pos: usize,
read_pos: usize,
available: usize,
}
impl AudioRingBuffer {
/// Create a new ring buffer with given capacity (in samples)
pub fn new(capacity: usize) -> Self {
Self {
buffer: vec![0.0; capacity],
capacity,
write_pos: 0,
read_pos: 0,
available: 0,
}
}
/// Write samples to the ring buffer. Overwrites oldest data if full.
pub fn write(&mut self, samples: &[f32]) {
let mut written = 0;
while written < samples.len() {
let chunk = (samples.len() - written).min(self.capacity - self.write_pos);
let src = &samples[written..written + chunk];
let dest = &mut self.buffer[self.write_pos..self.write_pos + chunk];
dest.copy_from_slice(src);
written += chunk;
self.write_pos = (self.write_pos + chunk) % self.capacity;
self.available = (self.available + chunk).min(self.capacity);
// If we overwrote unread data, advance read_pos
if self.available == self.capacity {
self.read_pos = self.write_pos;
}
}
}
/// Read up to `max_samples` from the buffer. Returns the samples read.
pub fn read(&mut self, max_samples: usize) -> Vec<f32> {
let to_read = max_samples.min(self.available);
let mut out = Vec::with_capacity(to_read);
let mut remaining = to_read;
while remaining > 0 {
let chunk = remaining.min(self.capacity - self.read_pos);
out.extend_from_slice(&self.buffer[self.read_pos..self.read_pos + chunk]);
remaining -= chunk;
self.read_pos = (self.read_pos + chunk) % self.capacity;
}
self.available -= to_read;
out
}
/// Number of samples available to read
pub fn available_samples(&self) -> usize {
self.available
}
/// Clear all buffered data
pub fn clear(&mut self) {
self.write_pos = 0;
self.read_pos = 0;
self.available = 0;
}
}
/// Thread-safe wrapper around AudioRingBuffer
pub struct SharedRingBuffer {
inner: Arc<Mutex<AudioRingBuffer>>,
}
impl SharedRingBuffer {
pub fn new(capacity: usize) -> Self {
Self {
inner: Arc::new(Mutex::new(AudioRingBuffer::new(capacity))),
}
}
pub fn write(&self, samples: &[f32]) {
if let Ok(mut guard) = self.inner.lock() {
guard.write(samples);
}
}
pub fn read(&self, max_samples: usize) -> Vec<f32> {
if let Ok(mut guard) = self.inner.lock() {
guard.read(max_samples)
} else {
Vec::new()
}
}
pub fn available_samples(&self) -> usize {
if let Ok(guard) = self.inner.lock() {
guard.available_samples()
} else {
0
}
}
pub fn clear(&self) {
if let Ok(mut guard) = self.inner.lock() {
guard.clear();
}
}
pub fn clone_inner(&self) -> Arc<Mutex<AudioRingBuffer>> {
self.inner.clone()
}
}
impl Clone for SharedRingBuffer {
fn clone(&self) -> Self {
Self {
inner: self.inner.clone(),
}
}
}
@@ -0,0 +1,80 @@
use leptos::prelude::*;
use shared_types::voice::ActiveSpeaker;
/// ActiveSpeakers component for Leptos
/// Displays a real-time list of speaking users with avatar and status indicator
#[component]
pub fn ActiveSpeakers(
#[prop(optional)] speakers: RwSignal<Vec<ActiveSpeaker>>,
#[prop(optional)] class: &'static str,
) -> impl IntoView {
let empty_state = move || speakers.get().is_empty();
view! {
<div class=class>
<Show
when=empty_state
fallback=move || {
view! {
<div class="space-y-2">
<For
each=move || speakers.get()
key=|s| s.user_id.clone() + &s.username
let:speaker
>
<div class="flex items-center gap-3 rounded-xl border border-border bg-card p-3">
<div class="h-8 w-8 flex-shrink-0">
{speaker.avatar.as_ref().map(|avatar_url| {
let url = avatar_url.clone();
view! {
<img
src=url
alt=""
class="h-8 w-8 rounded-full object-cover ring-2 ring-primary/30"
/>
}
})}
</div>
<div class="min-w-0 flex-1">
<div class="truncate text-sm font-medium">
{speaker.username.clone()}
</div>
<div class="flex items-center gap-1.5">
<span class=move || {
if speaker.speaking {
"inline-block h-2 w-2 rounded-full bg-emerald-500"
} else {
"inline-block h-2 w-2 rounded-full bg-muted-foreground/40"
}
}></span>
<span class=move || {
if speaker.speaking {
"text-xs font-medium text-emerald-600 dark:text-emerald-400"
} else {
"text-xs font-medium text-muted-foreground"
}
}>
{move || if speaker.speaking { "Speaking" } else { "Silent" }}
</span>
</div>
</div>
</div>
</For>
</div>
}
}
>
<div class="rounded-xl border border-border bg-card p-8 text-center shadow-sm">
<div class="space-y-2">
<div class="text-4xl">
"🎤"
</div>
<p class="text-sm text-muted-foreground">
"No active speakers"
</p>
</div>
</div>
</Show>
</div>
}
}
@@ -0,0 +1,69 @@
use leptos::prelude::*;
use std::sync::{Arc, Mutex};
/// AudioVisualizer — Real-time 32-bar frequency spectrum display
/// Simplified implementation using CSS bars updated via signals
#[component]
pub fn AudioVisualizer(
#[prop(default = true)] _active: bool,
#[prop(optional)] pcm_data: Option<Arc<Mutex<Vec<f32>>>>,
) -> impl IntoView {
let bars = create_rw_signal::<Vec<f32>>(vec![0.0; 32]);
// Periodically update bars from PCM data
create_effect(move |_| {
if let Some(ref pcm_arc) = pcm_data {
if let Ok(pcm_vec) = pcm_arc.lock() {
let computed = compute_frequency_bands(&pcm_vec);
bars.update(|b| {
for i in 0..32 {
let target = computed.get(i).copied().unwrap_or(0.0).max(0.0).min(1.0);
b[i] = b[i] * 0.7 + target * 0.3; // Smooth decay
}
});
}
}
});
view! {
<div class="audio-visualizer">
<div class="audio-visualizer-bars">
{(0..32).map(|i| {
view! {
<div
class="audio-bar"
style=move || {
let height = bars.get()[i] * 100.0;
format!("height: {}%", height)
}
></div>
}
}).collect::<Vec<_>>()}
</div>
</div>
}
}
/// Compute 32-band frequency spectrum from PCM samples
fn compute_frequency_bands(pcm_samples: &[f32]) -> Vec<f32> {
let mut bands = vec![0.0; 32];
if pcm_samples.is_empty() {
return bands;
}
let samples_per_band = (pcm_samples.len() / 32).max(1);
for (band_idx, band) in bands.iter_mut().enumerate() {
let start = band_idx * samples_per_band;
let end = ((band_idx + 1) * samples_per_band).min(pcm_samples.len());
if start < pcm_samples.len() {
let slice = &pcm_samples[start..end];
let rms = (slice.iter().map(|s| s * s).sum::<f32>() / slice.len() as f32).sqrt();
*band = rms.min(1.0);
}
}
bands
}
@@ -0,0 +1,84 @@
use leptos::prelude::*;
use std::sync::{Arc, Mutex};
/// MicLevelMeter — Horizontal level indicator for microphone input
/// Displays 0-100% amplitude as a filling bar with smooth decay
#[component]
pub fn MicLevelMeter(
#[prop(default = true)] active: bool,
#[prop(optional)] pcm_data: Option<Arc<Mutex<Vec<f32>>>>,
#[prop(optional)] label: Option<&'static str>,
) -> impl IntoView {
let level = create_rw_signal::<f32>(0.0);
let peak = create_rw_signal::<f32>(0.0);
// Update level periodically
create_effect(move |_| {
if !active {
return;
}
if let Some(ref pcm_arc) = pcm_data {
if let Ok(pcm_vec) = pcm_arc.lock() {
let current_level = compute_rms(&pcm_vec);
level.update(|l| {
*l = *l * 0.8 + current_level * 0.2; // Smooth decay
});
peak.update(|p| {
*p = (*p * 0.95).max(current_level); // Peak hold with decay
});
}
}
});
let level_percent = move || (level.get() * 100.0).min(100.0);
let peak_percent = move || (peak.get() * 100.0).min(100.0);
// Determine color based on level
let level_color = move || {
let l = level.get();
if l < 0.5 {
"bg-green-500"
} else if l < 0.75 {
"bg-yellow-500"
} else {
"bg-red-500"
}
};
view! {
<div class="mic-level-meter">
{label.map(|l| view! {
<label class="text-xs font-medium text-foreground mb-1.5">{l}</label>
})}
<div class="flex items-center gap-2">
{/* Main level bar */}
<div class="relative flex-1 h-2 rounded-full bg-surface border border-border/50 overflow-hidden">
<div
class=move || format!("h-full {} transition-all", level_color())
style=move || format!("width: {}%", level_percent())
></div>
{/* Peak indicator */}
<div
class="absolute h-full w-0.5 bg-destructive/70"
style=move || format!("left: {}%", peak_percent())
></div>
</div>
{/* Percentage display */}
<span class="text-xs font-mono text-muted-foreground w-8 text-right">
{move || format!("{}%", (level_percent() as u8))}
</span>
</div>
</div>
}
}
/// Compute RMS (Root Mean Square) amplitude from PCM samples
/// Returns normalized value 0.0-1.0
fn compute_rms(samples: &[f32]) -> f32 {
if samples.is_empty() {
return 0.0;
}
let mean_square = samples.iter().map(|s| s * s).sum::<f32>() / samples.len() as f32;
mean_square.sqrt()
}
@@ -0,0 +1,19 @@
pub mod voice_connection_card;
pub mod active_speakers;
pub mod audio_visualizer;
pub mod mic_level_meter;
pub mod now_playing;
pub mod music_sub_panel;
pub mod screen_sub_panel;
pub mod recordings_sub_panel;
pub mod waveform_player;
pub use voice_connection_card::VoiceConnectionCard;
pub use active_speakers::ActiveSpeakers;
pub use audio_visualizer::AudioVisualizer;
pub use mic_level_meter::MicLevelMeter;
pub use now_playing::NowPlaying;
pub use music_sub_panel::MusicSubPanel;
pub use screen_sub_panel::ScreenSubPanel;
pub use recordings_sub_panel::RecordingsSubPanel;
pub use waveform_player::WaveformPlayer;
@@ -0,0 +1,56 @@
use leptos::prelude::*;
/// MusicSubPanel — Music playlist controls and URL input
#[component]
pub fn MusicSubPanel(
#[prop(optional)] on_queue: Option<Box<dyn Fn(String) + Send + Sync + 'static>>,
) -> impl IntoView {
let (url_input, set_url_input) = create_signal::<String>(String::new());
let (is_loading, set_is_loading) = create_signal::<bool>(false);
let handle_queue_click = move |_| {
let url = url_input.get().trim().to_string();
if !url.is_empty() {
if let Some(ref cb) = on_queue {
set_is_loading.set(true);
cb(url.clone());
set_url_input.set(String::new());
set_is_loading.set(false);
}
}
};
view! {
<div class="music-sub-panel card">
<div class="card-header">
<div class="card-title flex items-center gap-2">
<svg class="h-4 w-4" 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>
<path d="M9 8h6v8h-6z"></path>
</svg>
"Music"
</div>
</div>
<div class="card-content space-y-3">
<div class="space-y-2">
<label class="text-xs font-medium text-foreground">"YouTube URL or Search"</label>
<input
type="text"
class="input w-full text-sm"
placeholder="youtube.com/watch?v=... or song name"
prop:value=url_input
on:input=move |ev| set_url_input.set(event_target_value(&ev))
disabled=move || is_loading.get()
/>
</div>
<button
class=move || format!("btn btn-primary w-full {}", if is_loading.get() { "opacity-50" } else { "" })
on:click=handle_queue_click
disabled=move || url_input.get().is_empty() || is_loading.get()
>
{move || if is_loading.get() { "Queuing..." } else { "Queue Music" }}
</button>
</div>
</div>
}
}
@@ -0,0 +1,104 @@
use leptos::prelude::*;
use shared_types::media::MediaState;
/// NowPlaying — Displays current media item and queue info
#[component]
pub fn NowPlaying(
#[prop(optional)] state: Option<MediaState>,
#[prop(optional)] on_skip: Option<Box<dyn Fn() + Send + Sync + 'static>>,
#[prop(optional)] on_stop: Option<Box<dyn Fn() + Send + Sync + 'static>>,
) -> impl IntoView {
let media_state = create_rw_signal::<Option<MediaState>>(state);
// Wrap callbacks in StoredValue for shareable non-Clone ownership in Leptos context
let skip_cb = StoredValue::new(on_skip);
let stop_cb = StoredValue::new(on_stop);
let has_skip = skip_cb.with_value(|v| v.is_some());
let has_stop = stop_cb.with_value(|v| v.is_some());
view! {
<div class="now-playing card">
<div class="card-header">
<div class="card-title">"Now Playing"</div>
</div>
<div class="card-content space-y-3">
{move || {
media_state.get().map(|ms| {
let current = ms.current.as_ref().cloned();
let queue_len = ms.queue.len();
view! {
<>
{current.map(|item| {
let title = item.title.clone().unwrap_or_else(|| "Unknown".to_string());
let duration_ms = item.duration_ms.unwrap_or(0);
let duration_sec = duration_ms / 1000;
view! {
<div class="space-y-2">
<div class="text-sm font-medium text-foreground truncate">
{title}
</div>
<div class="flex items-center justify-between text-xs text-muted-foreground">
<span>{format!("{}s", duration_sec)}</span>
</div>
<div class="flex items-center gap-2">
{has_skip.then(|| {
view! {
<button
class="btn btn-sm btn-outline flex-1"
on:click=move |_| { skip_cb.with_value(|cb| { if let Some(cb) = cb { cb(); } }); }
>
"⏭ Skip"
</button>
}
})}
{has_stop.then(|| {
view! {
<button
class="btn btn-sm btn-destructive flex-1"
on:click=move |_| { stop_cb.with_value(|cb| { if let Some(cb) = cb { cb(); } }); }
>
"⏹ Stop"
</button>
}
})}
</div>
</div>
}
})}
{(queue_len > 0).then(|| {
view! {
<div class="border-t border-border/50 pt-3">
<div class="text-xs font-medium text-muted-foreground">
{format!("Queue: {} item{}", queue_len, if queue_len == 1 { "" } else { "s" })}
</div>
</div>
}
})}
{(queue_len == 0).then(|| {
view! {
<div class="text-xs text-muted-foreground text-center py-2">
"Queue is empty"
</div>
}
})}
</>
}
})
}}
{move || {
media_state.get().is_none().then(|| {
view! {
<div class="text-xs text-muted-foreground text-center py-4">
"No media connected"
</div>
}
})
}}
</div>
</div>
}
}
@@ -0,0 +1,170 @@
use leptos::prelude::*;
use shared_types::recording::VoiceRecording;
use crate::api::recordings::{get_recordings, delete_recording};
/// RecordingsSubPanel — Paginated list of voice recordings
#[component]
pub fn RecordingsSubPanel() -> impl IntoView {
let recordings = create_rw_signal::<Vec<VoiceRecording>>(Vec::new());
let loading = create_rw_signal::<bool>(false);
let has_more = create_rw_signal::<bool>(true);
let next_cursor = create_rw_signal::<Option<String>>(None);
// Load recordings
let load = move |reset: bool| {
if loading.get() { return; }
loading.set(true);
let cursor_val = if reset { None } else { next_cursor.get() };
wasm_bindgen_futures::spawn_local({
async move {
match get_recordings(Some(20), cursor_val.as_deref()).await {
Ok(resp) => {
if reset {
recordings.set(resp.items);
} else {
let mut current = recordings.get();
current.extend(resp.items);
recordings.set(current);
}
has_more.set(resp.has_more);
next_cursor.set(resp.next_cursor);
}
Err(_) => {
if reset {
recordings.set(Vec::new());
}
}
}
loading.set(false);
}
});
};
// Load on mount
create_effect(move |_| {
load(true);
});
// Delete recording handler
let do_delete = move |id: String| {
wasm_bindgen_futures::spawn_local({
let id = id.clone();
async move {
let _ = delete_recording(&id).await;
recordings.update(|r| r.retain(|rec| rec.id != id));
}
});
};
view! {
<div class="recordings-sub-panel card">
<div class="card-header">
<div class="card-title flex items-center gap-2">
<svg class="h-4 w-4" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M12 1a3 3 0 0 0-3 3v8a3 3 0 0 0 6 0V4a3 3 0 0 0-3-3z"></path>
<path d="M19 10v2a7 7 0 0 1-14 0v-2"></path>
<line x1="12" y1="19" x2="12" y2="23"></line>
<line x1="8" y1="23" x2="16" y2="23"></line>
</svg>
"Recordings"
</div>
<p class="card-description">"Voice channel recordings from all sessions."</p>
</div>
<div class="card-content">
{move || {
let recs = recordings.get();
if recs.is_empty() && !loading.get() {
view! {
<div class="flex flex-col items-center justify-center py-8 gap-2">
<p class="text-sm text-muted-foreground">"No recordings yet."</p>
<p class="text-xs text-muted-foreground">"Join a voice channel to start recording."</p>
</div>
}.into_any()
} else {
view! {
<div class="space-y-2">
{recs.iter().map(|rec| {
let id = rec.id.clone();
let username = rec.username.clone();
let channel_name = rec.channel_name.clone().unwrap_or_default();
let created_at = format_timestamp(rec.created_at);
let has_url = rec.download_url.is_some();
let url = rec.download_url.clone().unwrap_or_default();
view! {
<div class="recording-item flex items-center gap-3 p-3 rounded-lg border border-border/50 hover:bg-accent/5 transition-colors">
<div class="flex-1 min-w-0">
<div class="text-sm font-medium text-foreground truncate">
{username}
</div>
<div class="flex items-center gap-2 text-xs text-muted-foreground">
<span>{channel_name}</span>
<span>"·"</span>
<span>{format_size(rec.size_bytes)}</span>
<span>"·"</span>
<span>{created_at}</span>
</div>
</div>
<div class="flex items-center gap-1.5 shrink-0">
{has_url.then(|| {
view! {
<a
href=url
target="_blank"
class="btn btn-sm btn-outline"
>
"Download"
</a>
}
})}
<button
class="btn btn-sm btn-ghost text-destructive hover:text-destructive"
on:click=move |_| do_delete(id.clone())
>
"🗑"
</button>
</div>
</div>
}
}).collect::<Vec<_>>()}
</div>
}.into_any()
}
}}
{move || {
(has_more.get() && !loading.get()).then(|| {
view! {
<div class="mt-3 text-center">
<button
class="btn btn-sm btn-outline"
on:click=move |_| load(false)
>
"Load more"
</button>
</div>
}
})
}}
</div>
</div>
}
}
/// Format file size bytes to human readable
fn format_size(bytes: u64) -> String {
if bytes < 1024 {
format!("{} B", bytes)
} else if bytes < 1024 * 1024 {
format!("{:.1} KB", bytes as f64 / 1024.0)
} else {
format!("{:.1} MB", bytes as f64 / (1024.0 * 1024.0))
}
}
/// Format timestamp i64 to readable date
fn format_timestamp(ts: i64) -> String {
let d = js_sys::Date::new(&wasm_bindgen::JsValue::from_f64((ts as f64) * 1000.0));
d.to_locale_date_string("en-US", &wasm_bindgen::JsValue::UNDEFINED).into()
}
@@ -0,0 +1,84 @@
use leptos::prelude::*;
/// ScreenSubPanel — Screenshare controls
#[component]
pub fn ScreenSubPanel(
#[prop(optional)] on_start_stream: Option<Box<dyn Fn() + Send + Sync + 'static>>,
#[prop(optional)] on_stop_stream: Option<Box<dyn Fn() + Send + Sync + 'static>>,
) -> impl IntoView {
let (is_streaming, set_is_streaming) = create_signal::<bool>(false);
let has_start = on_start_stream.is_some();
let has_stop = on_stop_stream.is_some();
view! {
<div class="screen-sub-panel card">
<div class="card-header">
<div class="card-title flex items-center gap-2">
<svg class="h-4 w-4" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="2" y="3" width="20" height="14" rx="2" ry="2"></rect>
<line x1="8" y1="21" x2="16" y2="21"></line>
<line x1="12" y1="17" x2="12" y2="21"></line>
</svg>
"Screenshare"
</div>
</div>
<div class="card-content space-y-3">
<p class="text-xs text-muted-foreground">
"Stream your screen to the voice channel for everyone to see."
</p>
<div class="flex gap-2">
{has_start.then(|| {
view! {
<button
class=move || format!("btn btn-success flex-1 {}", if is_streaming.get() { "opacity-50" } else { "" })
disabled=move || is_streaming.get()
on:click=move |_| {
if !is_streaming.get() {
set_is_streaming.set(true);
if let Some(ref cb) = on_start_stream {
cb();
}
}
}
>
"🔴 Start Stream"
</button>
}
})}
{has_stop.then(|| {
view! {
<button
class=move || format!("btn btn-destructive flex-1 {}", if !is_streaming.get() { "opacity-50" } else { "" })
disabled=move || !is_streaming.get()
on:click=move |_| {
if is_streaming.get() {
set_is_streaming.set(false);
if let Some(ref cb) = on_stop_stream {
cb();
}
}
}
>
"⏹ Stop Stream"
</button>
}
})}
</div>
{move || {
is_streaming.get().then(|| {
view! {
<div class="rounded-md bg-success/10 px-2 py-1.5 text-xs text-success">
"🔴 Live streaming..."
</div>
}
})
}}
</div>
</div>
}
}
@@ -0,0 +1,223 @@
use leptos::prelude::*;
use wasm_bindgen::prelude::*;
use crate::features::live::hooks::use_voice_control::{use_voice_control, VoiceControlState};
/// VoiceConnectionCard component for Leptos
/// Renders guild and voice channel selectors with connect/disconnect controls
#[component]
pub fn VoiceConnectionCard(
#[prop(optional)] voice_state: Option<VoiceControlState>,
#[prop(optional)] class: &'static str,
) -> impl IntoView {
let default_state = use_voice_control();
let state = voice_state.unwrap_or(default_state);
// Reactive signal for selected guild
let (selected_guild, set_selected_guild) = create_signal::<String>(String::new());
// Reactive signal for selected channel
let (selected_channel, set_selected_channel) = create_signal::<String>(String::new());
// When guild is selected, load voice channels
create_effect(move |_| {
let guild_id = selected_guild.get();
if !guild_id.is_empty() {
(state.load_voice_channels)(guild_id);
}
});
// Load guilds on mount
create_effect(move |_| {
(state.load_guilds)();
});
let on_guild_change = move |ev: leptos::ev::Event| {
if let Some(target) = ev.target() {
if let Ok(select_el) = target.dyn_into::<web_sys::HtmlSelectElement>() {
set_selected_guild.set(select_el.value());
}
}
};
let on_channel_change = move |ev: leptos::ev::Event| {
if let Some(target) = ev.target() {
if let Ok(select_el) = target.dyn_into::<web_sys::HtmlSelectElement>() {
set_selected_channel.set(select_el.value());
}
}
};
let on_join_click = move |_| {
let guild_id = selected_guild.get();
let channel_id = selected_channel.get();
if !guild_id.is_empty() && !channel_id.is_empty() {
(state.join_voice)(guild_id, channel_id);
}
};
let on_disconnect_click = move |_| {
(state.leave_voice)();
};
// Read signals for reactive rendering
let guilds = state.guilds;
let voice_channels = state.voice_channels;
let loading = state.loading;
let error = state.error;
let voice_status = state.voice_status;
let is_connected = move || {
voice_status.get().map(|s| s.connected).unwrap_or(false)
};
let can_join = move || {
!selected_guild.get().is_empty() && !selected_channel.get().is_empty() && !loading.get()
};
let can_disconnect = move || {
is_connected() && !loading.get()
};
view! {
<div class=format!("rounded-xl border border-border bg-card shadow-sm {}", class)>
<div class="p-6">
<div class="flex items-center gap-2 mb-2">
<svg
class="h-5 w-5 text-primary"
fill="currentColor"
viewBox="0 0 24 24"
>
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm0 18c-4.42 0-8-3.58-8-8s3.58-8 8-8 8 3.58 8 8-3.58 8-8 8zm3.5-9c.83 0 1.5-.67 1.5-1.5S16.33 8 15.5 8 14 8.67 14 9.5s.67 1.5 1.5 1.5zm-7 0c.83 0 1.5-.67 1.5-1.5S9.33 8 8.5 8 7 8.67 7 9.5 7.67 11 8.5 11zm3.5 6.5c2.33 0 4.31-1.46 5.11-3.5H6.89c.8 2.04 2.78 3.5 5.11 3.5z" />
</svg>
<h3 class="text-lg font-semibold tracking-tight">"Voice Bridge"</h3>
</div>
<p class="text-sm text-muted-foreground mb-4">
"Join a Discord voice channel, listen, and transmit audio."
</p>
{/* Guild and Channel Selectors */}
<div class="grid gap-4 md:grid-cols-2 mb-4">
<div class="space-y-2">
<label class="text-sm font-medium text-foreground">"Guild"</label>
<select
prop:value=selected_guild
on:change=on_guild_change
class="w-full rounded-md border border-input bg-background px-3 py-2 text-sm text-foreground placeholder-muted-foreground focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary disabled:cursor-not-allowed disabled:opacity-50"
>
<option value="">"Select guild"</option>
<For each=move || guilds.get() key=|g| g.id.clone() let:guild>
<option value=guild.id.clone()>
{guild.name.clone()}
</option>
</For>
</select>
</div>
<div class="space-y-2">
<label class="text-sm font-medium text-foreground">"Voice Channel"</label>
<select
prop:value=selected_channel
on:change=on_channel_change
class="w-full rounded-md border border-input bg-background px-3 py-2 text-sm text-foreground placeholder-muted-foreground focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary disabled:cursor-not-allowed disabled:opacity-50"
>
<option value="">"Select voice channel"</option>
<For each=move || voice_channels.get() key=|c| c.id.clone() let:channel>
<option value=channel.id.clone()>
{channel.name.clone()}
</option>
</For>
</select>
</div>
</div>
{/* Error Display */}
{move || {
error.get().map(|err| {
view! {
<div class="rounded-md bg-destructive/15 px-3 py-2 text-sm text-destructive mb-4">
{err}
</div>
}
})
}}
{/* Status Display */}
{move || {
voice_status.get().map(|status| {
let connected = status.connected;
let active_channel = status.active_channel_name.clone();
view! {
<div class="flex items-center gap-2 text-sm mb-4">
<div class=move || {
if connected {
"h-2 w-2 rounded-full bg-emerald-500"
} else {
"h-2 w-2 rounded-full bg-muted-foreground/40"
}
}></div>
<span class=move || {
if connected {
"text-emerald-600 dark:text-emerald-400 font-medium"
} else {
"text-muted-foreground"
}
}>
{if connected { "Connected" } else { "Disconnected" }}
</span>
{active_channel.map(|name| {
view! {
<span class="text-muted-foreground">
{format!(" - {}", name)}
</span>
}
})}
</div>
}
})
}}
{/* Control Buttons */}
<div class="flex flex-wrap gap-2">
<button
class=move || {
if can_join() {
"btn btn-primary"
} else {
"btn btn-primary opacity-50 cursor-not-allowed"
}
}
disabled=move || !can_join()
on:click=on_join_click
>
{move || if is_connected() { "Reconnect" } else { "Join Voice" }}
</button>
<button
class=move || {
if can_disconnect() {
"btn btn-destructive"
} else {
"btn btn-destructive opacity-50 cursor-not-allowed"
}
}
disabled=move || !can_disconnect()
on:click=on_disconnect_click
>
"Disconnect"
</button>
{move || {
if loading.get() {
view! {
<span class="inline-flex items-center px-3 py-2 text-sm text-muted-foreground">
"Loading..."
</span>
}.into_any()
} else {
view! { <></> }.into_any()
}
}}
</div>
</div>
</div>
}
}
@@ -0,0 +1,97 @@
use leptos::prelude::*;
use wasm_bindgen::prelude::*;
/// WaveformPlayer — Audio player with waveform progress bar
#[component]
pub fn WaveformPlayer(
audio_url: String,
#[prop(default = "Recording".to_string())] title: String,
) -> impl IntoView {
let is_playing = create_rw_signal::<bool>(false);
let current_time = create_rw_signal::<f64>(0.0);
let duration = create_rw_signal::<f64>(0.0);
let audio_id = format!("audio_{}", &audio_url);
// Clone audio_url for the audio element
let audio_src = audio_url.clone();
let audio_src_for_id = audio_src.clone();
let toggle_play = move |_| {
let doc = web_sys::window().unwrap().document().unwrap();
let audio_opt = doc.get_element_by_id(&format!("audio_{}", &audio_src_for_id));
if let Some(audio_el) = audio_opt {
if let Ok(audio) = audio_el.dyn_into::<web_sys::HtmlAudioElement>() {
if is_playing.get() {
let _ = audio.pause();
is_playing.set(false);
} else {
if audio.ended() {
audio.set_current_time(0.0);
}
if let Ok(_) = audio.play() {
is_playing.set(true);
}
}
}
}
};
let _ = audio_url; // Mark as used for the audio_id
view! {
<div class="waveform-player border border-border/50 rounded-lg p-3 bg-surface/30">
<audio
id=audio_id.clone()
preload="auto"
src=audio_src
class="hidden"
on:timeupdate=move |ev| {
if let Some(target) = ev.target() {
if let Ok(audio) = target.dyn_into::<web_sys::HtmlAudioElement>() {
let ct = audio.current_time();
let dur = audio.duration();
current_time.set(ct);
if dur.is_finite() && dur > 0.0 {
duration.set(dur);
}
if audio.ended() {
is_playing.set(false);
}
}
}
}
></audio>
<div class="h-2 rounded-full bg-surface border border-border/50 overflow-hidden mb-2">
<div
class="h-full rounded-full bg-primary transition-all duration-200"
style=move || format!("width: {}%", progress_pct(current_time.get(), duration.get()))
></div>
</div>
<div class="flex items-center justify-between">
<button
class=move || format!("btn btn-sm {}", if is_playing.get() { "btn-secondary" } else { "btn-primary" })
on:click=toggle_play
>
{move || if is_playing.get() { "" } else { "" }}
</button>
<div class="flex items-center gap-2 text-xs text-muted-foreground font-mono">
<span>{move || format_time(current_time.get())}</span>
<span class="max-w-32 truncate">{title.clone()}</span>
</div>
</div>
</div>
}
}
fn progress_pct(current: f64, dur: f64) -> f64 {
if dur > 0.0 { (current / dur * 100.0).min(100.0) } else { 0.0 }
}
fn format_time(secs: f64) -> String {
if !secs.is_finite() || secs < 0.0 { return "00:00".to_string(); }
let total = secs as u32;
format!("{:02}:{:02}", total / 60, total % 60)
}
@@ -0,0 +1,4 @@
pub mod use_voice_control;
pub mod use_media_control;
pub mod use_audio_playback;
pub mod use_audio_transmit;
@@ -0,0 +1,107 @@
use leptos::prelude::*;
use std::sync::Arc;
use crate::features::live::audio::pcm_decoder::decode_pcm_frame;
use crate::features::live::audio::ring_buffer::SharedRingBuffer;
/// AudioPlaybackState — Manages PCM audio playback from WebSocket binary frames
pub struct AudioPlaybackState {
/// Ring buffer for incoming PCM data
pub buffer: SharedRingBuffer,
/// Whether playback is active
pub active: RwSignal<bool>,
/// Volume level (0.0-1.0)
pub volume: RwSignal<f64>,
}
/// Create and initialize audio playback state
pub fn use_audio_playback() -> AudioPlaybackState {
let buffer = SharedRingBuffer::new(44100 * 5); // 5 seconds at 44.1kHz
let active = create_rw_signal::<bool>(false);
let volume = create_rw_signal::<f64>(0.5);
AudioPlaybackState {
buffer,
active,
volume,
}
}
/// Process incoming binary data from WebSocket (PCM audio frame)
/// Format: [u32 userId (4 bytes)][i16 samples (N bytes)]
pub fn process_pcm_data(state: &AudioPlaybackState, data: Vec<u8>) {
if let Some(frame) = decode_pcm_frame(&data) {
state.buffer.write(&frame.samples);
}
}
/// Start consuming the ring buffer and playing through AudioContext
pub fn start_playback(state: &AudioPlaybackState) {
if state.active.get() {
return;
}
state.active.set(true);
let buffer = state.buffer.clone();
let active = state.active;
wasm_bindgen_futures::spawn_local(async move {
let ctx = match web_sys::AudioContext::new() {
Ok(ctx) => ctx,
Err(_) => {
active.set(false);
return;
}
};
let ctx_ref = &ctx;
let _ = ctx_ref.resume();
while active.get() {
let available = buffer.available_samples();
if available >= 4410 {
// ~100ms worth at 44.1kHz
let samples = buffer.read(4410);
if !samples.is_empty() {
play_samples(&ctx, &samples);
}
}
let _ = gloo_timers::future::TimeoutFuture::new(50).await;
}
let _ = ctx.close();
});
}
/// Stop playback and clear buffer
pub fn stop_playback(state: &AudioPlaybackState) {
state.active.set(false);
state.buffer.clear();
}
/// Play a chunk of PCM samples through AudioContext using AudioBufferSourceNode
fn play_samples(ctx: &web_sys::AudioContext, samples: &[f32]) {
let frame_count = samples.len() as u32;
let Ok(audio_buffer) = ctx.create_buffer(1, frame_count, ctx.sample_rate()) else {
return;
};
// Write samples into the buffer channel
let Ok(channel_data) = audio_buffer.get_channel_data(0) else {
return;
};
let len = samples.len().min(channel_data.len() as usize);
if len == 0 {
return;
}
// Copy samples directly to audio buffer channel
let _ = audio_buffer.copy_to_channel(&samples[..len], 0);
// Create source and play
if let Ok(source) = ctx.create_buffer_source() {
source.set_buffer(Some(&audio_buffer));
source.set_loop(false);
let _ = source.start();
}
}
@@ -0,0 +1,79 @@
use leptos::prelude::*;
use wasm_bindgen::prelude::*;
use wasm_bindgen_futures::spawn_local;
use web_sys::{MediaStream, MediaStreamConstraints, MediaStreamTrack};
/// AudioTransmitState — Manages microphone capture state
pub struct AudioTransmitState {
pub active: RwSignal<bool>,
pub stream: StoredValue<Option<MediaStream>>,
}
/// Create microphone transmit state
pub fn use_audio_transmit() -> AudioTransmitState {
let active = create_rw_signal::<bool>(false);
let stream = StoredValue::new(None::<MediaStream>);
AudioTransmitState { active, stream }
}
/// Start microphone capture - requests getUserMedia and stores the stream
pub fn start_transmit(state: &AudioTransmitState) {
if state.active.get() {
return;
}
state.active.set(true);
let constraints = MediaStreamConstraints::new();
let _ = js_sys::Reflect::set(
&constraints,
&JsValue::from_str("audio"),
&JsValue::from_bool(true),
);
let window = match web_sys::window() {
Some(w) => w,
None => return,
};
let media_devices = match window.navigator().media_devices() {
Ok(md) => md,
Err(_) => return,
};
let promise = match media_devices.get_user_media_with_constraints(&constraints) {
Ok(p) => p,
Err(_) => return,
};
// Clone signals before spawning async task to avoid reference escaping
let active_signal = state.active;
let stream_signal = state.stream;
spawn_local(async move {
match wasm_bindgen_futures::JsFuture::from(promise).await {
Ok(val) => {
if let Ok(s) = val.dyn_into::<MediaStream>() {
stream_signal.set_value(Some(s));
}
}
Err(_) => {
active_signal.set(false);
}
}
});
}
/// Stop microphone transmission
pub fn stop_transmit(state: &AudioTransmitState) {
state.active.set(false);
state.stream.update_value(|s| {
if let Some(stream) = s.take() {
let tracks = stream.get_tracks();
for i in 0..tracks.length() {
let track_val = tracks.get(i);
if let Ok(track) = track_val.dyn_into::<MediaStreamTrack>() {
track.stop();
}
}
}
});
}
@@ -0,0 +1,157 @@
use leptos::prelude::*;
use shared_types::media::MediaState;
use crate::api::voice::{
get_media_status, media_queue, media_skip, media_stop, media_volume,
};
use std::sync::Arc;
use wasm_bindgen_futures::spawn_local;
/// Callback type for enqueue
pub type EnqueueCallback = Arc<dyn Fn(String, String) + Send + Sync>;
/// Callback type for skip_track
pub type SkipTrackCallback = Arc<dyn Fn() + Send + Sync>;
/// Callback type for stop_playback
pub type StopPlaybackCallback = Arc<dyn Fn() + Send + Sync>;
/// Callback type for set_volume
pub type SetVolumeCallback = Arc<dyn Fn(f64) + Send + Sync>;
/// Callback type for refresh
pub type RefreshCallback = Arc<dyn Fn() + Send + Sync>;
/// State returned by use_media_control hook
#[derive(Clone)]
pub struct MediaControlState {
/// Current media playback state
pub media_state: RwSignal<Option<MediaState>>,
/// Whether we're currently loading data
pub loading: RwSignal<bool>,
/// Last error message if any
pub error: RwSignal<Option<String>>,
/// Enqueue media (source URL, mode: "music" or "screen")
pub enqueue: EnqueueCallback,
/// Skip to next track
pub skip_track: SkipTrackCallback,
/// Stop all playback
pub stop_playback: StopPlaybackCallback,
/// Set volume level (0.0 - 1.0)
pub set_volume: SetVolumeCallback,
/// Refresh media status from server
pub refresh: RefreshCallback,
}
/// Hook to manage media playback state and controls
pub fn use_media_control() -> MediaControlState {
// Core signals
let media_state_signal = RwSignal::new(None::<MediaState>);
let loading_signal = RwSignal::new(false);
let error_signal = RwSignal::new(None::<String>);
// Enqueue media
let enqueue_impl = Arc::new(move |source: String, mode: String| {
spawn_local({
let source = source.clone();
let mode = mode.clone();
async move {
error_signal.set(None);
loading_signal.set(true);
match media_queue(&source, &mode).await {
Ok(state) => {
media_state_signal.set(Some(state));
loading_signal.set(false);
}
Err(e) => {
error_signal.set(Some(format!("Failed to enqueue media: {}", e)));
loading_signal.set(false);
}
}
}
});
});
// Skip to next track
let skip_track_impl = Arc::new(move || {
spawn_local(async move {
error_signal.set(None);
loading_signal.set(true);
match media_skip().await {
Ok(state) => {
media_state_signal.set(Some(state));
loading_signal.set(false);
}
Err(e) => {
error_signal.set(Some(format!("Failed to skip track: {}", e)));
loading_signal.set(false);
}
}
});
});
// Stop all playback
let stop_playback_impl = Arc::new(move || {
spawn_local(async move {
error_signal.set(None);
loading_signal.set(true);
match media_stop().await {
Ok(state) => {
media_state_signal.set(Some(state));
loading_signal.set(false);
}
Err(e) => {
error_signal.set(Some(format!("Failed to stop playback: {}", e)));
loading_signal.set(false);
}
}
});
});
// Set volume level
let set_volume_impl = Arc::new(move |volume: f64| {
spawn_local(async move {
error_signal.set(None);
loading_signal.set(true);
match media_volume(volume).await {
Ok(state) => {
media_state_signal.set(Some(state));
loading_signal.set(false);
}
Err(e) => {
error_signal.set(Some(format!("Failed to set volume: {}", e)));
loading_signal.set(false);
}
}
});
});
// Refresh media status
let refresh_impl = Arc::new(move || {
spawn_local(async move {
error_signal.set(None);
loading_signal.set(true);
match get_media_status().await {
Ok(state) => {
media_state_signal.set(Some(state));
loading_signal.set(false);
}
Err(e) => {
error_signal.set(Some(format!("Failed to refresh media status: {}", e)));
loading_signal.set(false);
}
}
});
});
MediaControlState {
media_state: media_state_signal,
loading: loading_signal,
error: error_signal,
enqueue: enqueue_impl,
skip_track: skip_track_impl,
stop_playback: stop_playback_impl,
set_volume: set_volume_impl,
refresh: refresh_impl,
}
}
@@ -0,0 +1,177 @@
use leptos::prelude::*;
use shared_types::guild::{Guild, Channel};
use shared_types::voice::VoiceStatus;
use crate::api::voice::{
get_guilds, get_voice_channels, get_text_channels, get_voice_status,
connect_voice, disconnect_voice,
};
use std::sync::Arc;
use wasm_bindgen_futures::spawn_local;
/// Callback type for join_voice
pub type JoinVoiceCallback = Arc<dyn Fn(String, String) + Send + Sync>;
/// Callback type for leave_voice
pub type LeaveVoiceCallback = Arc<dyn Fn() + Send + Sync>;
/// Callback type for load_guilds
pub type LoadGuildsCallback = Arc<dyn Fn() + Send + Sync>;
/// Callback type for load_voice_channels
pub type LoadVoiceChannelsCallback = Arc<dyn Fn(String) + Send + Sync>;
/// Callback type for load_text_channels
pub type LoadTextChannelsCallback = Arc<dyn Fn(String) + Send + Sync>;
/// State returned by use_voice_control hook
#[derive(Clone)]
pub struct VoiceControlState {
/// List of available guilds
pub guilds: RwSignal<Vec<Guild>>,
/// List of voice channels for current guild
pub voice_channels: RwSignal<Vec<Channel>>,
/// List of text channels for current guild
pub text_channels: RwSignal<Vec<Channel>>,
/// Current voice connection status
pub voice_status: RwSignal<Option<VoiceStatus>>,
/// Whether we're currently loading data
pub loading: RwSignal<bool>,
/// Last error message if any
pub error: RwSignal<Option<String>>,
/// Join a voice channel
pub join_voice: JoinVoiceCallback,
/// Leave the current voice channel
pub leave_voice: LeaveVoiceCallback,
/// Fetch list of guilds
pub load_guilds: LoadGuildsCallback,
/// Fetch voice channels for a guild
pub load_voice_channels: LoadVoiceChannelsCallback,
/// Fetch text channels for a guild
pub load_text_channels: LoadTextChannelsCallback,
}
/// Hook to manage voice connection state and controls
pub fn use_voice_control() -> VoiceControlState {
// Core signals
let guilds_signal = RwSignal::new(Vec::<Guild>::new());
let voice_channels_signal = RwSignal::new(Vec::<Channel>::new());
let text_channels_signal = RwSignal::new(Vec::<Channel>::new());
let voice_status_signal = RwSignal::new(None::<VoiceStatus>);
let loading_signal = RwSignal::new(false);
let error_signal = RwSignal::new(None::<String>);
// Join a voice channel
let join_voice_impl = Arc::new(move |guild_id: String, channel_id: String| {
spawn_local({
let guild_id = guild_id.clone();
let channel_id = channel_id.clone();
async move {
error_signal.set(None);
loading_signal.set(true);
match connect_voice(&guild_id, &channel_id).await {
Ok(status) => {
voice_status_signal.set(Some(status));
loading_signal.set(false);
}
Err(e) => {
error_signal.set(Some(format!("Failed to join voice: {}", e)));
loading_signal.set(false);
}
}
}
});
});
// Leave the current voice channel
let leave_voice_impl = Arc::new(move || {
spawn_local(async move {
error_signal.set(None);
loading_signal.set(true);
match disconnect_voice().await {
Ok(status) => {
voice_status_signal.set(Some(status));
loading_signal.set(false);
}
Err(e) => {
error_signal.set(Some(format!("Failed to leave voice: {}", e)));
loading_signal.set(false);
}
}
});
});
// Fetch list of guilds
let load_guilds_impl = Arc::new(move || {
spawn_local(async move {
error_signal.set(None);
loading_signal.set(true);
match get_guilds().await {
Ok(guilds) => {
guilds_signal.set(guilds);
loading_signal.set(false);
}
Err(e) => {
error_signal.set(Some(format!("Failed to load guilds: {}", e)));
loading_signal.set(false);
}
}
});
});
// Fetch voice channels for a guild
let load_voice_channels_impl = Arc::new(move |guild_id: String| {
spawn_local({
let guild_id = guild_id.clone();
async move {
error_signal.set(None);
loading_signal.set(true);
match get_voice_channels(&guild_id).await {
Ok(channels) => {
voice_channels_signal.set(channels);
loading_signal.set(false);
}
Err(e) => {
error_signal.set(Some(format!("Failed to load voice channels: {}", e)));
loading_signal.set(false);
}
}
}
});
});
// Fetch text channels for a guild
let load_text_channels_impl = Arc::new(move |guild_id: String| {
spawn_local({
let guild_id = guild_id.clone();
async move {
error_signal.set(None);
loading_signal.set(true);
match get_text_channels(&guild_id).await {
Ok(channels) => {
text_channels_signal.set(channels);
loading_signal.set(false);
}
Err(e) => {
error_signal.set(Some(format!("Failed to load text channels: {}", e)));
loading_signal.set(false);
}
}
}
});
});
VoiceControlState {
guilds: guilds_signal,
voice_channels: voice_channels_signal,
text_channels: text_channels_signal,
voice_status: voice_status_signal,
loading: loading_signal,
error: error_signal,
join_voice: join_voice_impl,
leave_voice: leave_voice_impl,
load_guilds: load_guilds_impl,
load_voice_channels: load_voice_channels_impl,
load_text_channels: load_text_channels_impl,
}
}
@@ -0,0 +1,65 @@
pub mod components;
pub mod hooks;
pub mod audio;
use leptos::prelude::*;
use crate::ws::context::WsContext;
use components::{
VoiceConnectionCard, ActiveSpeakers, AudioVisualizer,
NowPlaying, MusicSubPanel, ScreenSubPanel, RecordingsSubPanel,
};
/// LivePanel — Composition shell for all voice and media components
#[component]
pub fn LivePanel() -> impl IntoView {
let ws = use_context::<WsContext>();
view! {
<div class="live-panel space-y-6">
<div class="flex items-center justify-between">
<div>
<h2 class="text-2xl font-bold tracking-tight">"Voice & Media"</h2>
<p class="text-sm text-muted-foreground mt-1">
"Monitor voice channels, play music, share your screen, and browse recordings."
</p>
</div>
</div>
{/* Top row: Voice connection + speakers + visualizer */}
<div class="grid gap-6 lg:grid-cols-3">
<div class="lg:col-span-2">
<VoiceConnectionCard />
</div>
<div>
<ActiveSpeakers />
</div>
</div>
{/* Audio visualization */}
<div class="card">
<div class="card-header">
<div class="card-title">"Audio Visualization"</div>
</div>
<div class="card-content">
<AudioVisualizer />
</div>
</div>
{/* Media controls: Now Playing + Music + Screen */}
<div class="grid gap-6 lg:grid-cols-3">
<div>
<NowPlaying />
</div>
<div>
<MusicSubPanel />
</div>
<div>
<ScreenSubPanel />
</div>
</div>
{/* Recordings */}
<RecordingsSubPanel />
</div>
}
}
@@ -0,0 +1,75 @@
use leptos::prelude::*;
use shared_types::message::MessageRecord;
#[component]
pub fn ImageGrid(
messages: Vec<MessageRecord>,
) -> impl IntoView {
let mut seen_urls = std::collections::HashSet::new();
let mut urls = Vec::new();
for msg in &messages {
if let Some(meta) = &msg.metadata {
// attachments with image MIME
if let Some(atts) = &meta.attachments {
for att in atts {
let is_img = att.content_type.as_deref().map(|ct| ct.starts_with("image/")).unwrap_or(false)
|| att.name.to_lowercase().ends_with(".png")
|| att.name.to_lowercase().ends_with(".jpg")
|| att.name.to_lowercase().ends_with(".jpeg")
|| att.name.to_lowercase().ends_with(".gif")
|| att.name.to_lowercase().ends_with(".webp");
if is_img && seen_urls.insert(att.url.clone()) {
urls.push(att.url.clone());
}
}
}
// stickers
if let Some(stickers) = &meta.stickers {
for s in stickers {
if let Some(ref url) = s.url {
if seen_urls.insert(url.clone()) {
urls.push(url.clone());
}
}
}
}
// embed images
if let Some(embeds) = &meta.embeds {
for e in embeds {
if let Some(ref img) = e.image {
if seen_urls.insert(img.url.clone()) {
urls.push(img.url.clone());
}
}
if let Some(ref thumb) = e.thumbnail {
if seen_urls.insert(thumb.url.clone()) {
urls.push(thumb.url.clone());
}
}
}
}
}
}
if urls.is_empty() {
return view! {
<div class="flex items-center justify-center h-32 text-secondary italic">
"No images found"
</div>
}.into_any();
}
view! {
<div class="image-grid">
{urls.into_iter().map(|url| {
let url_clone = url.clone();
view! {
<a href=url_clone target="_blank" class="image-grid-item">
<img src=url alt="attachment" loading="lazy" />
</a>
}
}).collect::<Vec<_>>()}
</div>
}.into_any()
}
@@ -0,0 +1,416 @@
use leptos::prelude::*;
use regex::Regex;
use shared_types::message::{AiSeverity, AiStatus, AttachmentRef, MessageRecord};
use std::sync::{Arc, OnceLock};
use wasm_bindgen::prelude::*;
// ─── 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() / 1000.0) as i64;
let secs = if now > ts { now - ts } 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) * 1000.0));
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) * 1000.0));
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", view! { }.into_any()),
AiStatus::Processing => ("status-badge-processing", view! { }.into_any()),
AiStatus::Warn => ("status-badge-warn", view! { }.into_any()),
};
view! {
<span class=format!("status-badge {}", cl)>
{icon_svg}
{format!("{:?}", status)}
</span>
}
}
// ─── MessageRow ───────────────────────────────────────────
#[component]
pub fn MessageRow(
message: MessageRecord,
on_reanalyze: Arc<dyn Fn(String) + Send + Sync + 'static>,
) -> impl IntoView {
let cats = get_cats(&message.ai_categories);
let conf = message.ai_confidence.or(message.ai_moderation_score);
let display = message.edited_content.as_deref().unwrap_or(&message.content);
let show = !display.is_empty() && !is_fallback(display);
let ai_st = message.ai_status.clone().unwrap_or(AiStatus::Pending);
let analysis_summary = {
let mut p = cats.iter().take(3).cloned().collect::<Vec<_>>().join(", ");
if cats.len() > 3 {
p = format!("{} +{} more", p, cats.len() - 3);
}
if !p.is_empty() { p.push_str(" · "); }
p.push_str(&format!("{}% conf", conf.map(|c| (c * 100.0) as u8).unwrap_or(0)));
p
};
// Attachments
let all_atts = message.metadata.as_ref()
.and_then(|m| m.attachments.as_ref()).cloned().unwrap_or_default();
let imgs: Vec<AttachmentRef> = all_atts.iter().filter(|a| {
a.content_type.as_deref().map(|ct| ct.starts_with("image/")).unwrap_or(false)
|| a.name.to_lowercase().ends_with(".png")
|| a.name.to_lowercase().ends_with(".jpg")
|| a.name.to_lowercase().ends_with(".jpeg")
|| a.name.to_lowercase().ends_with(".gif")
|| a.name.to_lowercase().ends_with(".webp")
}).cloned().collect();
let vids: Vec<AttachmentRef> = all_atts.iter().filter(|a| {
a.content_type.as_deref().map(|ct| ct.starts_with("video/")).unwrap_or(false)
|| a.name.to_lowercase().ends_with(".mp4")
|| a.name.to_lowercase().ends_with(".webm")
|| a.name.to_lowercase().ends_with(".mov")
}).cloned().collect();
let stickers = message.metadata.as_ref()
.and_then(|m| m.stickers.as_ref()).cloned().unwrap_or_default();
let reanalyze_id = message.id.clone();
let on_click_re = move |_| on_reanalyze(reanalyze_id.clone());
view! {
<div class="message-row">
{/* Header */}
<div class="flex flex-wrap items-center gap-x-2 gap-y-1">
<span class="message-timestamp" title=time_ago(message.created_at)>
{fmt_time(message.created_at)}
</span>
{message.edited_at.is_some().then(|| view! {
<span class="flex items-center gap-0.5 text-xs text-secondary">
"✎ edited"
</span>
})}
{message.deleted_at.is_some().then(|| view! {
<span class="flex items-center gap-0.5 text-xs text-destructive">
"🗑 deleted"
</span>
})}
<div class="ml-auto flex items-center gap-1">
<StatusBadgeInline status=ai_st.clone() />
{message.ai_severity.as_ref().filter(|s| **s != AiSeverity::None).map(|sev| view! {
<span class=format!("badge text-xs {}", severity_class(sev))>{format!("{:?}", sev)}</span>
})}
</div>
</div>
{/* Reply - field removed from MessageRecord */}
{/* Forward - field removed from MessageRecord */}
{/* Crosspost - field removed from MessageRecord */}
{/* Content */}
{show.then(|| {
let rendered = render_emojis(display);
let cls_str = if message.deleted_at.is_some() { "text-secondary/60" } else { "" };
let class_str = format!("whitespace-pre-wrap break-words text-sm leading-6 {}", cls_str);
view! {
<p class=class_str>
{rendered.into_iter().collect::<Vec<_>>()}
</p>
}
})}
{/* Stickers */}
{(!stickers.is_empty()).then(|| view! {
<div class="flex flex-wrap gap-2">
{stickers.iter().map(|s| {
let url_owned = s.url.clone().unwrap_or_default();
let name_owned = s.name.clone().unwrap_or_default();
let has_url = !url_owned.is_empty();
view! {
<div>
{if has_url {
view! {
<img src=url_owned alt=name_owned class="h-12 w-12 rounded-lg border border-border object-contain bg-surface/50" loading="lazy" />
}.into_any()
} else {
view! {
<div class="flex h-12 w-12 items-center justify-center rounded-lg border border-border bg-surface/50">
"😊"
</div>
}.into_any()
}}
</div>
}
}).collect::<Vec<_>>()}
</div>
})}
{/* Images */}
{if !imgs.is_empty() {
let imgs_local = imgs.clone();
let images_view = imgs_local.iter().take(4).map(|a| {
let url1 = a.url.clone();
let url2 = a.url.clone();
let name1 = a.name.clone();
view! {
<a href=url1 target="_blank" class="shrink-0 overflow-hidden rounded-lg border border-border">
<img src=url2 alt=name1 class="h-16 w-16 object-cover hover:scale-105 transition-transform" loading="lazy" />
</a>
}
}).collect::<Vec<_>>();
let overflow = if imgs.len() > 4 {
let extra = imgs.len() - 4;
view! {
<div class="flex h-16 w-16 items-center justify-center rounded-lg border border-border bg-surface text-xs text-secondary">
<span>{"+"} {extra}</span> <span class="ml-0.5">"🖼"</span>
</div>
}.into_any()
} else {
view! {}.into_any()
};
view! {
<div class="flex gap-2 overflow-x-auto">
{images_view}
{overflow}
</div>
}.into_any()
} else {
view! {}.into_any()
}}
{/* Videos */}
{if !vids.is_empty() {
let vids_local = vids.clone();
let videos_view = vids_local.iter().take(4).map(|a| {
let url = a.url.clone();
view! {
<video src=url controls class="h-28 w-48 shrink-0 rounded-lg border border-border object-cover bg-black" preload="metadata"></video>
}
}).collect::<Vec<_>>();
let overflow = if vids.len() > 4 {
let extra = vids.len() - 4;
view! {
<div class="flex h-28 w-16 items-center justify-center rounded-lg border border-border bg-surface text-xs text-secondary">
<span>{"+"} {extra}</span> <span class="ml-0.5">""</span>
</div>
}.into_any()
} else {
view! {}.into_any()
};
view! {
<div class="flex gap-2 overflow-x-auto">
{videos_view}
{overflow}
</div>
}.into_any()
} else {
view! {}.into_any()
}}
{/* Categories */}
{if !cats.is_empty() {
let cats_local = cats.clone();
view! {
<div class="flex flex-wrap gap-1">
{cats_local.iter().map(|c| view! {
<span class="badge badge-secondary text-xs">{c.clone()}</span>
}).collect::<Vec<_>>()}
</div>
}.into_any()
} else {
view! {}.into_any()
}}
{/* AI Analysis */}
{message.ai_analysis.as_ref().map(|analysis| {
let border_str = if ai_st == AiStatus::Flagged { "border-l-3 bg-warning/5" } else { "border-l-3 bg-success/5" };
let icon = if ai_st == AiStatus::Flagged { "🚨" } else { "" };
let analysis_summary_str = analysis_summary.clone();
let analysis_str = analysis.clone();
view! {
<div class=format!("rounded-lg px-3 py-2 {}", border_str)>
<div class="flex items-start gap-2 text-xs">
<span class="mt-0.5 shrink-0">{icon}</span>
<div class="min-w-0 flex-1">
<span class="block font-medium mb-1">{analysis_summary_str}</span>
<div class="text-xs leading-relaxed whitespace-pre-wrap">{analysis_str}</div>
</div>
</div>
</div>
}
})}
{/* Error */}
{message.ai_error.as_ref().map(|e| {
let error_str = e.clone();
view! {
<div class="rounded-lg bg-warning/5 px-3 py-2 text-xs text-warning">
<span>"AI error: "{error_str}</span>
</div>
}
})}
{/* Re-analyze */}
<div class="flex items-center gap-2">
<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="text-xs text-secondary/70">"Click to retry"</span>
})}
</div>
</div>
}
}
// ─── MessageCard ──────────────────────────────────────────
#[component]
pub fn MessageCard(
messages: Vec<MessageRecord>,
on_reanalyze: Arc<dyn Fn(String) + Send + Sync + 'static>,
) -> impl IntoView {
let first = &messages[0];
let has_multi = messages.len() > 1;
let deleted = first.deleted_at.is_some();
let avatar = first.avatar_url.clone()
.unwrap_or_else(|| "https://cdn.discordapp.com/embed/avatars/0.png".into());
let loc_label = first.metadata.as_ref().and_then(|m| m.channel.as_ref()).map(|c| {
if let Some(ref tn) = c.thread_name {
format!("# {} {}", c.channel_name.as_deref().unwrap_or("?"), tn)
} else {
format!("# {}", c.channel_name.as_deref().unwrap_or("?"))
}
});
let card_cls = if deleted { "border-destructive/20 opacity-60" } else { "" };
view! {
<article class=format!("message-card shadow-sm transition-all {}", card_cls)>
<div class="flex gap-3 p-4">
<img src=avatar alt="" class="h-10 w-10 shrink-0 rounded-full object-cover ring-2 ring-primary/30" />
<div class="min-w-0 flex-1">
<div class="flex items-baseline gap-2 mb-2">
<span class="font-semibold text-sm">{first.username.clone()}</span>
{loc_label.as_ref().map(|l| {
let label_str = l.clone();
view! {
<span class="flex items-center gap-1 text-xs text-secondary/50 bg-surface/50 px-1.5 py-0.5 rounded-full">
"#" " " {label_str}
</span>
}
})}
<span class="text-xs text-secondary/60">
{time_ago(first.created_at)}
{has_multi.then(|| format!(" · {} msgs", messages.len()))}
</span>
</div>
<div class=if has_multi { "space-y-2.5" } else { "" }>
{messages.into_iter().enumerate().map(|(i, msg)| {
let sep = has_multi && i > 0;
view! {
<div class=if sep { "pt-2.5 border-t border-border/30" } else { "" }>
<MessageRow message=msg on_reanalyze=on_reanalyze.clone() />
</div>
}
}).collect::<Vec<_>>()}
</div>
</div>
</div>
</article>
}
}
// ─── Skeleton ─────────────────────────────────────────────
#[component]
pub fn MessageCardSkeleton() -> impl IntoView {
view! {
<article class="message-card">
<div class="flex gap-3 p-4">
<div class="skeleton skeleton-circular" style="width:40px;height:40px"></div>
<div class="min-w-0 flex-1 space-y-3">
<div class="skeleton" style="height:20px;width:192px"></div>
<div class="skeleton" style="height:16px;width:100%"></div>
<div class="skeleton" style="height:16px;width:75%"></div>
<div class="flex gap-2">
<div class="skeleton" style="height:24px;width:64px;border-radius:9999px"></div>
<div class="skeleton" style="height:24px;width:80px;border-radius:9999px"></div>
</div>
</div>
</div>
</article>
}
}
@@ -0,0 +1,128 @@
use leptos::prelude::*;
use shared_types::message::MessageRecord;
use std::sync::Arc;
use wasm_bindgen::prelude::*;
use web_sys::IntersectionObserver;
use leptos::html;
const GROUP_WINDOW_MS: i64 = 5 * 60 * 1000;
fn group_messages(messages: Vec<MessageRecord>) -> Vec<Vec<MessageRecord>> {
let mut groups: Vec<Vec<MessageRecord>> = Vec::new();
for msg in messages {
if let Some(last_group) = groups.last_mut() {
let same_user = last_group.first()
.map(|m| m.user_id == msg.user_id)
.unwrap_or(false);
let same_window = last_group.last()
.map(|m| (m.created_at - msg.created_at).abs() < GROUP_WINDOW_MS)
.unwrap_or(false);
if same_user && same_window {
last_group.push(msg);
continue;
}
}
groups.push(vec![msg]);
}
groups
}
#[component]
pub fn MessageFeed(
messages: Vec<MessageRecord>,
#[prop(optional)] empty_text: &'static str,
#[prop(optional)] loading: bool,
#[prop(optional)] has_more: bool,
#[prop(optional)] loading_more: bool,
#[prop(optional)] on_load_more: Option<Arc<dyn Fn() + Send + Sync + 'static>>,
on_reanalyze: Arc<dyn Fn(String) + Send + Sync + 'static>,
) -> impl IntoView {
let sentinel_ref = create_node_ref::<html::Div>();
let (intersecting, set_intersecting) = create_signal(false);
create_effect(move |_| {
let _ = intersecting.get(); // track signal
if let Some(node) = sentinel_ref.get() {
let on_load_more = on_load_more.clone();
let cb = Closure::<dyn Fn(Vec<JsValue>)>::new(move |entries: Vec<JsValue>| {
for entry in entries {
if let Some(entry) = entry.dyn_ref::<web_sys::IntersectionObserverEntry>() {
if entry.is_intersecting() {
if let Some(ref cb) = on_load_more {
cb();
}
}
}
}
});
let observer = IntersectionObserver::new(cb.as_ref().unchecked_ref())
.expect("IntersectionObserver failed");
observer.observe(&node);
on_cleanup(move || {
observer.disconnect();
});
// Keep closure alive
cb.forget();
}
});
// Loading state
if loading {
return view! {
<div class="space-y-4">
{std::iter::repeat_with(|| {
use super::message_card::MessageCardSkeleton;
view! { <MessageCardSkeleton /> }
}).take(3).collect::<Vec<_>>()}
</div>
}.into_any();
}
if messages.is_empty() {
return view! {
<div class="empty-state">
<div class="empty-state-title">
{if empty_text.is_empty() { "No messages" } else { empty_text }}
</div>
</div>
}.into_any();
}
let groups = group_messages(messages);
let has_more_val = has_more;
let loading_more_val = loading_more;
view! {
<div class="space-y-4">
{groups.into_iter().map(|group| {
let cb = on_reanalyze.clone();
view! {
<MessageCardGroup messages=group on_reanalyze=cb.clone() />
}
}).collect::<Vec<_>>()}
{/* Infinite scroll sentinel */}
{has_more_val.then(|| {
view! {
<div node_ref=sentinel_ref class="h-4">
{loading_more_val.then(|| {
use super::message_card::MessageCardSkeleton;
view! { <MessageCardSkeleton /> }
})}
</div>
}
})}
</div>
}.into_any()
}
#[component]
fn MessageCardGroup(
messages: Vec<MessageRecord>,
on_reanalyze: Arc<dyn Fn(String) + Send + Sync + 'static>,
) -> impl IntoView {
use super::message_card::MessageCard;
view! {
<MessageCard messages=messages on_reanalyze=on_reanalyze />
}
}
@@ -0,0 +1,3 @@
pub mod message_feed;
pub mod message_card;
pub mod image_grid;
@@ -0,0 +1 @@
pub mod use_messages;
@@ -0,0 +1,200 @@
use leptos::prelude::*;
use shared_types::message::{MessageRecord, PageResult};
use crate::api::messages::{get_messages, reanalyze_message, reanalyze_batch};
use std::collections::HashMap;
use std::sync::Arc;
use wasm_bindgen_futures::spawn_local;
/// Merges current messages with incoming messages, deduplicating by ID and sorting
pub fn merge_messages(current: &[MessageRecord], incoming: &[MessageRecord]) -> Vec<MessageRecord> {
let mut by_id: HashMap<String, MessageRecord> = current.iter().map(|m| (m.id.clone(), m.clone())).collect();
for msg in incoming {
by_id.insert(msg.id.clone(), msg.clone());
}
let mut merged: Vec<MessageRecord> = by_id.into_values().collect();
merged.sort_by(|a, b| b.created_at.cmp(&a.created_at).then_with(|| b.id.cmp(&a.id)));
merged
}
/// Callback type for fetch_messages
pub type FetchMessagesCallback = Arc<dyn Fn(String) + Send + Sync>;
/// Callback type for load_more
pub type LoadMoreCallback = Arc<dyn Fn() + Send + Sync>;
/// Callback type for reanalyze
pub type ReanalyzeCallback = Arc<dyn Fn(String) + Send + Sync>;
/// Callback type for reanalyze_all_errors
pub type ReanalyzeAllErrorsCallback = Arc<dyn Fn() + Send + Sync>;
/// State returned by use_messages hook
#[derive(Clone)]
pub struct MessagesState {
/// Current list of messages
pub messages: RwSignal<Vec<MessageRecord>>,
/// Whether the initial fetch is in progress
pub loading: ReadSignal<bool>,
/// Whether we're loading more messages
pub loading_more: RwSignal<bool>,
/// Pagination cursor for next page
pub cursor: RwSignal<Option<String>>,
/// Derived: whether there are more messages to load
pub has_more: Memo<bool>,
/// Last error message if any
pub error: RwSignal<Option<String>>,
/// Current guild ID
pub current_guild: RwSignal<Option<String>>,
/// Fetch initial messages for a guild
pub fetch_messages: FetchMessagesCallback,
/// Load next page of messages
pub load_more: LoadMoreCallback,
/// Reanalyze a single message
pub reanalyze: ReanalyzeCallback,
/// Reanalyze all error messages in current batch
pub reanalyze_all_errors: ReanalyzeAllErrorsCallback,
}
/// Hook to manage message data fetching and state
pub fn use_messages() -> MessagesState {
// Core signals
let messages_signal = RwSignal::new(Vec::<MessageRecord>::new());
let (loading, set_loading) = create_signal(false);
let loading_more_signal = RwSignal::new(false);
let cursor_signal = RwSignal::new(None::<String>);
let error_signal = RwSignal::new(None::<String>);
let current_guild_signal = RwSignal::new(None::<String>);
// Derived signal: has_more is true if cursor is Some
let has_more_signal = create_memo(move |_| cursor_signal.get().is_some());
// Fetch initial messages for a guild
let fetch_messages_impl = Arc::new(move |guild_id: String| {
spawn_local({
let guild_id = guild_id.clone();
async move {
error_signal.set(None);
set_loading.set(true);
match get_messages(&guild_id, Some(30), None, None).await {
Ok(PageResult { data, next_cursor }) => {
messages_signal.set(data);
cursor_signal.set(next_cursor);
current_guild_signal.set(Some(guild_id));
set_loading.set(false);
}
Err(e) => {
error_signal.set(Some(format!("Failed to fetch messages: {}", e)));
set_loading.set(false);
}
}
}
});
});
// Load more messages (append next page)
let load_more_impl = Arc::new(move || {
spawn_local(async move {
let guild_id = match current_guild_signal.get() {
Some(id) => id,
None => {
error_signal.set(Some("No guild selected".to_string()));
return;
}
};
let cursor = match cursor_signal.get() {
Some(c) => c,
None => {
error_signal.set(Some("No more messages to load".to_string()));
return;
}
};
loading_more_signal.set(true);
error_signal.set(None);
match get_messages(&guild_id, Some(30), None, Some(&cursor)).await {
Ok(PageResult { data, next_cursor }) => {
let current = messages_signal.get();
messages_signal.set(merge_messages(&current, &data));
cursor_signal.set(next_cursor);
loading_more_signal.set(false);
}
Err(e) => {
error_signal.set(Some(format!("Failed to load more: {}", e)));
loading_more_signal.set(false);
}
}
});
});
// Reanalyze single message with optimistic update
let reanalyze_impl = Arc::new(move |message_id: String| {
spawn_local({
let message_id = message_id.clone();
async move {
// Optimistic: flip status to Processing
let mut msgs = messages_signal.get();
if let Some(pos) = msgs.iter().position(|m| m.id == message_id) {
if let Some(ref mut msg) = msgs.get_mut(pos) {
msg.ai_status = Some(shared_types::message::AiStatus::Processing);
}
}
messages_signal.set(msgs);
// Call API
match reanalyze_message(&message_id).await {
Ok(_) => {
// Success: keep the Processing status (will be updated via WS)
}
Err(e) => {
// Revert to Error status on failure
let mut msgs = messages_signal.get();
if let Some(pos) = msgs.iter().position(|m| m.id == message_id) {
if let Some(ref mut msg) = msgs.get_mut(pos) {
msg.ai_status = Some(shared_types::message::AiStatus::Error);
msg.ai_error = Some(e.to_string());
}
}
messages_signal.set(msgs);
error_signal.set(Some(format!("Reanalyze failed: {}", e)));
}
}
}
});
});
// Reanalyze all error messages
let reanalyze_all_errors_impl = Arc::new(move || {
spawn_local(async move {
match reanalyze_batch().await {
Ok(_count) => {
error_signal.set(None);
// Optimistically mark all error messages as Processing
let mut msgs = messages_signal.get();
for msg in msgs.iter_mut() {
if msg.ai_status == Some(shared_types::message::AiStatus::Error) {
msg.ai_status = Some(shared_types::message::AiStatus::Processing);
}
}
messages_signal.set(msgs);
}
Err(e) => {
error_signal.set(Some(format!("Batch reanalyze failed: {}", e)));
}
}
});
});
MessagesState {
messages: messages_signal,
loading,
loading_more: loading_more_signal,
cursor: cursor_signal,
has_more: has_more_signal,
error: error_signal,
current_guild: current_guild_signal,
fetch_messages: fetch_messages_impl,
load_more: load_more_impl,
reanalyze: reanalyze_impl,
reanalyze_all_errors: reanalyze_all_errors_impl,
}
}
@@ -0,0 +1,310 @@
use leptos::prelude::*;
use shared_types::message::{AiStatus, MessageRecord};
use std::sync::Arc;
use wasm_bindgen_futures::spawn_local;
pub mod components;
pub mod hooks;
use components::message_feed::MessageFeed;
use components::image_grid::ImageGrid;
use hooks::use_messages::{merge_messages, use_messages};
type AiFilter = &'static str;
const FILTERS: &[AiFilter] = &["all", "analyzed", "clean", "flagged", "error", "pending"];
#[derive(Clone, PartialEq)]
enum ViewTab { All, Images }
#[component]
pub fn MessagesPanel() -> impl IntoView {
let state = use_messages();
let (search_query, set_search_query) = create_signal(String::new());
let (search_results, set_search_results) = create_signal::<Vec<MessageRecord>>(Vec::new());
let (show_search, set_show_search) = create_signal(false);
let (is_searching, set_is_searching) = create_signal(false);
let ai_filter = RwSignal::new("analyzed".to_string());
let view_tab = RwSignal::new(ViewTab::All);
let (retrying_all, set_retrying_all) = create_signal(false);
// Stats derived from filtered messages
let stats = create_memo(move |_| {
let base = if show_search.get() { search_results.get() } else { state.messages.get() };
let total = base.len();
let clean = base.iter().filter(|m| m.ai_status == Some(AiStatus::Clean)).count();
let flagged = base.iter().filter(|m| m.ai_status == Some(AiStatus::Flagged)).count();
let error = base.iter().filter(|m| m.ai_status == Some(AiStatus::Error)).count();
let pending = base.iter().filter(|m| m.ai_status.is_none() || m.ai_status == Some(AiStatus::Pending)).count();
let deleted = base.iter().filter(|m| m.deleted_at.is_some()).count();
let edited = base.iter().filter(|m| m.edited_at.is_some()).count();
(total, clean, flagged, error, pending, deleted, edited)
});
// Filter messages based on active filter
let filtered_messages = create_memo(move |_| {
let base = if show_search.get() { search_results.get() } else { state.messages.get() };
let filter = ai_filter.get();
if filter == "all" { return base; }
base.into_iter().filter(|m| {
let status = m.ai_status.clone().unwrap_or(AiStatus::Pending);
if filter == "analyzed" { return status != AiStatus::Pending; }
if filter == "pending" { return status == AiStatus::Pending; }
format!("{:?}", status).to_lowercase() == filter
}).collect()
});
// 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();
spawn_local(async move {
match crate::api::messages::search_messages(&q_clone, Some(50)).await {
Ok(results) => {
set_search_results.set(results);
set_show_search.set(true);
}
Err(_) => {
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());
};
// Reanalyze all errors
let handle_retry_all = move |_| {
set_retrying_all.set(true);
let cb = state.reanalyze_all_errors.clone();
spawn_local(async move {
cb();
set_retrying_all.set(false);
});
};
// Filter chip click
let set_filter = {
let af = ai_filter;
move |f: &'static str| af.set(f.to_string())
};
// WS event handlers (wire once on mount)
let ws = use_context::<crate::ws::context::WsContext>();
if let Some(ref ws) = ws {
// Subscribe to real-time message events
{
let msgs = state.messages;
*ws.on_message_created.borrow_mut() = Some(Box::new(move |msg| {
let current = msgs.get();
msgs.set(merge_messages(&current, &[msg]));
}));
}
{
let msgs = state.messages;
*ws.on_message_updated.borrow_mut() = Some(Box::new(move |msg| {
let current = msgs.get();
msgs.set(merge_messages(&current, &[msg]));
}));
}
{
let msgs = state.messages;
*ws.on_message_deleted.borrow_mut() = Some(Box::new(move |id| {
let current = msgs.get();
msgs.set(current.into_iter().filter(|m| m.id != id).collect());
}));
}
{
let msgs = state.messages;
*ws.on_message_analyzed.borrow_mut() = Some(Box::new(move |msg| {
let current = msgs.get();
msgs.set(merge_messages(&current, &[msg]));
}));
}
}
// Fetch messages on mount if guild is configured
create_effect(move |_| {
if let Some(config) = use_context::<crate::app::AppConfig>() {
if let Some(ref guild_id) = config.monitor_guild_id {
(state.fetch_messages)(guild_id.clone());
}
}
});
// ─── View ────────────────────────────────────────────────
let get_stats = move || stats.get();
let (total, clean, flagged, error, pending, deleted, edited) = (
move || get_stats().0,
move || get_stats().1,
move || get_stats().2,
move || get_stats().3,
move || get_stats().4,
move || get_stats().5,
move || get_stats().6,
);
view! {
<div class="messages-panel">
{/* Header card */}
<div class="card">
<div class="card-header">
<div class="card-title">"Messages"</div>
<p class="card-description">
"Messages are automatically captured from all text channels. Real-time updates arrive via WebSocket."
</p>
</div>
</div>
{/* Stats badges */}
{(total() > 0).then(|| view! {
<div class="message-stats">
<span class="badge badge-outline text-xs">{total()} " total" {state.has_more.get().then(|| "+")}</span>
<span class="badge badge-success text-xs">{clean()} " clean"</span>
<span class="badge badge-primary text-xs">{flagged()} " flagged"</span>
<span class="badge badge-warning text-xs">{error()} " error"</span>
<span class="badge badge-outline text-xs">{pending()} " pending"</span>
{(deleted() > 0).then(|| view! {
<span class="badge badge-destructive text-xs">{deleted()} " deleted"</span>
})}
{(edited() > 0).then(|| view! {
<span class="badge badge-outline text-xs">{edited()} " edited"</span>
})}
</div>
})}
{/* Search + filters row */}
<div class="search-row">
<div class="relative flex-1" style="min-width:200px">
{/* Search icon as SVG */}
<svg class="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-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"><circle cx="11" cy="11" r="8"></circle><path d="m21 21-4.35-4.35"></path></svg>
<input
class="input"
style="padding-left:2.25rem;border-radius:9999px"
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>
{show_search.get().then(|| view! {
<button class="btn btn-outline btn-sm" on:click=clear_search>
"✕ Clear"
</button>
})}
{(error() > 0 && !show_search.get()).then(|| view! {
<button
class="btn btn-destructive btn-sm"
on:click=handle_retry_all
disabled=move || retrying_all.get()
>
{/* Rotate CCW icon as SVN */}
<svg class=format!("mr-1.5 h-3.5 w-3.5{}", if retrying_all.get() { " animate-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 ({})", error()) }}
</button>
})}
<div class="ml-auto flex items-center gap-1.5">
{/* Filter icon as SVG since lucide-leptos Filter unavailable */}
<svg class="h-4 w-4 text-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 active = ai_filter.get() == *f;
let cls = if active {
"filter-chip active"
} else {
"filter-chip"
};
let f_ptr: &'static str = f;
view! {
<button class=cls on:click=move |_| set_filter(f_ptr) >{*f}</button>
}
}).collect::<Vec<_>>()}
</div>
</div>
{/* Search results count */}
{show_search.get().then(|| {
let n = search_results.get().len();
view! {
<div class="text-sm text-secondary">
"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" }>
{
let load_more_cb = state.load_more.clone();
let empty_text: &'static str = if show_search.get() { "No messages found matching your search." } else { "No captures yet." };
let has_more = if show_search.get() { false } else { state.has_more.get() };
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=state.loading.get()
has_more=has_more
loading_more=state.loading_more.get()
on_load_more=on_load_more_clone
on_reanalyze=state.reanalyze.clone()
/>
}
}
</div>
<div class="tab-content" style:display=move || if view_tab.get() == ViewTab::Images { "block" } else { "none" }>
<ImageGrid messages=filtered_messages.get() />
</div>
</div>
</div>
}
}
@@ -0,0 +1,4 @@
pub mod dashboard;
pub mod live;
pub mod messages;
pub mod polish;
@@ -0,0 +1,152 @@
use leptos::prelude::*;
use wasm_bindgen_futures::spawn_local;
#[derive(Clone, PartialEq)]
enum ChatRole {
User,
Mascot,
}
#[derive(Clone)]
struct ChatMessage {
id: String,
role: ChatRole,
content: String,
}
#[component]
pub fn MascotChatbot() -> impl IntoView {
let open = RwSignal::new(false);
let minimized = RwSignal::new(false);
let loading = RwSignal::new(false);
let input = RwSignal::new(String::new());
let messages = RwSignal::new(vec![ChatMessage {
id: "init-1".to_string(),
role: ChatRole::Mascot,
content: "Halo! 👋 Aku mascot IMPHNEN. Tanya aku soal analytics, pesan, atau moderation queue.".to_string(),
}]);
let send_message = move || {
let text = input.get().trim().to_string();
if text.is_empty() || loading.get() {
return;
}
let now = js_sys::Date::now() as u64;
messages.update(|list| list.push(ChatMessage {
id: format!("user-{}", now),
role: ChatRole::User,
content: text.clone(),
}));
input.set(String::new());
loading.set(true);
spawn_local(async move {
let response = match crate::api::mascot::send_mascot_message(&text).await {
Ok(resp) => resp.response,
Err(_) => fallback_response(&text),
};
messages.update(|list| list.push(ChatMessage {
id: format!("mascot-{}", js_sys::Date::now() as u64),
role: ChatRole::Mascot,
content: response,
}));
loading.set(false);
});
};
view! {
<div class="mascot-widget">
{move || if open.get() {
view! {
<div class=move || if minimized.get() { "mascot-panel minimized" } else { "mascot-panel" }>
<div class="mascot-header">
<div class="flex items-center gap-2">
<div class="mascot-header-icon">"💬"</div>
<div>
<div class="mascot-title">"Mascot IMPHNEN"</div>
<div class="mascot-subtitle">{move || if loading.get() { "Mengetik..." } else { "Online" }}</div>
</div>
</div>
<div class="flex items-center gap-1">
<button class="mascot-icon-button" on:click=move |_| minimized.update(|v| *v = !*v)>
{move || if minimized.get() { "" } else { "" }}
</button>
<button class="mascot-icon-button" on:click=move |_| open.set(false)>"×"</button>
</div>
</div>
{move || (!minimized.get()).then(|| view! {
<>
<div class="mascot-messages">
{messages.get().into_iter().map(|msg| {
let is_user = msg.role == ChatRole::User;
view! {
<div class=if is_user { "mascot-message-row user" } else { "mascot-message-row mascot" }>
{(!is_user).then(|| view! { <div class="mascot-avatar">"🤖"</div> })}
<div class=if is_user { "mascot-bubble user" } else { "mascot-bubble mascot" }>
{msg.content}
</div>
</div>
}
}).collect::<Vec<_>>()}
{loading.get().then(|| view! {
<div class="mascot-message-row mascot">
<div class="mascot-avatar">"🤖"</div>
<div class="mascot-bubble mascot typing">
<span></span><span></span><span></span>
</div>
</div>
})}
</div>
<form class="mascot-form" on:submit=move |ev| {
ev.prevent_default();
send_message();
}>
<input
class="mascot-input"
placeholder="Tanya mascot..."
prop:value=input
on:input=move |ev| input.set(event_target_value(&ev))
disabled=move || loading.get()
/>
<button
class="mascot-send"
type="submit"
disabled=move || loading.get() || input.get().trim().is_empty()
>
""
</button>
</form>
</>
})}
</div>
}.into_any()
} else {
view! {
<button class="mascot-launcher" on:click=move |_| open.set(true) title="Open mascot chat">
<span>"🤖"</span>
</button>
}.into_any()
}}
</div>
}
}
fn fallback_response(input: &str) -> String {
let lower = input.to_lowercase();
if lower.contains("halo") || lower.contains("hai") {
"Halo juga! 👋 Aku siap bantu baca kondisi server.".to_string()
} else if lower.contains("pesan") || lower.contains("message") {
"Cek tab Messages untuk live capture dan hasil AI moderation terbaru.".to_string()
} else if lower.contains("voice") || lower.contains("audio") {
"Tab Voice & Media punya voice bridge, speakers, media controls, dan recordings.".to_string()
} else if lower.contains("dashboard") || lower.contains("stat") {
"Dashboard Guild merangkum total pesan, user aktif, channel teratas, dan moderation queue.".to_string()
} else {
format!("Menarik: \"{}\". Kalau backend mascot offline, aku tetap bisa bantu arahkan ke Messages, Voice, atau Dashboard. 😊", input)
}
}
@@ -0,0 +1,7 @@
pub mod mascot_chatbot;
pub mod particle_background;
pub mod theme_toggle;
pub use mascot_chatbot::MascotChatbot;
pub use particle_background::ParticleBackground;
pub use theme_toggle::ThemeToggle;
@@ -0,0 +1,12 @@
use leptos::prelude::*;
#[component]
pub fn ParticleBackground() -> impl IntoView {
view! {
<div class="particle-bg" aria-hidden="true">
<div class="particle-orb"></div>
<div class="particle-orb"></div>
<div class="particle-orb"></div>
</div>
}
}
@@ -0,0 +1,35 @@
use leptos::prelude::*;
use crate::features::polish::{persist_theme, ThemeContext};
#[component]
pub fn ThemeToggle() -> impl IntoView {
let theme_ctx = use_context::<ThemeContext>();
let theme_for_label = theme_ctx.clone();
let theme_for_toggle = theme_ctx.clone();
let is_dark = move || {
theme_for_label
.as_ref()
.map(|ctx| ctx.theme.get() == "dark")
.unwrap_or(false)
};
let toggle = move |_| {
if let Some(ctx) = theme_for_toggle.as_ref() {
let next = if ctx.theme.get() == "dark" { "light" } else { "dark" };
ctx.theme.set(next.to_string());
persist_theme(next);
}
};
view! {
<button
class="theme-toggle"
on:click=toggle
aria-label="Toggle theme"
title="Toggle theme"
>
{move || if is_dark() { "" } else { "" }}
</button>
}
}
@@ -0,0 +1,22 @@
pub mod components;
use leptos::prelude::*;
#[derive(Clone)]
pub struct ThemeContext {
pub theme: RwSignal<String>,
}
pub fn initial_theme() -> String {
web_sys::window()
.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())
}
pub fn persist_theme(theme: &str) {
if let Some(storage) = web_sys::window().and_then(|window| window.local_storage().ok().flatten()) {
let _ = storage.set_item("imphnen-theme", theme);
}
}
@@ -0,0 +1,28 @@
// services/frontend-leptos/frontend/src/layout/dashboard_layout.rs
use leptos::children::Children;
use leptos::prelude::*;
use super::header::Header;
use super::mobile_tab_bar::MobileTabBar;
use super::sidebar::Sidebar;
use super::tab_strip::TabStrip;
#[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>
<MobileTabBar />
</div>
}
}
@@ -0,0 +1,56 @@
// services/frontend-leptos/frontend/src/layout/header.rs
use leptos::prelude::*;
use crate::ws::context::WsContext;
use crate::ws::socket::WsStatus;
#[component]
pub fn Header() -> impl IntoView {
let ws = use_context::<WsContext>().expect("WsContext not provided");
let ws_status = ws.status;
let indicator_text_memo = create_memo(move |_| match ws_status.get() {
WsStatus::Connected => "Online",
WsStatus::Connecting => "Menghubungkan...",
WsStatus::Disconnected => "Offline",
WsStatus::Error(_) => "Error",
});
let indicator_color_memo = create_memo(move |_| match ws_status.get() {
WsStatus::Connected => "var(--color-success)",
WsStatus::Connecting => "var(--color-warning)",
WsStatus::Disconnected => "var(--text-tertiary)",
WsStatus::Error(_) => "var(--color-error)",
});
view! {
<header style="
height: var(--header-height);
border-bottom: 1px solid var(--surface-border);
display: flex;
align-items: center;
padding: 0 1.5rem;
background: var(--surface-base);
position: sticky;
top: 0;
z-index: var(--z-header);
">
<div class="flex items-center gap-2">
<span style="font-weight: 700; font-size: 1.125rem; color: var(--color-primary);">
"IMPHNEN"
</span>
<span style="color: var(--text-secondary); font-size: 0.75rem; padding: 0.125rem 0.375rem; background: var(--surface-overlay); border-radius: var(--radius-sm);">
"Guild Watcher"
</span>
</div>
<div class="flex items-center gap-4 ml-auto">
<div class="flex items-center gap-1" style="font-size: 0.75rem; color: var(--text-secondary);">
<span
style="width: 8px; height: 8px; border-radius: 50%;"
style:background={move || indicator_color_memo.get()}
></span>
<span>{move || indicator_text_memo.get()}</span>
</div>
</div>
</header>
}
}
@@ -0,0 +1,63 @@
// services/frontend-leptos/frontend/src/layout/mobile_tab_bar.rs
use leptos::prelude::*;
use shared_types::ui_state::Tab;
use crate::app::UiContext;
#[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>
}
}
#[component]
fn MobileTabItem(
icon: &'static str,
label: &'static str,
tab: Tab,
ui: UiContext,
) -> impl IntoView {
let tab_active = tab.clone();
let tab_click = tab;
view! {
<button
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>
<span>{label}</span>
</button>
}
}
@@ -0,0 +1,6 @@
// 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;
@@ -0,0 +1,71 @@
// services/frontend-leptos/frontend/src/layout/sidebar.rs
use leptos::prelude::*;
use shared_types::ui_state::Tab;
use crate::app::UiContext;
#[component]
pub fn Sidebar() -> impl IntoView {
let ui = use_context::<UiContext>().expect("UiContext not provided");
let (collapsed, _set_collapsed) = create_signal(false);
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>
}
}
#[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());
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
>
{label}
</button>
}
}
@@ -0,0 +1,54 @@
// services/frontend-leptos/frontend/src/layout/tab_strip.rs
use leptos::prelude::*;
use shared_types::ui_state::Tab;
use crate::app::UiContext;
#[component]
pub fn TabStrip() -> impl IntoView {
let ui = use_context::<UiContext>().expect("UiContext not provided");
view! {
<div style="
display: flex;
border-bottom: 1px solid var(--surface-border);
padding: 0 1rem;
background: var(--surface-base);
">
<TabItem label="Pesan & Moderasi" tab=Tab::Messages ui=ui.clone() />
<TabItem label="Voice & Media" tab=Tab::Live ui=ui.clone() />
<TabItem label="Dashboard Guild" tab=Tab::Dashboard ui=ui.clone() />
</div>
}
}
#[component]
fn TabItem(
label: &'static str,
tab: Tab,
ui: UiContext,
) -> impl IntoView {
let tab_color = tab.clone();
let tab_border = tab.clone();
let tab_click = tab;
view! {
<button
on:click=move |_| ui.active_tab.set(tab_click.clone())
style="
padding: 0.75rem 1rem;
background: none;
border: none;
border-bottom: 2px solid transparent;
font-family: inherit;
font-size: 0.875rem;
font-weight: 500;
cursor: pointer;
color: var(--text-secondary);
transition: all var(--transition-fast);
"
style:color=move || if ui.active_tab.get() == tab_color { "var(--color-primary)" } else { "" }
style:border-bottom-color=move || if ui.active_tab.get() == tab_border { "var(--color-primary)" } else { "transparent" }
>
{label}
</button>
}
}
+20
View File
@@ -0,0 +1,20 @@
pub mod api;
pub mod app;
pub mod auth;
pub mod features;
pub mod layout;
pub mod ui;
pub mod ws;
use wasm_bindgen::prelude::*;
#[wasm_bindgen(start)]
pub fn start() {
// Set up panic hook for better error messages in the browser console
console_error_panic_hook::set_once();
// Initialize logger
wasm_logger::init(wasm_logger::Config::default());
// Mount the Leptos app to the body
leptos::mount::mount_to_body(app::App);
}
@@ -0,0 +1,42 @@
use leptos::prelude::*;
#[derive(Clone)]
pub enum BadgeVariant {
Default,
Primary,
Success,
Warning,
Destructive,
Outline,
Info,
}
impl Default for BadgeVariant {
fn default() -> Self {
Self::Default
}
}
#[component]
pub fn Badge(
#[prop(optional)] variant: BadgeVariant,
children: Children,
) -> impl IntoView {
let variant_class = match variant {
BadgeVariant::Default => "",
BadgeVariant::Primary => "badge-primary",
BadgeVariant::Success => "badge-success",
BadgeVariant::Warning => "badge-warning",
BadgeVariant::Destructive => "badge-destructive",
BadgeVariant::Outline => "badge-outline",
BadgeVariant::Info => "badge-info",
};
let combined = format!("badge {}", variant_class);
view! {
<span class=combined>
{children()}
</span>
}
}
@@ -0,0 +1,67 @@
use leptos::prelude::*;
#[derive(Clone, Default)]
pub enum ButtonVariant {
#[default]
Primary,
Secondary,
Tertiary,
Destructive,
Outline,
Ghost,
Link,
}
#[derive(Clone)]
pub enum ButtonSize {
Default,
Sm,
Lg,
Icon,
IconSm,
}
impl Default for ButtonSize {
fn default() -> Self {
Self::Default
}
}
#[component]
pub fn Button(
#[prop(optional)] variant: ButtonVariant,
#[prop(optional)] size: ButtonSize,
#[prop(optional)] disabled: bool,
#[prop(optional)] class: &'static str,
#[prop(optional)] on_click: Option<Box<dyn Fn(leptos::ev::MouseEvent)>>,
children: Children,
) -> impl IntoView {
let variant_class = match variant {
ButtonVariant::Primary => "btn-primary",
ButtonVariant::Secondary => "btn-secondary",
ButtonVariant::Tertiary => "btn-tertiary",
ButtonVariant::Destructive => "btn-destructive",
ButtonVariant::Outline => "btn-outline",
ButtonVariant::Ghost => "btn-ghost",
ButtonVariant::Link => "btn-link",
};
let size_class = match size {
ButtonSize::Default => "",
ButtonSize::Sm => "btn-sm",
ButtonSize::Lg => "btn-lg",
ButtonSize::Icon => "btn-icon",
ButtonSize::IconSm => "btn-icon-sm",
};
let combined = format!("btn {} {} {}", variant_class, size_class, class);
view! {
<button
class=combined
disabled=disabled
on:click=move |ev| { if let Some(ref cb) = on_click { cb(ev); } }
>
{children()}
</button>
}
}
+46
View File
@@ -0,0 +1,46 @@
use leptos::prelude::*;
#[component]
pub fn Card(
#[prop(optional)] elevated: bool,
#[prop(optional)] bordered: bool,
#[prop(optional)] class: &'static str,
children: Children,
) -> impl IntoView {
let combined = format!("card {}", class);
view! {
<div
class=combined
class:card-elevated=elevated
class:card-bordered=bordered
>
{children()}
</div>
}
}
#[component]
pub fn CardHeader(children: Children) -> impl IntoView {
view! { <div class="card-header">{children()}</div> }
}
#[component]
pub fn CardTitle(children: Children) -> impl IntoView {
view! { <h3 class="card-title">{children()}</h3> }
}
#[component]
pub fn CardDescription(children: Children) -> impl IntoView {
view! { <p class="card-description">{children()}</p> }
}
#[component]
pub fn CardContent(children: Children) -> impl IntoView {
view! { <div class="card-content">{children()}</div> }
}
#[component]
pub fn CardFooter(children: Children) -> impl IntoView {
view! { <div class="card-footer">{children()}</div> }
}
@@ -0,0 +1,19 @@
// services/frontend-leptos/frontend/src/ui/empty_state.rs
use leptos::prelude::*;
#[component]
pub fn EmptyState(
#[prop(optional)] icon: Option<AnyView>,
title: &'static str,
#[prop(optional)] description: Option<&'static str>,
#[prop(optional)] children: Option<Children>,
) -> impl IntoView {
view! {
<div class="empty-state">
{icon.map(|i| view! { <div class="empty-state-icon">{i}</div> })}
<div class="empty-state-title">{title}</div>
{description.map(|d| view! { <p class="empty-state-description">{d}</p> })}
{children.map(|c| c())}
</div>
}
}
@@ -0,0 +1,47 @@
// services/frontend-leptos/frontend/src/ui/input.rs
use leptos::prelude::*;
#[component]
pub fn Input(
#[prop(optional)] input_type: &'static str,
#[prop(optional)] placeholder: &'static str,
#[prop(optional)] value: RwSignal<String>,
#[prop(optional)] soft: bool,
#[prop(optional)] error: bool,
#[prop(optional)] class: &'static str,
#[prop(optional)] on_input: Option<Box<dyn Fn(String)>>,
) -> impl IntoView {
view! {
<input
type=input_type
class={if !class.is_empty() { format!("input {}", class) } else { "input".to_string() }}
class:input-soft=soft
class:input-error=error
placeholder=placeholder
prop:value=move || value.get()
on:input=move |ev| {
let val = event_target_value(&ev);
value.set(val.clone());
if let Some(ref cb) = on_input { cb(val); }
}
/>
}
}
#[component]
pub fn TextArea(
#[prop(optional)] placeholder: &'static str,
#[prop(optional)] value: RwSignal<String>,
#[prop(optional)] rows: u32,
#[prop(optional)] class: &'static str,
) -> impl IntoView {
view! {
<textarea
class={if !class.is_empty() { format!("input {}", class) } else { "input".to_string() }}
placeholder=placeholder
prop:value=move || value.get()
on:input=move |ev| value.set(event_target_value(&ev))
rows=rows
></textarea>
}
}
+13
View File
@@ -0,0 +1,13 @@
// services/frontend-leptos/frontend/src/ui/mod.rs
pub mod badge;
pub mod button;
pub mod card;
pub mod input;
pub mod scroll_area;
pub mod select;
pub mod tabs;
pub mod toast;
pub mod skeleton;
pub mod status_badge;
pub mod empty_state;
pub mod modal;
@@ -0,0 +1,46 @@
// services/frontend-leptos/frontend/src/ui/modal.rs
use std::sync::Arc;
use leptos::prelude::*;
#[component]
pub fn Modal(
is_open: RwSignal<bool>,
#[prop(optional)] title: Option<&'static str>,
#[prop(optional)] on_close: Option<Arc<dyn Fn() + Send + Sync + 'static>>,
children: Children,
) -> impl IntoView {
let oc1 = on_close.clone();
let oc2 = on_close;
view! {
<div
class="modal-overlay"
style=move || {
if is_open.get() {
String::new()
} else {
"display: none;".to_string()
}
}
on:click=move |_| {
is_open.set(false);
if let Some(ref cb) = oc1 { cb(); }
}
>
<div class="modal-content" on:click=|ev| ev.stop_propagation()>
{title.map(|t| view! {
<div class="modal-header">
<h3>{t}</h3>
<button class="btn btn-ghost btn-icon-sm" on:click=move |_| {
is_open.set(false);
if let Some(ref cb) = oc2 { cb(); }
}>"×"</button>
</div>
})}
<div class="modal-body">
{children()}
</div>
</div>
</div>
}
}
@@ -0,0 +1,15 @@
// services/frontend-leptos/frontend/src/ui/scroll_area.rs
use leptos::prelude::*;
#[component]
pub fn ScrollArea(
#[prop(optional)] class: &'static str,
#[prop(optional)] style: &'static str,
children: Children,
) -> impl IntoView {
view! {
<div class={if !class.is_empty() { format!("scroll-area {}", class) } else { "scroll-area".to_string() }} style=style>
{children()}
</div>
}
}
@@ -0,0 +1,30 @@
// services/frontend-leptos/frontend/src/ui/select.rs
use leptos::prelude::*;
/// Simple select — values and labels are the same
/// For options with different value/label, use `SelectOptions`
#[component]
pub fn Select(
#[prop(optional)] value: RwSignal<String>,
options: Vec<(&'static str, &'static str)>, // (value, label)
#[prop(optional)] placeholder: &'static str,
#[prop(optional)] class: &'static str,
#[prop(optional)] on_change: Option<Box<dyn Fn(String)>>,
) -> impl IntoView {
view! {
<select
class={if !class.is_empty() { format!("select {}", class) } else { "select".to_string() }}
prop:value=move || value.get()
on:change=move |ev| {
let val = event_target_value(&ev);
value.set(val.clone());
if let Some(ref cb) = on_change { cb(val); }
}
>
<option value="" disabled=placeholder.len() > 0>{placeholder}</option>
{options.into_iter().map(|(val, label)| view! {
<option value=val selected=move || value.get() == val>{label}</option>
}).collect::<Vec<_>>()}
</select>
}
}
@@ -0,0 +1,30 @@
// services/frontend-leptos/frontend/src/ui/skeleton.rs
use leptos::prelude::*;
#[derive(Clone, Default)]
pub enum SkeletonShape {
#[default]
Rounded,
Circular,
Rectangular,
}
#[component]
pub fn Skeleton(
#[prop(optional)] width: &'static str,
#[prop(optional)] height: &'static str,
#[prop(optional)] shape: SkeletonShape,
) -> impl IntoView {
let shape_class = match shape {
SkeletonShape::Rounded => "",
SkeletonShape::Circular => "skeleton-circular",
SkeletonShape::Rectangular => "skeleton-rectangular",
};
let combined = format!("skeleton {}", shape_class);
view! {
<div
class=combined
style=format!("width: {}; height: {};", width, height)
></div>
}
}
@@ -0,0 +1,21 @@
// services/frontend-leptos/frontend/src/ui/status_badge.rs
use leptos::prelude::*;
use shared_types::message::AiStatus;
#[component]
pub fn StatusBadge(status: AiStatus) -> impl IntoView {
let (class, label) = match status {
AiStatus::Flagged => ("status-badge-flagged", "Flagged"),
AiStatus::Clean => ("status-badge-clean", "Clean"),
AiStatus::Warn => ("status-badge-warn", "Warned"),
AiStatus::Pending => ("status-badge-pending", "Pending"),
AiStatus::Processing => ("status-badge-processing", "Processing"),
AiStatus::Error => ("status-badge-error", "Error"),
};
let combined = format!("status-badge {}", class);
view! {
<span class=combined>
{label}
</span>
}
}
+66
View File
@@ -0,0 +1,66 @@
// services/frontend-leptos/frontend/src/ui/tabs.rs
use leptos::prelude::*;
#[component]
pub fn Tabs(
active: RwSignal<String>,
#[prop(optional)] class: &'static str,
children: Children,
) -> impl IntoView {
view! {
<div class={if !class.is_empty() { format!("tabs {}", class) } else { "tabs".to_string() }}>
{children()}
</div>
}
}
#[component]
pub fn TabList(
#[prop(optional)] class: &'static str,
children: Children,
) -> impl IntoView {
view! {
<div class={if !class.is_empty() { format!("tab-list {}", class) } else { "tab-list".to_string() }} role="tablist">
{children()}
</div>
}
}
#[component]
pub fn TabTrigger(
value: String,
active: RwSignal<String>,
children: Children,
) -> impl IntoView {
let v1 = value.clone();
let v2 = value.clone();
view! {
<button
class="tab-trigger"
class:active=move || active.get() == v1
role="tab"
aria-selected=move || if active.get() == v2 { "true" } else { "false" }
on:click=move |_| active.set(value.clone())
>
{children()}
</button>
}
}
#[component]
pub fn TabContent(
value: String,
active: RwSignal<String>,
children: Children,
) -> impl IntoView {
let is_selected = move || active.get() == value;
view! {
<div
class="tab-content"
role="tabpanel"
style:display=move || if is_selected() { "block" } else { "none" }
>
{children()}
</div>
}
}
@@ -0,0 +1,85 @@
// services/frontend-leptos/frontend/src/ui/toast.rs
use leptos::prelude::*;
use std::sync::{Arc, Mutex};
#[derive(Clone)]
pub enum ToastType {
Info,
Success,
Error,
Warning,
}
#[derive(Clone)]
pub struct ToastMessage {
pub id: u64,
pub message: String,
pub toast_type: ToastType,
}
#[derive(Clone)]
pub struct ToastContext {
pub toasts: RwSignal<Vec<ToastMessage>>,
next_id: Arc<Mutex<u64>>,
}
impl ToastContext {
pub fn new() -> Self {
Self {
toasts: create_rw_signal(vec![]),
next_id: Arc::new(Mutex::new(0)),
}
}
pub fn show(&self, message: &str, toast_type: ToastType) {
let id = {
let mut n = self.next_id.lock().unwrap();
*n += 1;
*n
};
let msg = ToastMessage {
id,
message: message.to_string(),
toast_type,
};
self.toasts.update(|t| t.push(msg));
// Auto-dismiss after 4 seconds
let toasts = self.toasts;
leptos::prelude::set_timeout(
move || {
toasts.update(|t| t.retain(|m| m.id != id));
},
std::time::Duration::from_secs(4),
);
}
}
#[component]
pub fn ToastProvider(children: Children) -> impl IntoView {
let ctx = ToastContext::new();
provide_context(ctx.clone());
view! {
{children()}
<div class="toast-container">
{move || ctx.toasts.get().into_iter().map(|msg| {
let type_class = match msg.toast_type {
ToastType::Info => "toast-info",
ToastType::Success => "toast-success",
ToastType::Error => "toast-error",
ToastType::Warning => "toast-warning",
};
let toasts = ctx.toasts;
view! {
<div class={format!("toast {}", type_class)}>
<span>{msg.message}</span>
<button class="toast-close" on:click=move |_| {
toasts.update(|t| t.retain(|m| m.id != msg.id));
}>"×"</button>
</div>
}
}).collect::<Vec<_>>()}
</div>
}
}
@@ -0,0 +1,151 @@
// services/frontend-leptos/frontend/src/ws/context.rs
use leptos::prelude::*;
use crate::ws::socket::{WsHandle, WsStatus, WsEvent};
use shared_types::message::MessageRecord;
use shared_types::voice::ActiveSpeaker;
use shared_types::media::MediaState;
use shared_types::recording::VoiceRecording;
#[derive(Clone)]
pub struct WsContext {
pub handle: std::rc::Rc<WsHandle>,
pub status: ReadSignal<WsStatus>,
// Per-event callbacks (set externally by feature components)
// Wrapped in Rc so cloning shares the same callback slots
pub on_message_created: std::rc::Rc<std::cell::RefCell<Option<Box<dyn Fn(MessageRecord)>>>>,
pub on_message_updated: std::rc::Rc<std::cell::RefCell<Option<Box<dyn Fn(MessageRecord)>>>>,
pub on_message_deleted: std::rc::Rc<std::cell::RefCell<Option<Box<dyn Fn(String)>>>>,
pub on_message_analyzed: std::rc::Rc<std::cell::RefCell<Option<Box<dyn Fn(MessageRecord)>>>>,
pub on_voice_active_user: std::rc::Rc<std::cell::RefCell<Option<Box<dyn Fn(ActiveSpeaker)>>>>,
pub on_voice_recording_uploaded: std::rc::Rc<std::cell::RefCell<Option<Box<dyn Fn(VoiceRecording)>>>>,
pub on_media_state: std::rc::Rc<std::cell::RefCell<Option<Box<dyn Fn(MediaState)>>>>,
pub on_binary: std::rc::Rc<std::cell::RefCell<Option<Box<dyn Fn(Vec<u8>)>>>>,
}
impl WsContext {
pub fn new(url: &str) -> Self {
let ws_handle = std::rc::Rc::new(WsHandle::new(url));
let status = ws_handle.status;
let ctx = Self {
status,
handle: ws_handle,
on_message_created: std::rc::Rc::new(std::cell::RefCell::new(None)),
on_message_updated: std::rc::Rc::new(std::cell::RefCell::new(None)),
on_message_deleted: std::rc::Rc::new(std::cell::RefCell::new(None)),
on_message_analyzed: std::rc::Rc::new(std::cell::RefCell::new(None)),
on_voice_active_user: std::rc::Rc::new(std::cell::RefCell::new(None)),
on_voice_recording_uploaded: std::rc::Rc::new(std::cell::RefCell::new(None)),
on_media_state: std::rc::Rc::new(std::cell::RefCell::new(None)),
on_binary: std::rc::Rc::new(std::cell::RefCell::new(None)),
};
// Wire up the main event dispatcher
let ctx_clone = ctx.clone();
ctx.handle.on_event(move |event| {
ctx_clone.dispatch_event(event);
});
ctx
}
fn dispatch_event(&self, event: WsEvent) {
match event {
WsEvent::Text(text) => {
// Parse JSON envelope: { type: string, data?: any }
if let Ok(parsed) = serde_json::from_str::<serde_json::Value>(&text) {
let event_type = parsed["type"].as_str().unwrap_or("").to_string();
let data = parsed.get("data");
match event_type.as_str() {
"message_created" => {
if let Some(d) = data.and_then(|v| serde_json::from_value::<MessageRecord>(v.clone()).ok()) {
if let Some(cb) = self.on_message_created.borrow().as_ref() {
cb(d);
}
}
}
"message_updated" => {
if let Some(d) = data.and_then(|v| serde_json::from_value::<MessageRecord>(v.clone()).ok()) {
if let Some(cb) = self.on_message_updated.borrow().as_ref() {
cb(d);
}
}
}
"message_deleted" => {
if let Some(d) = data.and_then(|v| v.as_str().map(String::from)) {
if let Some(cb) = self.on_message_deleted.borrow().as_ref() {
cb(d);
}
}
}
"message_analyzed" => {
if let Some(d) = data.and_then(|v| serde_json::from_value::<MessageRecord>(v.clone()).ok()) {
if let Some(cb) = self.on_message_analyzed.borrow().as_ref() {
cb(d);
}
}
}
"voice_active_user" => {
if let Some(d) = data.and_then(|v| serde_json::from_value::<ActiveSpeaker>(v.clone()).ok()) {
if let Some(cb) = self.on_voice_active_user.borrow().as_ref() {
cb(d);
}
}
}
"voice_recording_uploaded" => {
if let Some(d) = data.and_then(|v| serde_json::from_value::<VoiceRecording>(v.clone()).ok()) {
if let Some(cb) = self.on_voice_recording_uploaded.borrow().as_ref() {
cb(d);
}
}
}
"media_state" => {
// Backend sends initial state with "state" key, live updates with "data"
let raw = data
.or_else(|| parsed.get("state"))
.cloned();
if let Some(d) = raw.and_then(|v| serde_json::from_value::<MediaState>(v).ok()) {
if let Some(cb) = self.on_media_state.borrow().as_ref() {
cb(d);
}
}
}
_ => {
// Unknown event type — log and ignore
web_sys::console::log_1(&format!("[WS] unhandled event: {}", event_type).into());
}
}
}
}
WsEvent::Binary(data) => {
if let Some(cb) = self.on_binary.borrow().as_ref() {
cb(data);
}
}
}
}
pub fn connect(&self) {
self.handle.connect();
}
pub fn disconnect(&self) {
self.handle.disconnect();
}
pub fn send_text(&self, text: &str) {
let _ = self.handle.send_text(text);
}
pub fn send_binary(&self, data: &[u8]) {
let _ = self.handle.send_binary(data);
}
}
// SAFETY: WsContext uses Rc/RefCell for cheap cloning in a single-threaded WASM
// environment. The leptos reactive system requires provided contexts to be Send+Sync.
#[allow(unsafe_code)]
unsafe impl Send for WsContext {}
#[allow(unsafe_code)]
unsafe impl Sync for WsContext {}
+3
View File
@@ -0,0 +1,3 @@
// services/frontend-leptos/frontend/src/ws/mod.rs
pub mod socket;
pub mod context;
+195
View File
@@ -0,0 +1,195 @@
// services/frontend-leptos/frontend/src/ws/socket.rs
use leptos::prelude::*;
use wasm_bindgen::prelude::*;
use wasm_bindgen::JsCast;
use web_sys::{WebSocket, MessageEvent, CloseEvent, ErrorEvent};
#[derive(Debug, Clone, PartialEq)]
pub enum WsStatus {
Disconnected,
Connecting,
Connected,
Error(String),
}
#[derive(Debug, Clone)]
pub enum WsEvent {
Text(String),
Binary(Vec<u8>),
}
pub struct WsHandle {
pub status: ReadSignal<WsStatus>,
set_status: WriteSignal<WsStatus>,
ws: std::cell::RefCell<Option<WebSocket>>,
on_event: std::rc::Rc<std::cell::RefCell<Option<Box<dyn Fn(WsEvent)>>>>,
url: String,
reconnect_attempt: std::cell::Cell<u32>,
}
impl WsHandle {
pub fn new(url: &str) -> Self {
let (status, set_status) = create_signal(WsStatus::Disconnected);
Self {
status,
set_status,
ws: std::cell::RefCell::new(None),
on_event: std::rc::Rc::new(std::cell::RefCell::new(None)),
url: url.to_string(),
reconnect_attempt: std::cell::Cell::new(0),
}
}
pub fn on_event<F>(&self, callback: F)
where
F: Fn(WsEvent) + 'static,
{
*self.on_event.borrow_mut() = Some(Box::new(callback));
}
pub fn connect(&self) {
if self.status.get() == WsStatus::Connected || self.status.get() == WsStatus::Connecting {
return;
}
self.set_status.set(WsStatus::Connecting);
let url = self.url.clone();
let status_clone = self.set_status.clone();
let event_clone: std::rc::Rc<std::cell::RefCell<Option<Box<dyn Fn(WsEvent)>>>> = self.on_event.clone();
let ws_holder = &self.ws as *const std::cell::RefCell<Option<WebSocket>>;
let reconnect_attempt = &self.reconnect_attempt as *const std::cell::Cell<u32>;
Self::perform_connect(&url, status_clone, event_clone, ws_holder, reconnect_attempt);
}
/// Shared connection setup used for both initial connect and reconnection.
/// Takes raw pointers because it must be callable from `wasm_bindgen` closures
/// that cannot borrow `self`.
#[allow(unsafe_code)]
fn perform_connect(
url: &str,
set_status: WriteSignal<WsStatus>,
on_event: std::rc::Rc<std::cell::RefCell<Option<Box<dyn Fn(WsEvent)>>>>,
ws_holder: *const std::cell::RefCell<Option<WebSocket>>,
reconnect_attempt: *const std::cell::Cell<u32>,
) {
let url_owned = url.to_string();
let status1 = set_status.clone();
let status2 = set_status.clone();
let status3 = set_status.clone();
let event_clone = on_event.clone();
match WebSocket::new(&url_owned) {
Ok(ws) => {
// Store reference
unsafe { *(*ws_holder).borrow_mut() = Some(ws.clone()) };
// onopen
let onopen_cb = Closure::<dyn Fn(web_sys::ProgressEvent)>::new(move |_| {
status1.set(WsStatus::Connected);
unsafe { (*reconnect_attempt).set(0) };
});
ws.set_onopen(Some(onopen_cb.as_ref().unchecked_ref()));
onopen_cb.forget();
// onclose — schedule reconnect with exponential backoff
let event_for_close = event_clone.clone();
let onclose_cb = Closure::<dyn Fn(CloseEvent)>::new(move |_| {
status2.set(WsStatus::Disconnected);
unsafe { *(*ws_holder).borrow_mut() = None };
let attempt = unsafe { (*reconnect_attempt).get() };
if attempt >= 20 {
status2.set(WsStatus::Error("Max reconnect attempts reached".to_string()));
return;
}
// Full-jitter exponential backoff: min(1000 * 2^attempt, 30000) * (0.5 + random * 0.5)
let base = core::cmp::min(1000u32 * (1u32 << attempt), 30000u32);
let jitter = 0.5 + js_sys::Math::random() * 0.5;
let delay_ms = (base as f64 * jitter) as u32;
unsafe { (*reconnect_attempt).set(attempt + 1) };
let url_reconnect = url_owned.clone();
let status_rc = status2.clone();
let event_rc = event_for_close.clone();
let reconnect_fn = Closure::<dyn Fn()>::new(move || {
Self::perform_connect(&url_reconnect, status_rc.clone(), event_rc.clone(), ws_holder, reconnect_attempt);
});
web_sys::window()
.and_then(|w| {
w.set_timeout_with_callback_and_timeout_and_arguments_0(
reconnect_fn.as_ref().unchecked_ref(),
delay_ms as i32,
).ok()
});
reconnect_fn.forget();
});
ws.set_onclose(Some(onclose_cb.as_ref().unchecked_ref()));
onclose_cb.forget();
// onerror
let onerror_cb = Closure::<dyn Fn(ErrorEvent)>::new(move |e: ErrorEvent| {
status3.set(WsStatus::Error(e.message()));
});
ws.set_onerror(Some(onerror_cb.as_ref().unchecked_ref()));
onerror_cb.forget();
// onmessage
let onmsg_cb = Closure::<dyn Fn(MessageEvent)>::new(move |e: MessageEvent| {
if let Some(cb) = &*event_clone.borrow() {
if let Some(text) = e.data().as_string() {
cb(WsEvent::Text(text));
} else if let Some(abuf) = e.data().dyn_ref::<js_sys::ArrayBuffer>() {
let len = abuf.byte_length() as usize;
let u8view = js_sys::Uint8Array::new(abuf);
let mut bytes = vec![0u8; len];
u8view.copy_to(&mut bytes);
cb(WsEvent::Binary(bytes));
} else {
// Blob — would need async FileReader, skip for now
}
}
});
ws.set_onmessage(Some(onmsg_cb.as_ref().unchecked_ref()));
onmsg_cb.forget();
}
Err(e) => {
set_status.set(WsStatus::Error(
js_sys::Error::from(e).to_string().as_string().unwrap_or_default(),
));
}
}
}
pub fn disconnect(&self) {
if let Some(ws) = self.ws.borrow_mut().take() {
ws.close().ok();
}
self.set_status.set(WsStatus::Disconnected);
}
pub fn send_text(&self, text: &str) -> Result<(), JsValue> {
if let Some(ws) = self.ws.borrow().as_ref() {
ws.send_with_str(text)
} else {
Err(JsValue::from_str("WebSocket not connected"))
}
}
pub fn send_binary(&self, data: &[u8]) -> Result<(), JsValue> {
if let Some(ws) = self.ws.borrow().as_ref() {
let array = js_sys::Uint8Array::from(data);
let buffer = array.buffer();
ws.send_with_array_buffer(&buffer)
} else {
Err(JsValue::from_str("WebSocket not connected"))
}
}
}
// SAFETY: WsHandle uses Rc/RefCell for cheap cloning in a single-threaded WASM
// environment. The leptos reactive system requires provided contexts to be Send+Sync.
#[allow(unsafe_code)]
unsafe impl Send for WsHandle {}
#[allow(unsafe_code)]
unsafe impl Sync for WsHandle {}
-17
View File
@@ -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>
-45
View File
@@ -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
-38
View File
@@ -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"
}
}
-5
View File
@@ -1,5 +0,0 @@
export default {
plugins: {
"@tailwindcss/postcss": {},
},
};
-44
View File
@@ -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

+4
View File
@@ -0,0 +1,4 @@
[toolchain]
channel = "nightly-2026-06-01"
components = ["rust-src", "rustc-dev"]
targets = ["wasm32-unknown-unknown"]
@@ -0,0 +1,7 @@
[package]
name = "shared-types"
version = "0.1.0"
edition = "2021"
[dependencies]
serde = { version = "1", features = ["derive"] }
@@ -0,0 +1,87 @@
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DashboardStats {
pub total_messages: u64,
pub total_users: u64,
pub total_flagged: u64,
pub total_clean: u64,
pub total_warned: u64,
pub total_error: u64,
pub total_voice_recordings: u64,
pub total_profiles: u64,
pub today_messages: u64,
pub today_flagged: u64,
pub active_users_24h: u64,
pub top_channels: Vec<TopChannel>,
pub moderation_overview: ModerationOverview,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TopChannel {
pub channel_id: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub channel_name: Option<String>,
pub message_count: u64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ModerationOverview {
pub pending: u64,
pub processing: u64,
pub error: u64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DashboardUser {
pub user_id: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub username: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub avatar_url: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub profile_summary: Option<String>,
pub total_messages: u64,
pub flagged_count: u64,
#[serde(skip_serializing_if = "Option::is_none")]
pub last_message_at: Option<i64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub trust_score: Option<f64>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DashboardUserDetail {
#[serde(flatten)]
pub user: DashboardUser,
#[serde(skip_serializing_if = "Option::is_none")]
pub last_analyzed_at: Option<i64>,
pub clean_message_streak: u64,
pub total_infractions: u64,
pub clean_count: u64,
pub recent_messages: Vec<super::message::MessageRecord>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DashboardChannel {
pub channel_id: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub channel_name: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub guild_id: Option<String>,
pub total_messages: u64,
pub flagged_count: u64,
#[serde(skip_serializing_if = "Option::is_none")]
pub last_message_at: Option<i64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub culture_summary: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub last_analyzed_at: Option<i64>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DashboardChannelDetail {
#[serde(flatten)]
pub channel: DashboardChannel,
pub clean_count: u64,
pub recent_messages: Vec<super::message::MessageRecord>,
}
@@ -0,0 +1,36 @@
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Guild {
pub id: String,
pub name: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub icon: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Channel {
pub id: String,
pub name: String,
#[serde(rename = "type")]
#[serde(skip_serializing_if = "Option::is_none")]
pub channel_type: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub parent_id: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct GuildVoiceEntry {
pub guild_id: String,
pub channel_id: String,
pub channel_name: String,
pub connected_at: i64,
}
// ── Config ────────────────────────────────────────────────
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AppConfig {
#[serde(skip_serializing_if = "Option::is_none")]
pub monitor_guild_id: Option<String>,
}
@@ -0,0 +1,7 @@
pub mod message;
pub mod guild;
pub mod voice;
pub mod media;
pub mod dashboard;
pub mod recording;
pub mod ui_state;
@@ -0,0 +1,30 @@
use serde::{Deserialize, Serialize};
pub type MediaMode = String; // "music" | "screen"
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MediaItem {
#[serde(skip_serializing_if = "Option::is_none")]
pub id: Option<String>,
pub source: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub title: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub mode: Option<MediaMode>,
#[serde(rename = "durationMs")]
#[serde(skip_serializing_if = "Option::is_none")]
pub duration_ms: Option<u64>,
#[serde(rename = "thumbnailUrl")]
#[serde(skip_serializing_if = "Option::is_none")]
pub thumbnail_url: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MediaState {
pub playing: bool,
#[serde(rename = "musicVolume")]
pub music_volume: f64,
#[serde(skip_serializing_if = "Option::is_none")]
pub current: Option<MediaItem>,
pub queue: Vec<MediaItem>,
}
@@ -0,0 +1,170 @@
use serde::{Deserialize, Serialize};
// ── AI Status ─────────────────────────────────────────────
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "snake_case")]
pub enum AiStatus {
Pending,
Processing,
Clean,
Warn,
Flagged,
Error,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "snake_case")]
pub enum AiSeverity {
None,
Low,
Medium,
High,
Critical,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "snake_case")]
pub enum AiRecommendedAction {
None,
Monitor,
Warn,
Review,
Delete,
Escalate,
}
// ── Message Metadata ──────────────────────────────────────
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct MessageMetadata {
#[serde(skip_serializing_if = "Option::is_none")]
pub stickers: Option<Vec<StickerInfo>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub attachments: Option<Vec<AttachmentRef>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub embeds: Option<Vec<EmbedInfo>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub channel: Option<ChannelRef>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct StickerInfo {
#[serde(skip_serializing_if = "Option::is_none")]
pub name: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub url: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct AttachmentRef {
pub name: String,
pub url: String,
#[serde(rename = "contentType")]
#[serde(skip_serializing_if = "Option::is_none")]
pub content_type: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct EmbedInfo {
#[serde(skip_serializing_if = "Option::is_none")]
pub title: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub image: Option<EmbedMedia>,
#[serde(skip_serializing_if = "Option::is_none")]
pub thumbnail: Option<EmbedMedia>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct EmbedMedia {
pub url: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub width: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub height: Option<u32>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct ChannelRef {
pub channel_id: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub channel_name: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub thread_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub thread_name: Option<String>,
}
// ── Message Record ────────────────────────────────────────
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct MessageRecord {
pub id: String,
pub guild_id: String,
pub channel_id: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub thread_id: Option<String>,
pub user_id: String,
pub username: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub avatar_url: Option<String>,
pub content: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub edited_content: Option<String>,
#[serde(rename = "type")]
pub msg_type: String, // "text" | "edited" | "deleted"
pub created_at: i64,
#[serde(skip_serializing_if = "Option::is_none")]
pub edited_at: Option<i64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub deleted_at: Option<i64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub ai_status: Option<AiStatus>,
#[serde(skip_serializing_if = "Option::is_none")]
pub ai_severity: Option<AiSeverity>,
#[serde(skip_serializing_if = "Option::is_none")]
pub ai_confidence: Option<f64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub ai_moderation_flags: Option<Vec<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub ai_moderation_score: Option<f64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub ai_analysis: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub ai_categories: Option<Vec<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub ai_recommended_action: Option<AiRecommendedAction>,
#[serde(skip_serializing_if = "Option::is_none")]
pub ai_error: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub ai_analyzed_at: Option<i64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub metadata: Option<MessageMetadata>,
}
// ── Pagination ────────────────────────────────────────────
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct PageResult<T> {
pub data: Vec<T>,
#[serde(rename = "nextCursor")]
pub next_cursor: Option<String>,
}
// ── Attachment ────────────────────────────────────────────
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct AttachmentRecord {
pub id: String,
pub message_id: String,
pub guild_id: String,
pub channel_id: String,
pub filename: String,
pub size: u64,
#[serde(rename = "type")]
pub mime_type: String,
pub discord_url: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub uploaded_url: Option<String>,
pub upload_status: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub upload_error: Option<String>,
pub created_at: i64,
#[serde(skip_serializing_if = "Option::is_none")]
pub uploaded_at: Option<i64>,
}
@@ -0,0 +1,37 @@
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct VoiceRecording {
pub id: String,
pub user_id: String,
pub username: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub avatar_url: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub guild_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub channel_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub channel_name: Option<String>,
pub filename: String,
pub size_bytes: u64,
#[serde(skip_serializing_if = "Option::is_none")]
pub download_url: Option<String>,
pub upload_status: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub upload_error: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub transcription: Option<String>,
pub created_at: i64,
#[serde(skip_serializing_if = "Option::is_none")]
pub uploaded_at: Option<i64>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct VoiceRecordingListResponse {
pub items: Vec<VoiceRecording>,
#[serde(rename = "nextCursor")]
pub next_cursor: Option<String>,
#[serde(rename = "hasMore")]
pub has_more: bool,
}
@@ -0,0 +1,29 @@
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "snake_case")]
pub enum Tab {
Messages,
Live,
Dashboard,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct UiState {
#[serde(skip_serializing_if = "Option::is_none")]
pub selected_guild: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub selected_voice_guild: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub selected_voice_channel: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub selected_text_guild: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub selected_text_channel: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub active_tab: Option<Tab>,
#[serde(skip_serializing_if = "Option::is_none")]
pub is_listening: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub is_streaming: Option<bool>,
}
@@ -0,0 +1,26 @@
use serde::{Deserialize, Serialize};
use crate::guild::GuildVoiceEntry;
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct VoiceStatus {
pub connected: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub active_guild_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub active_channel_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub active_channel_name: Option<String>,
pub connections: Vec<GuildVoiceEntry>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ActiveSpeaker {
#[serde(skip_serializing_if = "Option::is_none")]
pub id: Option<String>,
pub user_id: String,
pub username: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub avatar: Option<String>,
pub speaking: bool,
}
-300
View File
@@ -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>
);
}
@@ -1,75 +0,0 @@
export interface DashboardStats {
total_messages: number;
total_users: number;
total_flagged: number;
total_clean: number;
total_warned: number;
total_error: number;
total_voice_recordings: number;
total_profiles: number;
today_messages: number;
today_flagged: number;
active_users_24h: number;
top_channels: Array<{
channel_id: string;
channel_name: string | null;
message_count: number;
}>;
moderation_overview: {
pending: number;
processing: number;
error: number;
};
}
export interface DashboardUser {
user_id: string;
username: string | null;
avatar_url: string | null;
profile_summary: string | null;
total_messages: number;
flagged_count: number;
last_message_at: number | null;
trust_score: number | null;
}
export interface DashboardUserDetail extends DashboardUser {
last_analyzed_at: number | null;
clean_message_streak: number | null;
total_infractions: number | null;
clean_count: number;
recent_messages: Array<{
id: string;
content: string;
channel_id: string;
created_at: number;
ai_status: string | null;
}>;
}
export interface DashboardChannel {
channel_id: string;
channel_name: string | null;
guild_id: string | null;
total_messages: number;
flagged_count: number;
last_message_at: number | null;
culture_summary: string | null;
last_analyzed_at: number | null;
}
export interface DashboardChannelDetail extends DashboardChannel {
clean_count: number;
recent_messages: Array<{
id: string;
content: string;
channel_id: string;
created_at: number;
ai_status: string | null;
username: string | null;
}>;
}
export interface ChatResponse {
response?: string;
}
@@ -1,19 +0,0 @@
export interface Guild {
id: string;
name: string;
icon: string | null;
}
export interface Channel {
id: string;
name: string;
type?: string;
parentId?: string | null;
}
export interface GuildVoiceEntry {
guildId: string;
channelId: string;
channelName: string;
connectedAt: number;
}
@@ -1,17 +0,0 @@
export type MediaMode = "music" | "screen";
export interface MediaItem {
id?: string;
source: string;
title: string;
mode?: "music" | "screen";
durationMs?: number | null;
thumbnailUrl?: string | null;
}
export interface MediaState {
playing: boolean;
musicVolume: number;
current: MediaItem | null;
queue: MediaItem[];
}
@@ -1,19 +0,0 @@
export type {
AIRecommendedAction,
AISeverity,
AIStatus,
MessageRecord,
PageResult,
} from "@bete/shared";
export interface MessageMetadata {
stickers?: Array<{ name?: string; url?: string }>;
attachments?: Array<{ name: string; url: string; contentType?: string }>;
embeds?: Array<{ title?: string; image?: string; thumbnail?: string }>;
channel?: {
channelId: string;
channelName?: string;
threadId?: string;
threadName?: string;
};
}
@@ -1,23 +0,0 @@
export interface VoiceRecording {
id: string;
user_id: string;
username: string;
avatar_url: string | null;
guild_id: string | null;
channel_id: string | null;
channel_name: string | null;
filename: string;
size_bytes: number;
download_url: string | null;
upload_status: "pending" | "uploaded" | "failed";
upload_error: string | null;
transcription?: string | null;
created_at: number;
uploaded_at: number | null;
}
export interface VoiceRecordingListResponse {
items: VoiceRecording[];
nextCursor: string | null;
hasMore: boolean;
}

Some files were not shown because too many files have changed in this diff Show More