chore: remove React frontend, rename frontend-leptos to frontend
This commit is contained in:
@@ -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
|
||||
|
||||
Generated
+2548
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,3 @@
|
||||
[workspace]
|
||||
resolver = "2"
|
||||
members = ["shared-types", "frontend"]
|
||||
@@ -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` |
|
||||
@@ -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"
|
||||
@@ -0,0 +1,7 @@
|
||||
[build]
|
||||
target = "index.html"
|
||||
dist = "dist"
|
||||
|
||||
[serve]
|
||||
port = 8080
|
||||
open = false
|
||||
@@ -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
@@ -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>
|
||||
}
|
||||
}
|
||||
@@ -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(¤t, &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(¤t, &[msg]));
|
||||
}));
|
||||
}
|
||||
{
|
||||
let msgs = state.messages;
|
||||
*ws.on_message_updated.borrow_mut() = Some(Box::new(move |msg| {
|
||||
let current = msgs.get();
|
||||
msgs.set(merge_messages(¤t, &[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(¤t, &[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>
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
@@ -0,0 +1,3 @@
|
||||
// services/frontend-leptos/frontend/src/ws/mod.rs
|
||||
pub mod socket;
|
||||
pub mod context;
|
||||
@@ -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 {}
|
||||
@@ -1,17 +0,0 @@
|
||||
<!doctype html>
|
||||
<html lang="id">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="theme-color" content="#23a1eb" />
|
||||
<title>IMPHNEN — Discord Moderation</title>
|
||||
<link rel="icon" type="image/svg+xml" href="https://raw.githubusercontent.com/IMPHNEN/imphnen-frontend-service/develop/docs/logo.svg" />
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link href="https://fonts.googleapis.com/css2?family=Poppins:wght@300;400;500;600;700;800&display=swap" rel="stylesheet" />
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,45 +0,0 @@
|
||||
# Frontend web application
|
||||
language: typescript
|
||||
|
||||
tasks:
|
||||
dev:
|
||||
command: vite --host 0.0.0.0
|
||||
preset: server
|
||||
|
||||
build:
|
||||
script: 'tsc --noEmit && NODE_NO_WARNINGS=1 vite build'
|
||||
options:
|
||||
runFromWorkspaceRoot: false
|
||||
inputs:
|
||||
- src
|
||||
- tsconfig.json
|
||||
- vite.config.mts
|
||||
- index.html
|
||||
outputs:
|
||||
- dist
|
||||
|
||||
preview:
|
||||
command: vite preview --host 0.0.0.0 --port 3000
|
||||
preset: server
|
||||
|
||||
typecheck:
|
||||
command: tsc --noEmit
|
||||
options:
|
||||
runFromWorkspaceRoot: false
|
||||
inputs:
|
||||
- src
|
||||
- tsconfig.json
|
||||
|
||||
lint:
|
||||
command: biome check --diagnostic-level=error src/
|
||||
options:
|
||||
runFromWorkspaceRoot: false
|
||||
inputs:
|
||||
- src
|
||||
|
||||
format:
|
||||
command: biome format --write src/
|
||||
options:
|
||||
runFromWorkspaceRoot: false
|
||||
inputs:
|
||||
- src
|
||||
@@ -1,38 +0,0 @@
|
||||
{
|
||||
"name": "@gmw/frontend",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite --host 0.0.0.0",
|
||||
"build": "tsc --noEmit && NODE_NO_WARNINGS=1 vite build",
|
||||
"preview": "vite preview --host 0.0.0.0 --port 3000",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"lint": "biome check --diagnostic-level=error src/",
|
||||
"format": "biome format --write src/"
|
||||
},
|
||||
"dependencies": {
|
||||
"@bete/shared": "workspace:*",
|
||||
"@radix-ui/react-scroll-area": "^1.2.10",
|
||||
"@radix-ui/react-slot": "^1.2.4",
|
||||
"@radix-ui/react-tabs": "^1.1.13",
|
||||
"clsx": "^2.1.1",
|
||||
"framer-motion": "^12.4.0",
|
||||
"lucide-react": "^1.16.0",
|
||||
"react": "^19.2.6",
|
||||
"react-dom": "^19.2.6",
|
||||
"tailwind-merge": "^3.6.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "latest",
|
||||
"@tailwindcss/postcss": "^4.3.0",
|
||||
"@types/react": "^19.2.14",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitejs/plugin-react": "^6.0.2",
|
||||
"autoprefixer": "^10.5.0",
|
||||
"postcss": "^8.5.14",
|
||||
"tailwindcss": "^4.3.0",
|
||||
"typescript": "^5.9.3",
|
||||
"vite": "^8.0.13"
|
||||
}
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
export default {
|
||||
plugins: {
|
||||
"@tailwindcss/postcss": {},
|
||||
},
|
||||
};
|
||||
@@ -1,44 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128" width="128" height="128">
|
||||
<defs>
|
||||
<linearGradient id="bg" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" stop-color="#1a1a2e"/>
|
||||
<stop offset="100%" stop-color="#16213e"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="shield" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" stop-color="#0ea5e9"/>
|
||||
<stop offset="100%" stop-color="#06b6d4"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="eye" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" stop-color="#22d3ee"/>
|
||||
<stop offset="100%" stop-color="#67e8f9"/>
|
||||
</linearGradient>
|
||||
<filter id="glow">
|
||||
<feGaussianBlur stdDeviation="2" result="blur"/>
|
||||
<feMerge>
|
||||
<feMergeNode in="blur"/>
|
||||
<feMergeNode in="SourceGraphic"/>
|
||||
</feMerge>
|
||||
</filter>
|
||||
</defs>
|
||||
<!-- Background -->
|
||||
<rect width="128" height="128" rx="24" fill="url(#bg)"/>
|
||||
<!-- Shield shape -->
|
||||
<path d="M64 18 L102 32 L102 64 C102 88 84 106 64 114 C44 106 26 88 26 64 L26 32 Z"
|
||||
fill="url(#shield)" opacity="0.9"/>
|
||||
<!-- Shield inner border -->
|
||||
<path d="M64 24 L96 36 L96 63 C96 84 80 100 64 108 C48 100 32 84 32 63 L32 36 Z"
|
||||
fill="none" stroke="#38bdf8" stroke-width="1.5" opacity="0.6"/>
|
||||
<!-- Eye outer -->
|
||||
<ellipse cx="64" cy="60" rx="22" ry="14" fill="none" stroke="#0f172a" stroke-width="2.5" opacity="0.8"/>
|
||||
<!-- Eye inner -->
|
||||
<ellipse cx="64" cy="60" rx="20" ry="12" fill="#0f172a" opacity="0.7"/>
|
||||
<!-- Pupil -->
|
||||
<circle cx="64" cy="60" r="7" fill="url(#eye)" filter="url(#glow)"/>
|
||||
<!-- Pupil inner dot -->
|
||||
<circle cx="64" cy="60" r="3" fill="#ffffff" opacity="0.9"/>
|
||||
<!-- Eyebrow / scan line -->
|
||||
<line x1="42" y1="60" x2="86" y2="60" stroke="#0ea5e9" stroke-width="0.5" opacity="0.4"/>
|
||||
<!-- GMW text -->
|
||||
<text x="64" y="92" text-anchor="middle" font-family="system-ui, -apple-system, sans-serif"
|
||||
font-weight="900" font-size="16" fill="#ffffff" letter-spacing="3" opacity="0.95">GMW</text>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 2.0 KiB |
@@ -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,
|
||||
}
|
||||
@@ -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
Reference in New Issue
Block a user