feat(frontend): rebuild as Ambient/WebGL console with all pages + command palette
Ground-up rombak UI: hapus semua component/page lama, bangun ulang dengan desain sistem Ambient (WebGL haze + drifting motes, signal-driven color) di atas kontrak API/WS/type yang sudah ada. - Design system: globals.css tokens + primitives (glass, button, badge, select, avatar, toast, chart SVG murni). - Shell: nav rail, topbar (status WS + pill signal + theme), AppFrame. - 8 halaman: dashboard, voice (orbital stage), media, messages (live feed + detail AI), moderation, analysis (search), recordings, + chatbot floating. - Command palette (Cmd/Ctrl+K) untuk navigasi cepat. - Server fetch di-page di-try/catch agar render graceful saat backend mati. Verified: tsc clean, next build 8/8 halaman, semua route 200.
This commit is contained in:
@@ -0,0 +1,231 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef } from "react";
|
||||
import * as THREE from "three";
|
||||
import { SIGNAL_RGB, type SignalTone } from "./ambient-context";
|
||||
|
||||
const VERT = /* glsl */ `
|
||||
varying vec2 vUv;
|
||||
void main(){
|
||||
vUv = uv;
|
||||
gl_Position = vec4(position.xy, 0.0, 1.0);
|
||||
}
|
||||
`;
|
||||
|
||||
const FRAG = /* glsl */ `
|
||||
precision mediump float;
|
||||
varying vec2 vUv;
|
||||
uniform float uTime;
|
||||
uniform vec3 uColor;
|
||||
uniform float uIntensity;
|
||||
uniform vec2 uRes;
|
||||
|
||||
float hash(vec2 p){ p=fract(p*vec2(123.34,456.21)); p+=dot(p,p+45.32); return fract(p.x*p.y); }
|
||||
float noise(vec2 p){
|
||||
vec2 i=floor(p); vec2 f=fract(p);
|
||||
float a=hash(i), b=hash(i+vec2(1.,0.)), c=hash(i+vec2(0.,1.)), d=hash(i+vec2(1.,1.));
|
||||
vec2 u=f*f*(3.-2.*f);
|
||||
return mix(mix(a,b,u.x),mix(c,d,u.x),u.y);
|
||||
}
|
||||
float fbm(vec2 p){
|
||||
float v=0.0, a=0.5;
|
||||
mat2 m=mat2(1.6,1.2,-1.2,1.6);
|
||||
for(int i=0;i<5;i++){ v+=a*noise(p); p=m*p; a*=0.5; }
|
||||
return v;
|
||||
}
|
||||
|
||||
void main(){
|
||||
vec2 uv=vUv;
|
||||
vec2 p=uv-0.5;
|
||||
p.x*=uRes.x/uRes.y;
|
||||
float t=uTime*0.04*(0.6+uIntensity);
|
||||
vec2 q=vec2(fbm(p*1.5+t), fbm(p*1.5-t+5.0));
|
||||
float f=fbm(p*2.2 + q*1.8 + t*0.5);
|
||||
vec2 c=vec2(sin(uTime*0.05)*0.25, cos(uTime*0.04)*0.18);
|
||||
float d=length(p-c);
|
||||
float glow=smoothstep(0.95,0.0,d)*0.5;
|
||||
float haze=(f*0.7+glow)*uIntensity;
|
||||
vec3 col=uColor*haze;
|
||||
float g=hash(uv*uRes+uTime)*0.035;
|
||||
col+=g;
|
||||
float vig=smoothstep(1.25,0.15,length(p));
|
||||
col*=0.35+0.65*vig;
|
||||
gl_FragColor=vec4(col,1.0);
|
||||
}
|
||||
`;
|
||||
|
||||
const MOTE_COUNT = 140;
|
||||
|
||||
export function AmbientCanvas({
|
||||
targetRef,
|
||||
}: {
|
||||
targetRef: React.MutableRefObject<{ tone: SignalTone; intensity: number }>;
|
||||
}) {
|
||||
const mountRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const mount = mountRef.current;
|
||||
if (!mount) return;
|
||||
|
||||
let renderer: THREE.WebGLRenderer;
|
||||
try {
|
||||
renderer = new THREE.WebGLRenderer({
|
||||
antialias: false,
|
||||
alpha: false,
|
||||
powerPreference: "high-performance",
|
||||
});
|
||||
} catch {
|
||||
return; // static CSS fallback remains
|
||||
}
|
||||
|
||||
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
||||
const dpr = Math.min(window.devicePixelRatio || 1, 1.5);
|
||||
renderer.setPixelRatio(dpr);
|
||||
renderer.setSize(mount.clientWidth, mount.clientHeight);
|
||||
mount.appendChild(renderer.domElement);
|
||||
renderer.domElement.style.width = "100%";
|
||||
renderer.domElement.style.height = "100%";
|
||||
renderer.domElement.style.display = "block";
|
||||
|
||||
const scene = new THREE.Scene();
|
||||
const camera = new THREE.OrthographicCamera(-1, 1, 1, -1, 0, 1);
|
||||
|
||||
const uniforms = {
|
||||
uTime: { value: 0 },
|
||||
uColor: { value: new THREE.Color(...SIGNAL_RGB.signal) },
|
||||
uIntensity: { value: 0.35 },
|
||||
uRes: { value: new THREE.Vector2(1, 1) },
|
||||
};
|
||||
|
||||
const quad = new THREE.Mesh(
|
||||
new THREE.PlaneGeometry(2, 2),
|
||||
new THREE.ShaderMaterial({
|
||||
vertexShader: VERT,
|
||||
fragmentShader: FRAG,
|
||||
uniforms,
|
||||
depthTest: false,
|
||||
depthWrite: false,
|
||||
}),
|
||||
);
|
||||
scene.add(quad);
|
||||
|
||||
// — Drifting motes —
|
||||
const positions = new Float32Array(MOTE_COUNT * 3);
|
||||
const speeds = new Float32Array(MOTE_COUNT);
|
||||
for (let i = 0; i < MOTE_COUNT; i++) {
|
||||
positions[i * 3] = (Math.random() - 0.5) * 2;
|
||||
positions[i * 3 + 1] = (Math.random() - 0.5) * 2;
|
||||
positions[i * 3 + 2] = 0;
|
||||
speeds[i] = 0.01 + Math.random() * 0.03;
|
||||
}
|
||||
const geo = new THREE.BufferGeometry();
|
||||
geo.setAttribute("position", new THREE.BufferAttribute(positions, 3));
|
||||
const moteMat = new THREE.PointsMaterial({
|
||||
size: 0.012,
|
||||
color: new THREE.Color(...SIGNAL_RGB.signal),
|
||||
transparent: true,
|
||||
opacity: 0.5,
|
||||
blending: THREE.AdditiveBlending,
|
||||
depthTest: false,
|
||||
depthWrite: false,
|
||||
});
|
||||
const motes = new THREE.Points(geo, moteMat);
|
||||
scene.add(motes);
|
||||
|
||||
const color = new THREE.Color();
|
||||
const target = new THREE.Color();
|
||||
let targetIntensity = 0.35;
|
||||
let intensity = 0.35;
|
||||
let raf = 0;
|
||||
let last = performance.now();
|
||||
let running = !reduce;
|
||||
|
||||
const resize = () => {
|
||||
const w = mount.clientWidth || 1;
|
||||
const h = mount.clientHeight || 1;
|
||||
renderer.setSize(w, h);
|
||||
uniforms.uRes.value.set(w * dpr, h * dpr);
|
||||
};
|
||||
const ro = new ResizeObserver(resize);
|
||||
ro.observe(mount);
|
||||
resize();
|
||||
|
||||
const onVisibility = () => {
|
||||
running = !document.hidden && !reduce;
|
||||
if (running) {
|
||||
last = performance.now();
|
||||
loop();
|
||||
}
|
||||
};
|
||||
document.addEventListener("visibilitychange", onVisibility);
|
||||
|
||||
const lerp = (a: number, b: number, t: number) => a + (b - a) * t;
|
||||
|
||||
const frame = (now: number) => {
|
||||
const dt = Math.min((now - last) / 1000, 0.05);
|
||||
last = now;
|
||||
uniforms.uTime.value += dt;
|
||||
|
||||
// Ease toward target signal/intensity each frame (no React re-render).
|
||||
const tgt = targetRef.current;
|
||||
target.set(...SIGNAL_RGB[tgt.tone]);
|
||||
color.lerp(target, 0.04);
|
||||
uniforms.uColor.value.copy(color);
|
||||
moteMat.color.copy(color);
|
||||
targetIntensity = 0.2 + tgt.intensity * 0.8;
|
||||
intensity = lerp(intensity, targetIntensity, 0.04);
|
||||
uniforms.uIntensity.value = intensity;
|
||||
|
||||
const pos = geo.attributes.position as THREE.BufferAttribute;
|
||||
for (let i = 0; i < MOTE_COUNT; i++) {
|
||||
let y = pos.getY(i) + speeds[i] * dt * (0.5 + tgt.intensity);
|
||||
if (y > 1.1) y = -1.1;
|
||||
pos.setY(i, y);
|
||||
}
|
||||
pos.needsUpdate = true;
|
||||
|
||||
renderer.render(scene, camera);
|
||||
if (running) raf = requestAnimationFrame(frame);
|
||||
};
|
||||
|
||||
const loop = () => {
|
||||
if (raf) cancelAnimationFrame(raf);
|
||||
last = performance.now();
|
||||
raf = requestAnimationFrame(frame);
|
||||
};
|
||||
|
||||
if (reduce) {
|
||||
// single static frame
|
||||
uniforms.uIntensity.value = 0.3;
|
||||
renderer.render(scene, camera);
|
||||
} else {
|
||||
loop();
|
||||
}
|
||||
|
||||
return () => {
|
||||
cancelAnimationFrame(raf);
|
||||
ro.disconnect();
|
||||
document.removeEventListener("visibilitychange", onVisibility);
|
||||
geo.dispose();
|
||||
moteMat.dispose();
|
||||
(quad.geometry as THREE.BufferGeometry).dispose();
|
||||
(quad.material as THREE.Material).dispose();
|
||||
renderer.dispose();
|
||||
if (renderer.domElement.parentNode === mount) {
|
||||
mount.removeChild(renderer.domElement);
|
||||
}
|
||||
};
|
||||
}, [targetRef]);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={mountRef}
|
||||
aria-hidden
|
||||
className="fixed inset-0 -z-10 overflow-hidden"
|
||||
style={{
|
||||
background:
|
||||
"radial-gradient(120% 90% at 50% 0%, oklch(0.2 0.04 70 / 0.5), oklch(0.1 0.015 70) 60%)",
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
"use client";
|
||||
|
||||
import { createContext, useCallback, useContext, useMemo, useRef, useState } from "react";
|
||||
import { AmbientCanvas } from "./ambient-canvas";
|
||||
|
||||
export type SignalTone = "signal" | "amber" | "vermilion";
|
||||
|
||||
/** sRGB triplets for the three semantic signals (matches globals.css). */
|
||||
export const SIGNAL_RGB: Record<SignalTone, [number, number, number]> = {
|
||||
signal: [0.42, 1.0, 0.52],
|
||||
amber: [1.0, 0.76, 0.28],
|
||||
vermilion: [1.0, 0.34, 0.28],
|
||||
};
|
||||
|
||||
export interface AmbientState {
|
||||
tone: SignalTone;
|
||||
/** 0..1 — drives haze density + drift speed (e.g. server load). */
|
||||
intensity: number;
|
||||
label?: string;
|
||||
}
|
||||
|
||||
export interface AmbientControls {
|
||||
set: (tone: SignalTone, intensity?: number, label?: string) => void;
|
||||
reset: () => void;
|
||||
state: AmbientState;
|
||||
}
|
||||
|
||||
const DEFAULT: AmbientState = { tone: "signal", intensity: 0.35, label: "nominal" };
|
||||
|
||||
const AmbientContext = createContext<AmbientControls | null>(null);
|
||||
|
||||
/**
|
||||
* Holds the live ambient signal. The canvas reads `targetRef` inside its
|
||||
* render loop (no React re-render per frame); `state` is mirrored into React
|
||||
* only so small UI bits (topbar) can reflect the current tone.
|
||||
*/
|
||||
export function AmbientProvider({ children }: { children: React.ReactNode }) {
|
||||
const targetRef = useRef<AmbientState>({ ...DEFAULT });
|
||||
const [state, setState] = useState<AmbientState>(DEFAULT);
|
||||
|
||||
const set = useCallback((tone: SignalTone, intensity?: number, label?: string) => {
|
||||
targetRef.current = {
|
||||
tone,
|
||||
intensity: intensity ?? targetRef.current.intensity,
|
||||
label: label ?? targetRef.current.label,
|
||||
};
|
||||
setState({ ...targetRef.current });
|
||||
}, []);
|
||||
|
||||
const reset = useCallback(() => {
|
||||
targetRef.current = { ...DEFAULT };
|
||||
setState({ ...DEFAULT });
|
||||
}, []);
|
||||
|
||||
const value = useMemo<AmbientControls>(
|
||||
() => ({ set, reset, state }),
|
||||
[set, reset, state],
|
||||
);
|
||||
|
||||
return (
|
||||
<AmbientContext.Provider value={value}>
|
||||
<AmbientCanvas targetRef={targetRef} />
|
||||
{children}
|
||||
</AmbientContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useAmbient(): AmbientControls {
|
||||
const ctx = useContext(AmbientContext);
|
||||
if (!ctx) throw new Error("useAmbient must be used within <AmbientProvider>");
|
||||
return ctx;
|
||||
}
|
||||
@@ -1,140 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef } from "react";
|
||||
|
||||
/**
|
||||
* AmbientField — full-bleed WebGL particle haze that reacts to live data.
|
||||
*
|
||||
* No container, no grid, no chrome. Pure atmosphere: a slow-drifting field of
|
||||
* points whose motion density tracks server load, and whose color shifts with
|
||||
* the latest moderation signal (clean → lime, warn → amber, flagged → vermilion).
|
||||
*
|
||||
* This is the background of the new dashboard — everything else floats over it.
|
||||
*/
|
||||
|
||||
type Signal = "neutral" | "signal" | "amber" | "vermilion";
|
||||
|
||||
const SIGNAL_RGB: Record<Signal, [number, number, number]> = {
|
||||
neutral: [0.52, 0.49, 0.46],
|
||||
signal: [0.78, 0.85, 0.62],
|
||||
amber: [0.95, 0.78, 0.42],
|
||||
vermilion: [0.86, 0.32, 0.28],
|
||||
};
|
||||
|
||||
interface AmbientFieldProps {
|
||||
/** 0..1 — drives particle drift speed + density. */
|
||||
load?: number;
|
||||
/** Latest moderation signal — tints the haze. */
|
||||
signal?: Signal;
|
||||
}
|
||||
|
||||
export function AmbientField({
|
||||
load = 0.3,
|
||||
signal = "signal",
|
||||
}: AmbientFieldProps) {
|
||||
const canvasRef = useRef<HTMLCanvasElement | null>(null);
|
||||
const loadRef = useRef(load);
|
||||
const signalRef = useRef<[number, number, number]>(SIGNAL_RGB[signal]);
|
||||
const rafRef = useRef<number>(0);
|
||||
|
||||
useEffect(() => {
|
||||
loadRef.current = load;
|
||||
}, [load]);
|
||||
|
||||
useEffect(() => {
|
||||
signalRef.current = SIGNAL_RGB[signal];
|
||||
}, [signal]);
|
||||
|
||||
useEffect(() => {
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) return;
|
||||
const ctx = canvas.getContext("2d");
|
||||
if (!ctx) return;
|
||||
|
||||
let w = 0;
|
||||
let h = 0;
|
||||
const dpr = Math.min(window.devicePixelRatio || 1, 2);
|
||||
|
||||
const resize = () => {
|
||||
w = canvas.clientWidth;
|
||||
h = canvas.clientHeight;
|
||||
canvas.width = w * dpr;
|
||||
canvas.height = h * dpr;
|
||||
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
||||
};
|
||||
resize();
|
||||
const ro = new ResizeObserver(resize);
|
||||
ro.observe(canvas);
|
||||
|
||||
// Particle haze
|
||||
const N = 90;
|
||||
const pts = Array.from({ length: N }, () => ({
|
||||
x: Math.random(),
|
||||
y: Math.random(),
|
||||
z: Math.random() * 0.8 + 0.2,
|
||||
vx: (Math.random() - 0.5) * 0.0004,
|
||||
vy: (Math.random() - 0.5) * 0.0004,
|
||||
r: Math.random() * 1.5 + 0.5,
|
||||
}));
|
||||
|
||||
const draw = () => {
|
||||
const [cr, cg, cb] = signalRef.current;
|
||||
const speed = 0.4 + loadRef.current * 1.6;
|
||||
|
||||
// Trail fade
|
||||
ctx.fillStyle = "rgba(244, 240, 234, 0.06)";
|
||||
ctx.fillRect(0, 0, w, h);
|
||||
|
||||
for (const p of pts) {
|
||||
p.x += p.vx * speed;
|
||||
p.y += p.vy * speed;
|
||||
if (p.x < 0) p.x += 1;
|
||||
if (p.x > 1) p.x -= 1;
|
||||
if (p.y < 0) p.y += 1;
|
||||
if (p.y > 1) p.y -= 1;
|
||||
|
||||
const px = p.x * w;
|
||||
const py = p.y * h;
|
||||
const rad = p.r * p.z * (1 + loadRef.current);
|
||||
const alpha = 0.05 + p.z * 0.12;
|
||||
ctx.beginPath();
|
||||
ctx.arc(px, py, rad, 0, Math.PI * 2);
|
||||
ctx.fillStyle = `rgba(${Math.round(cr * 255)}, ${Math.round(cg * 255)}, ${Math.round(cb * 255)}, ${alpha})`;
|
||||
ctx.fill();
|
||||
}
|
||||
|
||||
// Faint vignette glow center
|
||||
const grad = ctx.createRadialGradient(
|
||||
w / 2,
|
||||
h / 2,
|
||||
0,
|
||||
w / 2,
|
||||
h / 2,
|
||||
Math.max(w, h) * 0.6,
|
||||
);
|
||||
grad.addColorStop(
|
||||
0,
|
||||
`rgba(${Math.round(cr * 255)}, ${Math.round(cg * 255)}, ${Math.round(cb * 255)}, 0.03)`,
|
||||
);
|
||||
grad.addColorStop(1, "rgba(0,0,0,0)");
|
||||
ctx.fillStyle = grad;
|
||||
ctx.fillRect(0, 0, w, h);
|
||||
|
||||
rafRef.current = requestAnimationFrame(draw);
|
||||
};
|
||||
rafRef.current = requestAnimationFrame(draw);
|
||||
|
||||
return () => {
|
||||
cancelAnimationFrame(rafRef.current);
|
||||
ro.disconnect();
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
aria-hidden
|
||||
className="pointer-events-none absolute inset-0 -z-10 h-full w-full"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,146 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { Loader2, Search, Sparkles } from "lucide-react";
|
||||
import { useCallback, useState } from "react";
|
||||
import { Avatar } from "@/components/primitives/avatar";
|
||||
import { Badge } from "@/components/primitives/badge";
|
||||
import { Button } from "@/components/primitives/button";
|
||||
import { Input } from "@/components/primitives/input";
|
||||
import { Progress } from "@/components/primitives/progress";
|
||||
import { EmptyState, LoadingSkeleton } from "@/components/shared";
|
||||
import { useMessageSearch } from "@/hooks";
|
||||
import { renderMessageContent, safeParseJsonArray } from "@/lib/format";
|
||||
|
||||
export function SearchPanel() {
|
||||
const [query, setQuery] = useState("");
|
||||
const [enabled, setEnabled] = useState(false);
|
||||
|
||||
const { data: results, isValidating: isFetching } = useMessageSearch(
|
||||
query,
|
||||
enabled,
|
||||
);
|
||||
|
||||
const handleSearch = useCallback(() => {
|
||||
if (!query.trim()) return;
|
||||
setEnabled(true);
|
||||
}, [query]);
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<div className="flex gap-2">
|
||||
<div className="relative flex-1">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 size-4 text-[var(--color-ink-soft)]" />
|
||||
<Input
|
||||
placeholder="Search message content, AI flags, analysis text…"
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
onKeyDown={(e) => e.key === "Enter" && handleSearch()}
|
||||
className="pl-9 h-9"
|
||||
/>
|
||||
</div>
|
||||
<Button onClick={handleSearch} disabled={!query.trim() || isFetching}>
|
||||
{isFetching && <Loader2 className="size-4 animate-spin mr-1.5" />}
|
||||
Search
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{isFetching ? (
|
||||
<LoadingSkeleton count={5} height="h-28" />
|
||||
) : results !== undefined ? (
|
||||
<>
|
||||
<p className="text-sm text-[var(--color-ink-soft)]">
|
||||
Found {results.length} result{results.length !== 1 ? "s" : ""}
|
||||
</p>
|
||||
{results.length === 0 ? (
|
||||
<EmptyState
|
||||
icon={Search}
|
||||
title="No messages found matching your query."
|
||||
/>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{results.map((msg) => (
|
||||
<div key={msg.id} className="surface p-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<Avatar
|
||||
src={msg.avatar_url ?? undefined}
|
||||
name={msg.username}
|
||||
size={32}
|
||||
className="mt-0.5 shrink-0"
|
||||
/>
|
||||
<div className="flex-1 min-w-0 space-y-2">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="text-sm font-medium text-[var(--color-ink)]">
|
||||
{msg.username}
|
||||
</span>
|
||||
<span className="text-xs text-[var(--color-ink-soft)]">
|
||||
{msg.created_at
|
||||
? new Date(msg.created_at).toLocaleString()
|
||||
: ""}
|
||||
</span>
|
||||
{msg.ai_status && (
|
||||
<Badge
|
||||
tone={
|
||||
msg.ai_status === "clean"
|
||||
? "signal"
|
||||
: msg.ai_status === "flagged"
|
||||
? "vermilion"
|
||||
: "neutral"
|
||||
}
|
||||
>
|
||||
{msg.ai_status}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-sm leading-relaxed text-[var(--color-ink)]">
|
||||
{renderMessageContent(
|
||||
msg.edited_content ?? msg.content,
|
||||
msg.metadata,
|
||||
)}
|
||||
</p>
|
||||
{msg.ai_moderation_flags &&
|
||||
msg.ai_moderation_flags !== "[]" && (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{safeParseJsonArray(msg.ai_moderation_flags).map(
|
||||
(flag) => (
|
||||
<Badge key={flag} tone="vermilion">
|
||||
{flag}
|
||||
</Badge>
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{msg.ai_analysis && (
|
||||
<p className="text-xs text-[var(--color-ink-soft)] italic line-clamp-2 leading-relaxed">
|
||||
<Sparkles className="size-3 inline mr-1" />
|
||||
{msg.ai_analysis}
|
||||
</p>
|
||||
)}
|
||||
{msg.ai_confidence != null && (
|
||||
<div className="flex items-center gap-2 max-w-40">
|
||||
<Progress value={msg.ai_confidence * 100} />
|
||||
<span className="text-[11px] text-[var(--color-ink-soft)] tabular-nums shrink-0">
|
||||
{(msg.ai_confidence * 100).toFixed(0)}%
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<div className="flex flex-col items-center justify-center py-24 text-center">
|
||||
<Search className="size-12 text-[var(--color-ink-soft)] mb-4" />
|
||||
<p className="text-sm text-[var(--color-ink-soft)]">
|
||||
Enter a search query to find messages across all channels.
|
||||
</p>
|
||||
<p className="text-xs text-[var(--color-ink-soft)] mt-1">
|
||||
Searches message content, AI flags, and analysis text.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,83 +1,48 @@
|
||||
"use client";
|
||||
|
||||
import { motion, useReducedMotion } from "motion/react";
|
||||
import { useId } from "react";
|
||||
|
||||
export interface AreaPoint {
|
||||
label: string;
|
||||
value: number;
|
||||
}
|
||||
|
||||
export interface AreaActivityProps {
|
||||
data: AreaPoint[];
|
||||
height?: number;
|
||||
stroke?: string;
|
||||
className?: string;
|
||||
label?: string;
|
||||
}
|
||||
import type { DailyActivityPoint } from "@/lib/types";
|
||||
|
||||
/**
|
||||
* Dual-area activity chart: total messages (signal) vs flagged (vermilion).
|
||||
* Pure SVG, scales to container. Includes a 7-day trailing window hint.
|
||||
*/
|
||||
export function AreaActivity({
|
||||
data,
|
||||
height = 160,
|
||||
stroke = "var(--color-signal)",
|
||||
className,
|
||||
label,
|
||||
}: AreaActivityProps) {
|
||||
const id = useId().replace(/:/g, "");
|
||||
const reduce = useReducedMotion();
|
||||
const width = 600;
|
||||
if (data.length === 0)
|
||||
return <div className={className} style={{ height }} />;
|
||||
daily,
|
||||
height = 200,
|
||||
}: {
|
||||
daily: DailyActivityPoint[];
|
||||
height?: number;
|
||||
}) {
|
||||
const w = 720;
|
||||
const pad = 8;
|
||||
const n = daily.length;
|
||||
const max = Math.max(...daily.map((d) => d.messages), 1);
|
||||
const x = (i: number) => pad + (i / Math.max(n - 1, 1)) * (w - pad * 2);
|
||||
const y = (v: number) => height - pad - (v / max) * (height - pad * 2);
|
||||
|
||||
const max = Math.max(...data.map((d) => d.value), 1);
|
||||
const stepX = width / Math.max(data.length - 1, 1);
|
||||
const pts = data.map((d, i) => {
|
||||
const x = i * stepX;
|
||||
const y = height - (d.value / max) * (height - 10) - 5;
|
||||
return [x, y] as const;
|
||||
});
|
||||
const line = pts
|
||||
.map(
|
||||
(p, i) => `${i === 0 ? "M" : "L"}${p[0].toFixed(1)},${p[1].toFixed(1)}`,
|
||||
)
|
||||
.join(" ");
|
||||
const area = `${line} L${width},${height} L0,${height} Z`;
|
||||
const pathLen = 1400;
|
||||
const msgLine = daily.map((d, i) => `${i === 0 ? "M" : "L"}${x(i).toFixed(1)},${y(d.messages).toFixed(1)}`).join(" ");
|
||||
const flagLine = daily.map((d, i) => `${i === 0 ? "M" : "L"}${x(i).toFixed(1)},${y(d.flagged).toFixed(1)}`).join(" ");
|
||||
const msgArea = `${msgLine} L${x(n - 1).toFixed(1)},${height - pad} L${x(0).toFixed(1)},${height - pad} Z`;
|
||||
|
||||
return (
|
||||
<svg
|
||||
viewBox={`0 0 ${width} ${height}`}
|
||||
className={className}
|
||||
style={{ width: "100%", height }}
|
||||
preserveAspectRatio="none"
|
||||
role="img"
|
||||
aria-label={label ?? "Activity chart"}
|
||||
>
|
||||
<svg viewBox={`0 0 ${w} ${height}`} preserveAspectRatio="none" className="w-full" style={{ height }}>
|
||||
<defs>
|
||||
<linearGradient id={`area-${id}`} x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" stopColor={stroke} stopOpacity="0.32" />
|
||||
<stop offset="100%" stopColor={stroke} stopOpacity="0.02" />
|
||||
<linearGradient id="area-msg" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" stopColor="var(--color-signal)" stopOpacity="0.3" />
|
||||
<stop offset="100%" stopColor="var(--color-signal)" stopOpacity="0" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<motion.path
|
||||
d={area}
|
||||
fill={`url(#area-${id})`}
|
||||
initial={reduce ? false : { pathLength: 0, opacity: 0.4 }}
|
||||
animate={{ pathLength: 1, opacity: 1 }}
|
||||
transition={{ duration: 0.9, ease: [0.22, 1, 0.36, 1] }}
|
||||
/>
|
||||
<motion.path
|
||||
d={line}
|
||||
fill="none"
|
||||
stroke={stroke}
|
||||
strokeWidth={2}
|
||||
strokeLinejoin="round"
|
||||
strokeLinecap="round"
|
||||
initial={reduce ? false : { pathLength: 0 }}
|
||||
animate={{ pathLength: 1 }}
|
||||
transition={{ duration: 0.9, ease: [0.22, 1, 0.36, 1] }}
|
||||
style={{ strokeDasharray: pathLen }}
|
||||
/>
|
||||
{[0.25, 0.5, 0.75].map((g) => (
|
||||
<line key={g} x1={pad} x2={w - pad} y1={height * g} y2={height * g} stroke="var(--color-hairline)" strokeWidth={1} vectorEffect="non-scaling-stroke" />
|
||||
))}
|
||||
<path d={msgArea} fill="url(#area-msg)" />
|
||||
<path d={msgLine} fill="none" stroke="var(--color-signal)" strokeWidth={2} vectorEffect="non-scaling-stroke" />
|
||||
<path d={flagLine} fill="none" stroke="var(--color-vermilion)" strokeWidth={1.5} vectorEffect="non-scaling-stroke" strokeDasharray="3 3" />
|
||||
{daily.map((d, i) =>
|
||||
i % 2 === 0 ? (
|
||||
<text key={d.day} x={x(i)} y={height - 1} fill="var(--color-ink-faint)" fontSize={9} textAnchor="middle" className="mono">
|
||||
{d.day.slice(5)}
|
||||
</text>
|
||||
) : null,
|
||||
)}
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
/** Stacked donut for moderation overview / composition. */
|
||||
export function Donut({
|
||||
segments,
|
||||
size = 132,
|
||||
thickness = 14,
|
||||
centerLabel,
|
||||
centerSub,
|
||||
}: {
|
||||
segments: { value: number; color: string; label: string }[];
|
||||
size?: number;
|
||||
thickness?: number;
|
||||
centerLabel?: string;
|
||||
centerSub?: string;
|
||||
}) {
|
||||
const total = segments.reduce((s, x) => s + x.value, 0) || 1;
|
||||
const r = size / 2 - thickness / 2;
|
||||
const c = 2 * Math.PI * r;
|
||||
let offset = 0;
|
||||
return (
|
||||
<div className="relative inline-flex items-center justify-center" style={{ width: size, height: size }}>
|
||||
<svg width={size} height={size} className="-rotate-90">
|
||||
<circle cx={size / 2} cy={size / 2} r={r} fill="none" stroke="var(--color-hairline)" strokeWidth={thickness} />
|
||||
{segments.map((s, i) => {
|
||||
const len = (s.value / total) * c;
|
||||
const el = (
|
||||
<circle
|
||||
key={i}
|
||||
cx={size / 2}
|
||||
cy={size / 2}
|
||||
r={r}
|
||||
fill="none"
|
||||
stroke={s.color}
|
||||
strokeWidth={thickness}
|
||||
strokeDasharray={`${len} ${c - len}`}
|
||||
strokeDashoffset={-offset}
|
||||
style={{ transition: "stroke-dashoffset 0.6s ease" }}
|
||||
/>
|
||||
);
|
||||
offset += len;
|
||||
return el;
|
||||
})}
|
||||
</svg>
|
||||
{(centerLabel || centerSub) && (
|
||||
<div className="absolute inset-0 flex flex-col items-center justify-center">
|
||||
{centerLabel && <span className="display text-lg">{centerLabel}</span>}
|
||||
{centerSub && <span className="mono text-[0.6rem] text-ink-faint">{centerSub}</span>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export { Sparkline } from "./sparkline";
|
||||
export { AreaActivity } from "./area-activity";
|
||||
export { RadialGauge } from "./radial-gauge";
|
||||
export { Donut } from "./donut";
|
||||
export { Equalizer } from "./waveform";
|
||||
@@ -1,90 +1,45 @@
|
||||
"use client";
|
||||
|
||||
import { motion, useReducedMotion } from "motion/react";
|
||||
import { useId } from "react";
|
||||
|
||||
export interface RadialGaugeProps {
|
||||
/** 0..1 health ratio */
|
||||
value: number;
|
||||
size?: number;
|
||||
label?: string;
|
||||
sublabel?: string;
|
||||
tone?: "signal" | "amber" | "vermilion";
|
||||
}
|
||||
|
||||
const toneColor = {
|
||||
signal: "var(--color-signal)",
|
||||
amber: "var(--color-amber)",
|
||||
vermilion: "var(--color-vermilion)",
|
||||
};
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
/** Circular progress gauge. value 0..1. */
|
||||
export function RadialGauge({
|
||||
value,
|
||||
size = 160,
|
||||
label,
|
||||
sublabel,
|
||||
tone = "signal",
|
||||
}: RadialGaugeProps) {
|
||||
const id = useId().replace(/:/g, "");
|
||||
const reduce = useReducedMotion();
|
||||
const stroke = 12;
|
||||
const r = (size - stroke) / 2;
|
||||
size = 120,
|
||||
}: {
|
||||
value: number;
|
||||
label: string;
|
||||
sublabel?: string;
|
||||
tone?: "signal" | "amber" | "vermilion";
|
||||
size?: number;
|
||||
}) {
|
||||
const v = Math.max(0, Math.min(1, value));
|
||||
const stroke = tone === "vermilion" ? "var(--color-vermilion)" : tone === "amber" ? "var(--color-amber)" : "var(--color-signal)";
|
||||
const r = size / 2 - 10;
|
||||
const c = 2 * Math.PI * r;
|
||||
const pct = Math.max(0, Math.min(1, value));
|
||||
const dash = c * pct;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="relative inline-flex items-center justify-center"
|
||||
style={{ width: size, height: size }}
|
||||
>
|
||||
<svg
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox={`0 0 ${size} ${size}`}
|
||||
className="-rotate-90"
|
||||
role="img"
|
||||
aria-label={`${Math.round(pct * 100)}% ${label ?? "gauge"}`}
|
||||
>
|
||||
<div className="relative inline-flex items-center justify-center" style={{ width: size, height: size }}>
|
||||
<svg width={size} height={size} className="-rotate-90">
|
||||
<circle cx={size / 2} cy={size / 2} r={r} fill="none" stroke="var(--color-hairline)" strokeWidth={8} />
|
||||
<circle
|
||||
cx={size / 2}
|
||||
cy={size / 2}
|
||||
r={r}
|
||||
fill="none"
|
||||
stroke="var(--color-hairline)"
|
||||
strokeWidth={stroke}
|
||||
/>
|
||||
<motion.circle
|
||||
cx={size / 2}
|
||||
cy={size / 2}
|
||||
r={r}
|
||||
fill="none"
|
||||
stroke={toneColor[tone]}
|
||||
strokeWidth={stroke}
|
||||
stroke={stroke}
|
||||
strokeWidth={8}
|
||||
strokeLinecap="round"
|
||||
strokeDasharray={c}
|
||||
initial={reduce ? false : { strokeDashoffset: c }}
|
||||
animate={{ strokeDashoffset: c - dash }}
|
||||
transition={{ duration: 1, ease: [0.22, 1, 0.36, 1] }}
|
||||
strokeDashoffset={c * (1 - v)}
|
||||
style={{ transition: "stroke-dashoffset 0.6s ease", filter: `drop-shadow(0 0 6px ${stroke})` }}
|
||||
/>
|
||||
</svg>
|
||||
<div className="absolute inset-0 flex flex-col items-center justify-center text-center">
|
||||
<span
|
||||
className="display text-2xl mono"
|
||||
style={{ color: toneColor[tone] }}
|
||||
>
|
||||
{Math.round(pct * 100)}%
|
||||
<div className="absolute inset-0 flex flex-col items-center justify-center">
|
||||
<span className={cn("display text-xl", tone === "vermilion" && "text-vermilion", tone === "amber" && "text-amber", tone === "signal" && "text-signal")}>
|
||||
{label}
|
||||
</span>
|
||||
{label && (
|
||||
<span className="text-[11px] font-medium text-[var(--color-ink-soft)] uppercase tracking-wide">
|
||||
{label}
|
||||
</span>
|
||||
)}
|
||||
{sublabel && (
|
||||
<span className="text-[10px] text-[var(--color-ink-soft)]/70">
|
||||
{sublabel}
|
||||
</span>
|
||||
)}
|
||||
{sublabel && <span className="mono text-[0.6rem] text-ink-faint">{sublabel}</span>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,56 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export interface RibbonSegment {
|
||||
id: string;
|
||||
label: string;
|
||||
value: number; // relative duration
|
||||
tone?: "signal" | "amber" | "vermilion" | "neutral";
|
||||
}
|
||||
|
||||
const toneClass = {
|
||||
signal: "bg-[var(--color-signal)]",
|
||||
amber: "bg-[var(--color-amber)]",
|
||||
vermilion: "bg-[var(--color-vermilion)]",
|
||||
neutral: "bg-[var(--color-ink-soft)]/40",
|
||||
};
|
||||
|
||||
export interface SessionRibbonProps {
|
||||
segments: RibbonSegment[];
|
||||
className?: string;
|
||||
height?: number;
|
||||
}
|
||||
|
||||
export function SessionRibbon({
|
||||
segments,
|
||||
className,
|
||||
height = 28,
|
||||
}: SessionRibbonProps) {
|
||||
const total = segments.reduce((s, x) => s + x.value, 0) || 1;
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex w-full gap-0.5 overflow-hidden rounded-[var(--radius-r-control)]",
|
||||
className,
|
||||
)}
|
||||
style={{ height }}
|
||||
>
|
||||
{segments.map((s) => (
|
||||
<div
|
||||
key={s.id}
|
||||
className={cn(
|
||||
"group relative flex items-center justify-center rounded-sm transition-all",
|
||||
toneClass[s.tone ?? "signal"],
|
||||
)}
|
||||
style={{ width: `${(s.value / total) * 100}%` }}
|
||||
title={`${s.label}: ${s.value}`}
|
||||
>
|
||||
<span className="pointer-events-none absolute inset-x-0 -top-6 hidden whitespace-nowrap rounded bg-[var(--color-ink)] px-1.5 py-0.5 text-[10px] text-[var(--color-canvas)] group-hover:block">
|
||||
{s.label}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,77 +1,42 @@
|
||||
"use client";
|
||||
|
||||
import { useId } from "react";
|
||||
|
||||
export interface SparklineProps {
|
||||
data: number[];
|
||||
width?: number;
|
||||
height?: number;
|
||||
stroke?: string;
|
||||
className?: string;
|
||||
fill?: boolean;
|
||||
}
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
/** Minimal sparkline. Pure SVG, scales to container width. */
|
||||
export function Sparkline({
|
||||
data,
|
||||
width = 120,
|
||||
height = 36,
|
||||
stroke = "var(--color-signal)",
|
||||
values,
|
||||
className,
|
||||
stroke = "var(--color-signal)",
|
||||
fill = true,
|
||||
}: SparklineProps) {
|
||||
const id = useId().replace(/:/g, "");
|
||||
if (data.length < 2)
|
||||
return (
|
||||
<svg
|
||||
width={width}
|
||||
height={height}
|
||||
className={className}
|
||||
role="img"
|
||||
aria-label="No data"
|
||||
/>
|
||||
);
|
||||
|
||||
const min = Math.min(...data);
|
||||
const max = Math.max(...data);
|
||||
height = 40,
|
||||
}: {
|
||||
values: number[];
|
||||
className?: string;
|
||||
stroke?: string;
|
||||
fill?: boolean;
|
||||
height?: number;
|
||||
}) {
|
||||
if (values.length === 0) return null;
|
||||
const w = 100;
|
||||
const max = Math.max(...values, 1);
|
||||
const min = Math.min(...values, 0);
|
||||
const span = max - min || 1;
|
||||
const stepX = width / (data.length - 1);
|
||||
const pts = data.map((v, i) => {
|
||||
const x = i * stepX;
|
||||
const pts = values.map((v, i) => {
|
||||
const x = (i / (values.length - 1)) * w;
|
||||
const y = height - ((v - min) / span) * (height - 4) - 2;
|
||||
return [x, y] as const;
|
||||
});
|
||||
const line = pts
|
||||
.map(
|
||||
(p, i) => `${i === 0 ? "M" : "L"}${p[0].toFixed(1)},${p[1].toFixed(1)}`,
|
||||
)
|
||||
.join(" ");
|
||||
const area = `${line} L${width},${height} L0,${height} Z`;
|
||||
|
||||
const line = pts.map((p, i) => `${i === 0 ? "M" : "L"}${p[0].toFixed(2)},${p[1].toFixed(2)}`).join(" ");
|
||||
const area = `${line} L${w},${height} L0,${height} Z`;
|
||||
const id = `spark-${stroke.replace(/[^a-z0-9]/gi, "")}`;
|
||||
return (
|
||||
<svg
|
||||
width={width}
|
||||
height={height}
|
||||
viewBox={`0 0 ${width} ${height}`}
|
||||
className={className}
|
||||
preserveAspectRatio="none"
|
||||
role="img"
|
||||
aria-label="Trend sparkline"
|
||||
>
|
||||
<svg viewBox={`0 0 ${w} ${height}`} preserveAspectRatio="none" className={cn("w-full", className)} style={{ height }}>
|
||||
<defs>
|
||||
<linearGradient id={`spark-${id}`} x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" stopColor={stroke} stopOpacity="0.28" />
|
||||
<linearGradient id={id} x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" stopColor={stroke} stopOpacity="0.35" />
|
||||
<stop offset="100%" stopColor={stroke} stopOpacity="0" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
{fill && <path d={area} fill={`url(#spark-${id})`} />}
|
||||
<path
|
||||
d={line}
|
||||
fill="none"
|
||||
stroke={stroke}
|
||||
strokeWidth={1.6}
|
||||
strokeLinejoin="round"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
{fill && <path d={area} fill={`url(#${id})`} />}
|
||||
<path d={line} fill="none" stroke={stroke} strokeWidth={1.5} vectorEffect="non-scaling-stroke" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,76 +1,37 @@
|
||||
"use client";
|
||||
|
||||
import { motion, useReducedMotion } from "motion/react";
|
||||
import { useMemo } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export interface WaveformProps {
|
||||
seed: string | number;
|
||||
bars?: number;
|
||||
height?: number;
|
||||
className?: string;
|
||||
tone?: "signal" | "amber" | "vermilion";
|
||||
}
|
||||
|
||||
// deterministic pseudo-random from seed so the shape is stable per recording
|
||||
function hashSeed(seed: string | number): number {
|
||||
const s = String(seed);
|
||||
let h = 2166136261;
|
||||
for (let i = 0; i < s.length; i++) {
|
||||
h ^= s.charCodeAt(i);
|
||||
h = Math.imul(h, 16777619);
|
||||
}
|
||||
return h >>> 0;
|
||||
}
|
||||
|
||||
export function Waveform({
|
||||
seed,
|
||||
bars = 40,
|
||||
height = 40,
|
||||
/** Live equalizer bars. `bars` are 0..1 levels. */
|
||||
export function Equalizer({
|
||||
bars,
|
||||
color = "var(--color-signal)",
|
||||
className,
|
||||
tone = "signal",
|
||||
}: WaveformProps) {
|
||||
const reduce = useReducedMotion();
|
||||
const values = useMemo(() => {
|
||||
let state = hashSeed(seed) || 1;
|
||||
const out: number[] = [];
|
||||
for (let i = 0; i < bars; i++) {
|
||||
state = (Math.imul(state, 1103515245) + 12345) >>> 0;
|
||||
const r = (state % 1000) / 1000;
|
||||
// envelope: louder in the middle, quieter at edges
|
||||
const env = Math.sin((i / (bars - 1)) * Math.PI);
|
||||
out.push(0.18 + r * 0.82 * (0.4 + env * 0.6));
|
||||
}
|
||||
return out;
|
||||
}, [seed, bars]);
|
||||
|
||||
const color = {
|
||||
signal: "var(--color-signal)",
|
||||
amber: "var(--color-amber)",
|
||||
vermilion: "var(--color-vermilion)",
|
||||
}[tone];
|
||||
|
||||
}: {
|
||||
bars: number[];
|
||||
color?: string;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className={cn("flex items-end gap-[2px]", className)}
|
||||
style={{ height }}
|
||||
aria-hidden
|
||||
>
|
||||
{values.map((v, i) => (
|
||||
<motion.span
|
||||
key={i}
|
||||
className="flex-1 rounded-[2px]"
|
||||
style={{ background: color, height: `${Math.max(8, v * 100)}%` }}
|
||||
initial={reduce ? false : { scaleY: 0.2, opacity: 0 }}
|
||||
animate={{ scaleY: 1, opacity: 1 }}
|
||||
whileHover={{ scaleY: 1.15 }}
|
||||
transition={{
|
||||
duration: 0.3,
|
||||
delay: reduce ? 0 : i * 0.006,
|
||||
ease: [0.22, 1, 0.36, 1],
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
<div className={cn("flex h-10 items-end gap-[3px]", className)}>
|
||||
{bars.length === 0 ? (
|
||||
<div className="flex w-full items-end gap-[3px]">
|
||||
{Array.from({ length: 28 }).map((_, i) => (
|
||||
<span key={i} className="flex-1 rounded-full bg-white/10" style={{ height: "12%" }} />
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
bars.map((b, i) => (
|
||||
<span
|
||||
key={i}
|
||||
className="flex-1 rounded-full"
|
||||
style={{
|
||||
height: `${Math.max(6, b * 100)}%`,
|
||||
background: color,
|
||||
boxShadow: b > 0.05 ? `0 0 8px ${color}` : "none",
|
||||
transition: "height 90ms linear",
|
||||
}}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,161 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { Eraser, Send, Sparkles } from "lucide-react";
|
||||
import { useEffect, useRef } from "react";
|
||||
import { useChatbot } from "./chatbot-context";
|
||||
|
||||
interface ChatPanelProps {
|
||||
inputRef?: React.RefObject<HTMLInputElement | null>;
|
||||
}
|
||||
|
||||
function formatTime(ts: string): string {
|
||||
const d = new Date(ts);
|
||||
if (Number.isNaN(d.getTime())) return "";
|
||||
return d.toLocaleTimeString("id-ID", {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
});
|
||||
}
|
||||
|
||||
const SUGGESTIONS = [
|
||||
"Gimana suasana server hari ini?",
|
||||
"Channel mana yang paling ramai?",
|
||||
"Total pesan di server?",
|
||||
"Ada pesan bermasalah?",
|
||||
];
|
||||
|
||||
export function ChatPanel({ inputRef: externalInputRef }: ChatPanelProps) {
|
||||
const { messages, sendMessage, clearMessages, isTyping } = useChatbot();
|
||||
const listRef = useRef<HTMLDivElement>(null);
|
||||
const internalInputRef = useRef<HTMLInputElement>(null);
|
||||
const inputRef = externalInputRef ?? internalInputRef;
|
||||
|
||||
// Auto-scroll to bottom on new messages
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: re-run on message arrival; scroll is a visual effect keyed on new content
|
||||
useEffect(() => {
|
||||
if (listRef.current) {
|
||||
listRef.current.scrollTop = listRef.current.scrollHeight;
|
||||
}
|
||||
}, [messages, isTyping]);
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
const input = inputRef.current;
|
||||
if (!input || !input.value.trim() || isTyping) return;
|
||||
sendMessage(input.value);
|
||||
input.value = "";
|
||||
};
|
||||
|
||||
const handleSuggestion = (text: string) => {
|
||||
if (isTyping) return;
|
||||
sendMessage(text);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
{/* Chat messages */}
|
||||
<div
|
||||
ref={listRef}
|
||||
className="flex-1 space-y-1.5 overflow-y-auto px-2 py-2"
|
||||
>
|
||||
{messages.length === 0 ? (
|
||||
<div className="flex h-full flex-col justify-center gap-3 px-3 text-center">
|
||||
<p className="text-[11px] text-[var(--color-ink-soft)]">
|
||||
Halo! 👋 Aku tau soal server ini — pesan, flag, dan aktivitas.
|
||||
</p>
|
||||
<div className="flex flex-wrap justify-center gap-1.5">
|
||||
{SUGGESTIONS.map((s) => (
|
||||
<button
|
||||
key={s}
|
||||
type="button"
|
||||
onClick={() => handleSuggestion(s)}
|
||||
disabled={isTyping}
|
||||
className="flex items-center gap-1 rounded-full border border-[var(--color-hairline)] bg-[var(--color-surface-2)] px-2.5 py-1 text-[10px] text-[var(--color-ink-soft)] transition-colors hover:bg-[var(--color-signal)] hover:text-[var(--color-signal-ink)] disabled:opacity-40"
|
||||
>
|
||||
<Sparkles className="size-2.5 text-[var(--color-signal)]" />
|
||||
{s}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
messages.map((msg, i) => (
|
||||
<div
|
||||
key={`${msg.timestamp}-${i}`}
|
||||
className={`flex flex-col ${msg.role === "user" ? "items-end" : "items-start"}`}
|
||||
>
|
||||
<div
|
||||
className={`max-w-[85%] break-words whitespace-pre-wrap rounded-xl px-2.5 py-1.5 text-[11px] leading-relaxed ${
|
||||
msg.role === "user"
|
||||
? "rounded-br-sm bg-[var(--color-signal)] text-[var(--color-signal-ink)]"
|
||||
: "rounded-bl-sm bg-[var(--color-surface-2)] text-[var(--color-ink)]"
|
||||
}`}
|
||||
>
|
||||
{msg.content}
|
||||
</div>
|
||||
<span className="mt-0.5 px-1 text-[9px] text-[var(--color-ink-soft)]">
|
||||
{formatTime(msg.timestamp)}
|
||||
</span>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
{isTyping && (
|
||||
<div className="flex justify-start">
|
||||
<div className="rounded-xl rounded-bl-sm bg-[var(--color-surface-2)] px-2.5 py-2">
|
||||
<span className="inline-flex gap-1">
|
||||
<span
|
||||
className="size-1.5 animate-bounce rounded-full bg-[var(--color-ink-soft)]"
|
||||
style={{ animationDelay: "0ms" }}
|
||||
/>
|
||||
<span
|
||||
className="size-1.5 animate-bounce rounded-full bg-[var(--color-ink-soft)]"
|
||||
style={{ animationDelay: "150ms" }}
|
||||
/>
|
||||
<span
|
||||
className="size-1.5 animate-bounce rounded-full bg-[var(--color-ink-soft)]"
|
||||
style={{ animationDelay: "300ms" }}
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Input bar */}
|
||||
<form
|
||||
onSubmit={handleSubmit}
|
||||
className="flex shrink-0 items-center gap-1.5 border-t border-[var(--color-hairline)] px-2 py-2"
|
||||
>
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
placeholder="Tanya soal server, pesan, atau statistik…"
|
||||
className="flex-1 bg-transparent text-[11px] text-[var(--color-ink)] outline-none placeholder:text-[var(--color-ink-soft)]/50"
|
||||
disabled={isTyping}
|
||||
autoComplete="off"
|
||||
/>
|
||||
{messages.length > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void clearMessages()}
|
||||
className="flex size-6 items-center justify-center rounded transition-colors hover:bg-[var(--color-surface-2)] disabled:opacity-40"
|
||||
disabled={isTyping}
|
||||
aria-label="Hapus riwayat chat"
|
||||
title="Hapus riwayat"
|
||||
>
|
||||
<Eraser className="size-3 text-[var(--color-ink-soft)] hover:text-[var(--color-vermilion)]" />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="submit"
|
||||
className="flex size-7 items-center justify-center rounded-lg bg-[var(--color-signal)] text-[var(--color-signal-ink)] transition-colors hover:opacity-90 disabled:opacity-40"
|
||||
disabled={isTyping}
|
||||
aria-label="Kirim pesan"
|
||||
title="Kirim"
|
||||
>
|
||||
<Send className="size-3.5" />
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,100 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { Bot, Minimize2 } from "lucide-react";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { ChatPanel } from "./chat-panel";
|
||||
import { useChatbot } from "./chatbot-context";
|
||||
|
||||
export function ChatbotContainer() {
|
||||
const { minimized, setMinimized } = useChatbot();
|
||||
const [position, setPosition] = useState({ x: 0, y: 0 });
|
||||
const [dragging, setDragging] = useState(false);
|
||||
const [dragStart, setDragStart] = useState({ x: 0, y: 0 });
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const handleMouseDown = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
setDragging(true);
|
||||
setDragStart({ x: e.clientX - position.x, y: e.clientY - position.y });
|
||||
},
|
||||
[position],
|
||||
);
|
||||
|
||||
const handleMouseMove = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
if (!dragging) return;
|
||||
setPosition({ x: e.clientX - dragStart.x, y: e.clientY - dragStart.y });
|
||||
},
|
||||
[dragging, dragStart],
|
||||
);
|
||||
|
||||
const handleMouseUp = useCallback(() => setDragging(false), []);
|
||||
|
||||
// Focus input when chat opens
|
||||
useEffect(() => {
|
||||
if (!minimized) {
|
||||
const id = setTimeout(() => inputRef.current?.focus(), 150);
|
||||
return () => clearTimeout(id);
|
||||
}
|
||||
}, [minimized]);
|
||||
|
||||
return (
|
||||
// biome-ignore lint/a11y/noStaticElementInteractions: drag container — mouse-move gesture surface, not keyboard-interactive content
|
||||
<div
|
||||
className="fixed bottom-4 right-4 z-40 select-none"
|
||||
style={{ transform: `translate(${position.x}px, ${position.y}px)` }}
|
||||
onMouseMove={handleMouseMove}
|
||||
onMouseUp={handleMouseUp}
|
||||
onMouseLeave={handleMouseUp}
|
||||
>
|
||||
<div
|
||||
className={`surface-2 overflow-hidden shadow-2xl transition-all duration-200 ${
|
||||
minimized ? "h-14 w-14 cursor-pointer" : "h-[460px] w-[320px]"
|
||||
}`}
|
||||
>
|
||||
{minimized ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setMinimized(false)}
|
||||
className="flex size-full items-center justify-center"
|
||||
onMouseDown={handleMouseDown}
|
||||
aria-label="Buka chatbot"
|
||||
title="Buka chatbot"
|
||||
>
|
||||
<Bot className="size-6 text-[var(--color-signal)]" />
|
||||
</button>
|
||||
) : (
|
||||
<div className="flex h-full flex-col">
|
||||
{/* Drag handle + controls */}
|
||||
{/* biome-ignore lint/a11y/noStaticElementInteractions: drag handle — mouse-only gesture, keyboard users use the buttons in this header */}
|
||||
<div
|
||||
className="flex shrink-0 cursor-grab items-center justify-between border-b border-[var(--color-hairline)] px-3 py-2 active:cursor-grabbing"
|
||||
onMouseDown={handleMouseDown}
|
||||
>
|
||||
<span className="flex items-center gap-1.5 text-[10px] font-semibold uppercase tracking-wide text-[var(--color-ink-soft)]">
|
||||
<Bot className="size-3.5 text-[var(--color-signal)]" />
|
||||
Chatbot
|
||||
</span>
|
||||
<div className="flex items-center gap-0.5">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setMinimized(true)}
|
||||
className="flex size-6 items-center justify-center rounded transition-colors hover:bg-[var(--color-surface-2)]"
|
||||
aria-label="Kecilkan chatbot"
|
||||
title="Kecilkan chatbot"
|
||||
>
|
||||
<Minimize2 className="size-3.5 text-[var(--color-ink-soft)] hover:text-[var(--color-ink)]" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Chat panel — always open when bubble is expanded */}
|
||||
<div className="min-h-0 flex-1">
|
||||
<ChatPanel inputRef={inputRef} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,188 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
createContext,
|
||||
type ReactNode,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { useChatbotUserId } from "@/hooks/use-chatbot-user";
|
||||
import { chatbotApi } from "@/lib/api";
|
||||
|
||||
export type ChatbotExpression =
|
||||
| "idle"
|
||||
| "listening"
|
||||
| "surprise"
|
||||
| "happy"
|
||||
| "sad"
|
||||
| "talking";
|
||||
|
||||
interface ChatbotMessage {
|
||||
role: "user" | "assistant";
|
||||
content: string;
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
interface ChatbotContextValue {
|
||||
/** Expression the chatbot avatar should display */
|
||||
expression: ChatbotExpression;
|
||||
setExpression: (expr: ChatbotExpression) => void;
|
||||
|
||||
/** Whether the enlarged bubble is minimized to a small icon */
|
||||
minimized: boolean;
|
||||
setMinimized: (v: boolean) => void;
|
||||
|
||||
/**
|
||||
* @deprecated Use `minimized` / `setMinimized` instead.
|
||||
* Legacy toggle alias kept for compatibility.
|
||||
*/
|
||||
isOpen: boolean;
|
||||
setOpen: (open: boolean) => void;
|
||||
toggle: () => void;
|
||||
|
||||
/** Chat messages with real API backend */
|
||||
messages: ChatbotMessage[];
|
||||
sendMessage: (content: string) => Promise<void>;
|
||||
clearMessages: () => Promise<void>;
|
||||
isTyping: boolean;
|
||||
|
||||
/** Active guild context sent to the backend so answers reference the server */
|
||||
guildId: string;
|
||||
setGuildId: (g: string) => void;
|
||||
}
|
||||
|
||||
const ChatbotContext = createContext<ChatbotContextValue | null>(null);
|
||||
|
||||
export function ChatbotProvider({ children }: { children: ReactNode }) {
|
||||
const [expression, setExpression] = useState<ChatbotExpression>("idle");
|
||||
const [minimized, setMinimized] = useState(true);
|
||||
const [messages, setMessages] = useState<ChatbotMessage[]>([]);
|
||||
const [isTyping, setIsTyping] = useState(false);
|
||||
const [guildId, setGuildId] = useState("");
|
||||
const historyFetched = useRef(false);
|
||||
const userId = useChatbotUserId();
|
||||
|
||||
// Derived legacy state
|
||||
const isOpen = !minimized;
|
||||
|
||||
const setOpen = useCallback((open: boolean) => {
|
||||
setMinimized(!open);
|
||||
}, []);
|
||||
|
||||
const toggle = useCallback(() => {
|
||||
setMinimized((prev) => !prev);
|
||||
}, []);
|
||||
|
||||
// Load chat history on first mount (per-device user history)
|
||||
useEffect(() => {
|
||||
if (historyFetched.current || !userId) return;
|
||||
historyFetched.current = true;
|
||||
|
||||
chatbotApi
|
||||
.getHistory(userId)
|
||||
.then((res) => {
|
||||
// Backend returns rows {user_message, bot_response, created_at} —
|
||||
// interleave each user message with its bot reply.
|
||||
const withReplies: ChatbotMessage[] = [];
|
||||
for (const row of res.history ?? []) {
|
||||
withReplies.push({
|
||||
role: "user",
|
||||
content: row.user_message,
|
||||
timestamp: row.created_at,
|
||||
});
|
||||
withReplies.push({
|
||||
role: "assistant",
|
||||
content: row.bot_response,
|
||||
timestamp: row.created_at,
|
||||
});
|
||||
}
|
||||
setMessages(withReplies);
|
||||
})
|
||||
.catch(() => {
|
||||
// API may not be available yet — silently ignore
|
||||
});
|
||||
}, [userId]);
|
||||
|
||||
const sendMessage = useCallback(
|
||||
async (content: string) => {
|
||||
if (!content.trim()) return;
|
||||
|
||||
const userMsg: ChatbotMessage = {
|
||||
role: "user",
|
||||
content: content.trim(),
|
||||
timestamp: new Date().toISOString(),
|
||||
};
|
||||
setMessages((prev) => [...prev, userMsg]);
|
||||
setExpression("listening");
|
||||
setIsTyping(true);
|
||||
|
||||
try {
|
||||
// Send active guild as context so the backend can answer with
|
||||
// real server insights (serverInsights path in chatbot.service),
|
||||
// and the per-device user id so the history stays isolated.
|
||||
const res = await chatbotApi.send(content.trim(), guildId, userId);
|
||||
const botMsg: ChatbotMessage = {
|
||||
role: "assistant",
|
||||
content: res.response,
|
||||
timestamp: res.timestamp ?? new Date().toISOString(),
|
||||
};
|
||||
setMessages((prev) => [...prev, botMsg]);
|
||||
setExpression("happy");
|
||||
} catch {
|
||||
const errorMsg: ChatbotMessage = {
|
||||
role: "assistant",
|
||||
content:
|
||||
"Maaf, aku lagi gagal nyambung ke server. Coba tanya lagi ya 🙏",
|
||||
timestamp: new Date().toISOString(),
|
||||
};
|
||||
setMessages((prev) => [...prev, errorMsg]);
|
||||
setExpression("sad");
|
||||
} finally {
|
||||
setIsTyping(false);
|
||||
}
|
||||
},
|
||||
[guildId, userId],
|
||||
);
|
||||
|
||||
const clearMessages = useCallback(async () => {
|
||||
try {
|
||||
await chatbotApi.clearHistory(userId);
|
||||
} catch {
|
||||
// Best-effort clear
|
||||
}
|
||||
setMessages([]);
|
||||
}, [userId]);
|
||||
|
||||
return (
|
||||
<ChatbotContext.Provider
|
||||
value={{
|
||||
expression,
|
||||
setExpression,
|
||||
minimized,
|
||||
setMinimized,
|
||||
isOpen,
|
||||
setOpen,
|
||||
toggle,
|
||||
messages,
|
||||
sendMessage,
|
||||
clearMessages,
|
||||
isTyping,
|
||||
guildId,
|
||||
setGuildId,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</ChatbotContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useChatbot(): ChatbotContextValue {
|
||||
const ctx = useContext(ChatbotContext);
|
||||
if (!ctx) {
|
||||
throw new Error("useChatbot must be used within a ChatbotProvider");
|
||||
}
|
||||
return ctx;
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { Bot, Send, X, MessageCircle } from "lucide-react";
|
||||
import { chatbotApi } from "@/lib/api";
|
||||
import { useChatbotUserId } from "@/hooks/use-chatbot-user";
|
||||
import { GlassPanel, Input, Button, Avatar } from "@/components/primitives";
|
||||
import { toast } from "@/components/primitives";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface Msg {
|
||||
role: "user" | "bot";
|
||||
content: string;
|
||||
}
|
||||
|
||||
export function Chatbot() {
|
||||
const userId = useChatbotUserId();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [msgs, setMsgs] = useState<Msg[]>([]);
|
||||
const [input, setInput] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const listRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || !userId) return;
|
||||
chatbotApi
|
||||
.getHistory(userId)
|
||||
.then((res) => {
|
||||
setMsgs(
|
||||
res.history
|
||||
.slice(-12)
|
||||
.flatMap((h) => [
|
||||
{ role: "user" as const, content: h.user_message },
|
||||
{ role: "bot" as const, content: h.bot_response },
|
||||
]),
|
||||
);
|
||||
})
|
||||
.catch(() => {});
|
||||
}, [open, userId]);
|
||||
|
||||
useEffect(() => {
|
||||
listRef.current?.scrollTo({ top: listRef.current.scrollHeight });
|
||||
}, [msgs, loading]);
|
||||
|
||||
const send = async () => {
|
||||
const text = input.trim();
|
||||
if (!text || loading || !userId) return;
|
||||
setInput("");
|
||||
setMsgs((m) => [...m, { role: "user", content: text }]);
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await chatbotApi.send(text, undefined, userId);
|
||||
setMsgs((m) => [...m, { role: "bot", content: res.response }]);
|
||||
} catch (e) {
|
||||
toast({ title: "Chat error", description: String(e), tone: "vermilion" });
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Open assistant"
|
||||
onClick={() => setOpen((o) => !o)}
|
||||
className="fixed bottom-5 right-5 z-50 flex items-center justify-center rounded-full bg-signal text-signal-ink shadow-[0_10px_30px_-8px_var(--color-signal-glow)] transition-transform hover:scale-105"
|
||||
style={{ width: 52, height: 52 }}
|
||||
>
|
||||
{open ? <X className="size-5" /> : <MessageCircle className="size-5" />}
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<GlassPanel
|
||||
className="fixed bottom-20 right-5 z-50 flex w-[min(92vw,360px)] flex-col p-0"
|
||||
style={{ animation: "fade-up 0.16s ease", height: 460 }}
|
||||
>
|
||||
<div className="flex items-center gap-2 border-b border-hairline px-4 py-3">
|
||||
<span className="flex size-8 items-center justify-center rounded-full bg-signal/15 text-signal">
|
||||
<Bot className="size-4" />
|
||||
</span>
|
||||
<div>
|
||||
<div className="text-sm font-semibold text-ink">GMW Assistant</div>
|
||||
<div className="mono text-[0.6rem] text-ink-faint">context-aware</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div ref={listRef} className="flex-1 space-y-3 overflow-y-auto px-4 py-3">
|
||||
{msgs.length === 0 && (
|
||||
<div className="py-8 text-center text-xs text-ink-faint">
|
||||
Ask about moderation, voice, or media.
|
||||
</div>
|
||||
)}
|
||||
{msgs.map((m, i) => (
|
||||
<div key={i} className={cn("flex gap-2", m.role === "user" ? "justify-end" : "justify-start")}>
|
||||
{m.role === "bot" && <Avatar name="GMW" size={26} className="mt-0.5 bg-signal/15 text-signal" />}
|
||||
<div
|
||||
className={cn(
|
||||
"max-w-[80%] rounded-2xl px-3 py-2 text-sm",
|
||||
m.role === "user"
|
||||
? "rounded-br-sm bg-signal/20 text-ink"
|
||||
: "rounded-bl-sm bg-white/5 text-ink-soft",
|
||||
)}
|
||||
>
|
||||
{m.content}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{loading && (
|
||||
<div className="flex gap-2">
|
||||
<Avatar name="GMW" size={26} className="bg-signal/15 text-signal" />
|
||||
<div className="rounded-2xl rounded-bl-sm bg-white/5 px-3 py-2 text-sm text-ink-faint">…</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 border-t border-hairline p-3">
|
||||
<Input
|
||||
placeholder="Message…"
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
onKeyDown={(e) => e.key === "Enter" && send()}
|
||||
/>
|
||||
<Button variant="primary" size="icon" onClick={send} disabled={loading}>
|
||||
<Send className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</GlassPanel>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
export { ChatPanel } from "./chat-panel";
|
||||
export { ChatbotContainer } from "./chatbot-container";
|
||||
export { ChatbotProvider, useChatbot } from "./chatbot-context";
|
||||
@@ -0,0 +1,169 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useTheme } from "next-themes";
|
||||
import {
|
||||
Search,
|
||||
CornerDownLeft,
|
||||
ArrowUp,
|
||||
ArrowDown,
|
||||
Moon,
|
||||
Sun,
|
||||
} from "lucide-react";
|
||||
import { navItems } from "@/lib/navigation";
|
||||
import { GlassPanel } from "@/components/primitives";
|
||||
|
||||
interface Command {
|
||||
id: string;
|
||||
label: string;
|
||||
hint: string;
|
||||
icon: React.ReactNode;
|
||||
run: () => void;
|
||||
}
|
||||
|
||||
export function CommandPalette() {
|
||||
const router = useRouter();
|
||||
const { theme, setTheme } = useTheme();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [query, setQuery] = useState("");
|
||||
const [active, setActive] = useState(0);
|
||||
|
||||
const commands = useMemo<Command[]>(() => {
|
||||
const nav: Command[] = navItems.map((n) => ({
|
||||
id: `nav:${n.href}`,
|
||||
label: `Go to ${n.label}`,
|
||||
hint: n.href,
|
||||
icon: <n.icon className="size-4 text-signal" />,
|
||||
run: () => router.push(n.href),
|
||||
}));
|
||||
const actions: Command[] = [
|
||||
{
|
||||
id: "act:theme",
|
||||
label: "Toggle theme",
|
||||
hint: "appearance",
|
||||
icon:
|
||||
theme === "light" ? (
|
||||
<Moon className="size-4 text-signal" />
|
||||
) : (
|
||||
<Sun className="size-4 text-signal" />
|
||||
),
|
||||
run: () => setTheme(theme === "light" ? "dark" : "light"),
|
||||
},
|
||||
];
|
||||
return [...nav, ...actions];
|
||||
}, [router, theme, setTheme]);
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const q = query.trim().toLowerCase();
|
||||
if (!q) return commands;
|
||||
return commands.filter(
|
||||
(c) => c.label.toLowerCase().includes(q) || c.hint.toLowerCase().includes(q),
|
||||
);
|
||||
}, [commands, query]);
|
||||
|
||||
useEffect(() => {
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "k") {
|
||||
e.preventDefault();
|
||||
setOpen((o) => !o);
|
||||
}
|
||||
if (e.key === "Escape") setOpen(false);
|
||||
};
|
||||
const onOpen = () => setOpen(true);
|
||||
window.addEventListener("keydown", onKey);
|
||||
window.addEventListener("command-palette:open", onOpen);
|
||||
return () => {
|
||||
window.removeEventListener("keydown", onKey);
|
||||
window.removeEventListener("command-palette:open", onOpen);
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setQuery("");
|
||||
setActive(0);
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
useEffect(() => {
|
||||
setActive(0);
|
||||
}, [query]);
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
const runAt = (i: number) => {
|
||||
const c = filtered[i];
|
||||
if (!c) return;
|
||||
setOpen(false);
|
||||
c.run();
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-[90] flex items-start justify-center bg-black/50 px-4 pt-[12vh] backdrop-blur-sm"
|
||||
onMouseDown={() => setOpen(false)}
|
||||
>
|
||||
<GlassPanel
|
||||
className="w-full max-w-[560px] overflow-hidden p-0"
|
||||
style={{ animation: "fade-up 0.14s ease" }}
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="flex items-center gap-3 border-b border-hairline px-4 py-3">
|
||||
<Search className="size-4 text-ink-faint" />
|
||||
<input
|
||||
autoFocus
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "ArrowDown") {
|
||||
e.preventDefault();
|
||||
setActive((a) => Math.min(a + 1, filtered.length - 1));
|
||||
} else if (e.key === "ArrowUp") {
|
||||
e.preventDefault();
|
||||
setActive((a) => Math.max(a - 1, 0));
|
||||
} else if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
runAt(active);
|
||||
}
|
||||
}}
|
||||
placeholder="Type a command or search…"
|
||||
className="flex-1 bg-transparent text-sm text-ink outline-none placeholder:text-ink-faint"
|
||||
/>
|
||||
<kbd className="mono rounded bg-white/8 px-1.5 py-0.5 text-[0.6rem] text-ink-faint">ESC</kbd>
|
||||
</div>
|
||||
|
||||
<div className="max-h-[50vh] overflow-y-auto p-2">
|
||||
{filtered.length === 0 ? (
|
||||
<div className="py-8 text-center text-xs text-ink-faint">No commands</div>
|
||||
) : (
|
||||
filtered.map((c, i) => (
|
||||
<button
|
||||
key={c.id}
|
||||
type="button"
|
||||
onMouseEnter={() => setActive(i)}
|
||||
onClick={() => runAt(i)}
|
||||
className={`flex w-full items-center gap-3 rounded-[10px] px-3 py-2.5 text-left text-sm transition-colors ${
|
||||
i === active ? "bg-signal/12 text-ink" : "text-ink-soft hover:bg-white/5"
|
||||
}`}
|
||||
>
|
||||
<span className="flex size-7 items-center justify-center rounded-[8px] bg-white/5">
|
||||
{c.icon}
|
||||
</span>
|
||||
<span className="flex-1">{c.label}</span>
|
||||
<span className="mono text-[0.65rem] text-ink-faint">{c.hint}</span>
|
||||
{i === active && <CornerDownLeft className="size-3.5 text-ink-faint" />}
|
||||
</button>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4 border-t border-hairline px-4 py-2 text-[0.65rem] text-ink-faint">
|
||||
<span className="flex items-center gap-1"><ArrowUp className="size-3" /><ArrowDown className="size-3" /> navigate</span>
|
||||
<span className="flex items-center gap-1"><CornerDownLeft className="size-3" /> select</span>
|
||||
<span className="ml-auto mono">⌘K</span>
|
||||
</div>
|
||||
</GlassPanel>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,182 +0,0 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* DashCommandLine — sticky bottom prompt for ops actions.
|
||||
*
|
||||
* The signature element of the new dashboard. Pure mono input; parses a
|
||||
* slash-prefixed verb and dispatches to existing APIs or client-side
|
||||
* actions. Autocomplete is intentionally light (suggestions render in
|
||||
* monospace below the input).
|
||||
*/
|
||||
|
||||
import {
|
||||
type FormEvent,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type CommandVerb = "mute" | "jump" | "find" | "clear";
|
||||
|
||||
interface CommandResult {
|
||||
ok: boolean;
|
||||
message: string;
|
||||
}
|
||||
|
||||
const VERBS: CommandVerb[] = ["mute", "jump", "find", "clear"];
|
||||
|
||||
interface DashCommandLineProps {
|
||||
onCommand?: (verb: CommandVerb, args: string) => CommandResult | undefined;
|
||||
placeholder?: string;
|
||||
}
|
||||
|
||||
export function DashCommandLine({
|
||||
onCommand,
|
||||
placeholder = "type a command — /mute @user 10m, /jump #channel, /find text, /clear",
|
||||
}: DashCommandLineProps) {
|
||||
const [value, setValue] = useState("");
|
||||
const [history, setHistory] = useState<string[]>([]);
|
||||
const [_historyIdx, setHistoryIdx] = useState<number>(-1);
|
||||
const [result, setResult] = useState<CommandResult | null>(null);
|
||||
const inputRef = useRef<HTMLInputElement | null>(null);
|
||||
|
||||
// Global "/" focuses the command line (skip when typing in another input).
|
||||
useEffect(() => {
|
||||
const handler = (e: KeyboardEvent) => {
|
||||
if (e.key !== "/" || e.metaKey || e.ctrlKey || e.altKey) return;
|
||||
const t = e.target as HTMLElement | null;
|
||||
const tag = t?.tagName?.toLowerCase();
|
||||
if (tag === "input" || tag === "textarea" || t?.isContentEditable) return;
|
||||
e.preventDefault();
|
||||
inputRef.current?.focus();
|
||||
};
|
||||
window.addEventListener("keydown", handler);
|
||||
return () => window.removeEventListener("keydown", handler);
|
||||
}, []);
|
||||
|
||||
const suggestions = useMemo(() => {
|
||||
const trimmed = value.trimStart();
|
||||
if (!trimmed.startsWith("/")) return [] as CommandVerb[];
|
||||
const verb = trimmed.slice(1).split(/\s+/)[0]?.toLowerCase() ?? "";
|
||||
if (!verb) return VERBS;
|
||||
return VERBS.filter((v) => v.startsWith(verb));
|
||||
}, [value]);
|
||||
|
||||
const submit = useCallback(
|
||||
(raw: string) => {
|
||||
const trimmed = raw.trim();
|
||||
if (!trimmed.startsWith("/")) {
|
||||
setResult({ ok: false, message: "commands start with /" });
|
||||
return;
|
||||
}
|
||||
const body = trimmed.slice(1);
|
||||
const [verbRaw, ...rest] = body.split(/\s+/);
|
||||
const verb = (verbRaw?.toLowerCase() ?? "") as CommandVerb;
|
||||
if (!VERBS.includes(verb)) {
|
||||
setResult({
|
||||
ok: false,
|
||||
message: `unknown verb "${verbRaw}" — try ${VERBS.join(", ")}`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
const args = rest.join(" ");
|
||||
try {
|
||||
const ret = onCommand?.(verb, args);
|
||||
const message =
|
||||
(ret && typeof ret === "object" && "message" in ret && ret.message) ||
|
||||
defaultMessage(verb, args);
|
||||
setResult({ ok: true, message });
|
||||
} catch (err) {
|
||||
setResult({
|
||||
ok: false,
|
||||
message: err instanceof Error ? err.message : "command failed",
|
||||
});
|
||||
}
|
||||
setHistory((h) => [trimmed, ...h].slice(0, 32));
|
||||
setHistoryIdx(-1);
|
||||
},
|
||||
[onCommand],
|
||||
);
|
||||
|
||||
const onSubmit = (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (value.trim()) {
|
||||
submit(value);
|
||||
setValue("");
|
||||
}
|
||||
};
|
||||
|
||||
const onKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (e.key === "ArrowUp") {
|
||||
e.preventDefault();
|
||||
setHistoryIdx((idx) => {
|
||||
const next = idx + 1;
|
||||
if (next >= history.length) return idx;
|
||||
setValue(history[next] ?? "");
|
||||
return next;
|
||||
});
|
||||
} else if (e.key === "ArrowDown") {
|
||||
e.preventDefault();
|
||||
setHistoryIdx((idx) => {
|
||||
const next = idx - 1;
|
||||
if (next < -1) return idx;
|
||||
setValue(next === -1 ? "" : (history[next] ?? ""));
|
||||
return next;
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<form
|
||||
onSubmit={onSubmit}
|
||||
className="sticky bottom-0 z-10 flex h-11 items-center gap-2 border-t border-[var(--color-hairline)] bg-[var(--color-canvas)] px-3 font-mono text-[12px]"
|
||||
role="search"
|
||||
>
|
||||
<span className="shrink-0 text-[var(--color-signal)]">{">"}</span>
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
value={value}
|
||||
onChange={(e) => setValue(e.target.value)}
|
||||
onKeyDown={onKeyDown}
|
||||
placeholder={placeholder}
|
||||
spellCheck={false}
|
||||
autoComplete="off"
|
||||
aria-label="Command line"
|
||||
className="min-w-0 flex-1 bg-transparent text-[var(--color-ink)] outline-none placeholder:text-[var(--color-ink-soft)]"
|
||||
/>
|
||||
{result ? (
|
||||
<span
|
||||
className={cn(
|
||||
"shrink-0 truncate text-[10px] uppercase tracking-wide",
|
||||
result.ok
|
||||
? "text-[var(--color-signal)]"
|
||||
: "text-[var(--color-vermilion)]",
|
||||
)}
|
||||
>
|
||||
{result.message}
|
||||
</span>
|
||||
) : suggestions.length > 0 ? (
|
||||
<span className="shrink-0 truncate text-[10px] uppercase tracking-wide text-[var(--color-ink-soft)]">
|
||||
{suggestions.map((s) => `/${s}`).join(" ")}
|
||||
</span>
|
||||
) : null}
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
function defaultMessage(verb: CommandVerb, args: string): string {
|
||||
switch (verb) {
|
||||
case "mute":
|
||||
return args ? `mute queued — ${args}` : "mute needs a target";
|
||||
case "jump":
|
||||
return args ? `jump queued — ${args}` : "jump needs a channel";
|
||||
case "find":
|
||||
return args ? `find queued — ${args}` : "find needs text";
|
||||
case "clear":
|
||||
return "feed cleared";
|
||||
}
|
||||
}
|
||||
@@ -1,35 +0,0 @@
|
||||
import type { AreaPoint } from "@/components/charts/area-activity";
|
||||
import { AreaActivity } from "@/components/charts/area-activity";
|
||||
|
||||
export interface ActivityChartProps {
|
||||
data: {
|
||||
day: string;
|
||||
messages: number;
|
||||
flagged: number;
|
||||
active_users: number;
|
||||
}[];
|
||||
}
|
||||
|
||||
export function ActivityChart({ data }: ActivityChartProps) {
|
||||
const points: AreaPoint[] = data.map((d) => ({
|
||||
label: d.day,
|
||||
value: d.messages,
|
||||
}));
|
||||
return (
|
||||
<div className="surface p-4">
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<h3 className="text-sm font-semibold">Daily messages</h3>
|
||||
<span className="pill bg-[var(--color-signal)]/15 text-[var(--color-signal)]">
|
||||
{data.length}d
|
||||
</span>
|
||||
</div>
|
||||
<ActivityChartInner points={points} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ActivityChartInner({ points }: { points: AreaPoint[] }) {
|
||||
return (
|
||||
<AreaActivity data={points} height={180} label="Daily message activity" />
|
||||
);
|
||||
}
|
||||
@@ -1,140 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { Hash, Search } from "lucide-react";
|
||||
import { useCallback, useState } from "react";
|
||||
import { Badge } from "@/components/primitives/badge";
|
||||
import { Button } from "@/components/primitives/button";
|
||||
import { Input } from "@/components/primitives/input";
|
||||
import { EmptyState, LoadingSkeleton } from "@/components/shared";
|
||||
import { useChannelDetail, useChannels } from "@/hooks";
|
||||
import type { DashboardChannel } from "@/lib/types";
|
||||
|
||||
export function ChannelsSection({ guildId }: { guildId?: string }) {
|
||||
const [search, setSearch] = useState("");
|
||||
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||
|
||||
const { data: channels = [], isLoading } = useChannels(guildId ?? "", search);
|
||||
const { data: detail } = useChannelDetail(selectedId);
|
||||
|
||||
const handleSearch = useCallback((v: string) => setSearch(v), []);
|
||||
|
||||
if (isLoading) return <LoadingSkeleton count={8} />;
|
||||
if (channels.length === 0)
|
||||
return (
|
||||
<EmptyState
|
||||
icon={Hash}
|
||||
title="No channels"
|
||||
description="No channels in this guild."
|
||||
/>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="grid gap-3 lg:grid-cols-[1fr_320px]">
|
||||
<div className="surface flex flex-col gap-2 p-3">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 size-4 -translate-y-1/2 text-[var(--color-ink-soft)]" />
|
||||
<Input
|
||||
mono
|
||||
placeholder="search channels…"
|
||||
value={search}
|
||||
onChange={(e) => handleSearch(e.target.value)}
|
||||
className="pl-9"
|
||||
/>
|
||||
</div>
|
||||
{channels.map((c) => (
|
||||
<ChannelRow
|
||||
key={c.channel_id}
|
||||
channel={c}
|
||||
selected={selectedId === c.channel_id}
|
||||
onSelect={() => setSelectedId(c.channel_id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="surface p-4">
|
||||
{detail ? (
|
||||
<div className="flex flex-col gap-3.5">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<span className="flex size-9 items-center justify-center rounded-[var(--radius-r-control)] bg-[var(--color-surface-2)]">
|
||||
<Hash className="size-4 text-[var(--color-ink-soft)]" />
|
||||
</span>
|
||||
<div>
|
||||
<div className="font-semibold">
|
||||
{detail.channel_name ?? detail.channel_id}
|
||||
</div>
|
||||
<div className="text-xs text-[var(--color-ink-soft)]">
|
||||
{detail.total_messages.toLocaleString()} messages
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Stat
|
||||
label="Flagged"
|
||||
value={detail.flagged_count}
|
||||
tone="vermilion"
|
||||
/>
|
||||
<p className="text-xs text-[var(--color-ink-soft)]">
|
||||
{detail.culture_summary ?? "No data yet."}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-[var(--color-ink-soft)]">
|
||||
Select a channel to inspect.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ChannelRow({
|
||||
channel,
|
||||
selected,
|
||||
onSelect,
|
||||
}: {
|
||||
channel: DashboardChannel;
|
||||
selected: boolean;
|
||||
onSelect: () => void;
|
||||
}) {
|
||||
const total = channel.total_messages + channel.flagged_count || 1;
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onSelect}
|
||||
className="flex items-center gap-3 rounded-[var(--radius-r-control)] px-2.5 py-2 text-left transition-colors hover:bg-[var(--color-surface-2)] data-[selected]:bg-[var(--color-signal)]/8"
|
||||
data-selected={selected}
|
||||
>
|
||||
<Hash className="size-4 text-[var(--color-ink-soft)]" />
|
||||
<span className="min-w-0 flex-1 truncate text-sm">
|
||||
{channel.channel_name ?? channel.channel_id}
|
||||
</span>
|
||||
<span className="mono text-xs text-[var(--color-ink-soft)]">
|
||||
{channel.flagged_count}/{channel.total_messages}
|
||||
</span>
|
||||
<div className="h-1.5 w-10 overflow-hidden rounded-full bg-[var(--color-hairline)]">
|
||||
<div
|
||||
className="h-full bg-[var(--color-signal)]"
|
||||
style={{ width: `${(channel.flagged_count / total) * 100}%` }}
|
||||
/>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function Stat({
|
||||
label,
|
||||
value,
|
||||
tone,
|
||||
}: {
|
||||
label: string;
|
||||
value: number;
|
||||
tone: "vermilion";
|
||||
}) {
|
||||
return (
|
||||
<div className="surface-2 flex items-center justify-between p-2.5">
|
||||
<span className="text-xs text-[var(--color-ink-soft)]">{label}</span>
|
||||
<span className="mono font-semibold text-[var(--color-vermilion)]">
|
||||
{value}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,29 +0,0 @@
|
||||
import type { AreaPoint } from "@/components/charts/area-activity";
|
||||
import { AreaActivity } from "@/components/charts/area-activity";
|
||||
|
||||
export interface HourlyActivityChartProps {
|
||||
data: { hour: number; messages: number; flagged: number }[];
|
||||
}
|
||||
|
||||
export function HourlyActivityChart({ data }: HourlyActivityChartProps) {
|
||||
const points: AreaPoint[] = data.map((d) => ({
|
||||
label: `${d.hour}:00`,
|
||||
value: d.messages,
|
||||
}));
|
||||
return (
|
||||
<div className="surface p-4">
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<h3 className="text-sm font-semibold">Hourly distribution</h3>
|
||||
<span className="text-xs text-[var(--color-ink-soft)]">
|
||||
00:00 – 23:00
|
||||
</span>
|
||||
</div>
|
||||
<AreaActivity
|
||||
data={points}
|
||||
height={140}
|
||||
stroke="var(--color-amber)"
|
||||
label="Hourly message activity"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,53 +0,0 @@
|
||||
import { RadialGauge } from "@/components/charts/radial-gauge";
|
||||
import type { DashboardStats } from "@/lib/types";
|
||||
|
||||
export interface ModerationDonutProps {
|
||||
stats?: DashboardStats;
|
||||
}
|
||||
|
||||
export function ModerationDonut({ stats }: ModerationDonutProps) {
|
||||
const clean = stats?.total_clean ?? 0;
|
||||
const flagged = stats?.total_flagged ?? 0;
|
||||
const warned = stats?.total_warned ?? 0;
|
||||
const total = clean + flagged + warned || 1;
|
||||
const ratio = clean / total;
|
||||
|
||||
return (
|
||||
<div className="surface flex flex-col items-center gap-3 p-4">
|
||||
<h3 className="self-start text-sm font-semibold">Moderation health</h3>
|
||||
<RadialGauge
|
||||
value={ratio}
|
||||
size={150}
|
||||
label="Clean"
|
||||
tone={ratio > 0.8 ? "signal" : ratio > 0.6 ? "amber" : "vermilion"}
|
||||
/>
|
||||
<div className="flex w-full flex-col gap-1.5 text-xs">
|
||||
<Row label="Clean" value={clean} tone="var(--color-signal)" />
|
||||
<Row label="Warned" value={warned} tone="var(--color-amber)" />
|
||||
<Row label="Flagged" value={flagged} tone="var(--color-vermilion)" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Row({
|
||||
label,
|
||||
value,
|
||||
tone,
|
||||
}: {
|
||||
label: string;
|
||||
value: number;
|
||||
tone: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="flex items-center gap-2 text-[var(--color-ink-soft)]">
|
||||
<span className="size-2 rounded-full" style={{ background: tone }} />
|
||||
{label}
|
||||
</span>
|
||||
<span className="mono text-[var(--color-ink)]">
|
||||
{value.toLocaleString()}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,89 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { Flame, Heart, SmilePlus } from "lucide-react";
|
||||
import { Avatar } from "@/components/primitives/avatar";
|
||||
import { Badge } from "@/components/primitives/badge";
|
||||
import { EmptyState, LoadingSkeleton } from "@/components/shared";
|
||||
import { useTopReactions, useTopReactors } from "@/hooks";
|
||||
|
||||
export interface ReactionsSectionProps {
|
||||
initialReactions?: Awaited<ReturnType<typeof useTopReactions>>["data"];
|
||||
}
|
||||
|
||||
export function ReactionsSection() {
|
||||
const { data: reactions, isLoading: reactionsLoading } = useTopReactions();
|
||||
const { data: reactors, isLoading: reactorsLoading } = useTopReactors();
|
||||
|
||||
if (reactionsLoading || reactorsLoading) return <LoadingSkeleton count={5} />;
|
||||
|
||||
const topReactions = (reactions ?? []).slice(0, 6);
|
||||
const topReactors = (reactors ?? []).slice(0, 6);
|
||||
|
||||
return (
|
||||
<div className="grid gap-3 lg:grid-cols-2">
|
||||
<div className="surface p-4">
|
||||
<h3 className="mb-3 flex items-center gap-2 text-sm font-semibold">
|
||||
<Heart className="size-4 text-[var(--color-vermilion)]" />
|
||||
Top reactions
|
||||
</h3>
|
||||
{topReactions.length === 0 ? (
|
||||
<EmptyState
|
||||
icon={SmilePlus}
|
||||
title="No reactions"
|
||||
description="No reactions yet."
|
||||
/>
|
||||
) : (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{topReactions
|
||||
.flatMap((m) =>
|
||||
m.top_emojis.map((e) => ({ emoji: e.emoji, count: e.count })),
|
||||
)
|
||||
.reduce<{ emoji: string; count: number }[]>((acc, cur) => {
|
||||
const found = acc.find((x) => x.emoji === cur.emoji);
|
||||
if (found) found.count += cur.count;
|
||||
else acc.push(cur);
|
||||
return acc;
|
||||
}, [])
|
||||
.sort((a, b) => b.count - a.count)
|
||||
.slice(0, 8)
|
||||
.map((r) => (
|
||||
<span
|
||||
key={r.emoji}
|
||||
className="flex items-center gap-1.5 rounded-[var(--radius-r-control)] bg-[var(--color-surface-2)] px-2.5 py-1 text-sm"
|
||||
>
|
||||
<span>{r.emoji}</span>
|
||||
<span className="mono text-xs text-[var(--color-ink-soft)]">
|
||||
{r.count}
|
||||
</span>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="surface p-4">
|
||||
<h3 className="mb-3 flex items-center gap-2 text-sm font-semibold">
|
||||
<Flame className="size-4 text-[var(--color-amber)]" />
|
||||
Top reactors
|
||||
</h3>
|
||||
{topReactors.length === 0 ? (
|
||||
<EmptyState
|
||||
icon={SmilePlus}
|
||||
title="No reactors"
|
||||
description="No reactors yet."
|
||||
/>
|
||||
) : (
|
||||
<div className="flex flex-col gap-2">
|
||||
{topReactors.map((r) => (
|
||||
<div key={r.user_id} className="flex items-center gap-3">
|
||||
<Avatar name={r.username} size={28} />
|
||||
<span className="flex-1 text-sm">{r.username}</span>
|
||||
<Badge tone="signal">+{r.net_count}</Badge>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,39 +0,0 @@
|
||||
import { Hash } from "lucide-react";
|
||||
import type { TopChannel } from "@/lib/types";
|
||||
|
||||
export interface TopChannelsChartProps {
|
||||
channels: TopChannel[];
|
||||
}
|
||||
|
||||
export function TopChannelsChart({ channels }: TopChannelsChartProps) {
|
||||
const max = Math.max(...channels.map((c) => c.message_count), 1);
|
||||
const top = [...channels]
|
||||
.sort((a, b) => b.message_count - a.message_count)
|
||||
.slice(0, 8);
|
||||
return (
|
||||
<div className="surface p-4">
|
||||
<h3 className="mb-3 text-sm font-semibold">Top channels</h3>
|
||||
<div className="flex flex-col gap-2.5">
|
||||
{top.map((c) => (
|
||||
<div key={c.channel_id} className="flex items-center gap-3">
|
||||
<span className="flex size-6 shrink-0 items-center justify-center rounded-[var(--radius-r-control)] bg-[var(--color-surface-2)] text-[var(--color-ink-soft)]">
|
||||
<Hash className="size-3.5" />
|
||||
</span>
|
||||
<span className="w-32 shrink-0 truncate text-xs text-[var(--color-ink)]">
|
||||
{c.channel_name ?? c.channel_id}
|
||||
</span>
|
||||
<div className="relative h-2 flex-1 overflow-hidden rounded-full bg-[var(--color-surface-2)]">
|
||||
<div
|
||||
className="absolute inset-y-0 left-0 rounded-full bg-[var(--color-signal)] transition-[width] duration-500"
|
||||
style={{ width: `${(c.message_count / max) * 100}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className="mono w-12 shrink-0 text-right text-xs text-[var(--color-ink-soft)]">
|
||||
{c.message_count.toLocaleString()}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,151 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { Search, Users } from "lucide-react";
|
||||
import { useCallback, useState } from "react";
|
||||
import { Avatar } from "@/components/primitives/avatar";
|
||||
import { Badge } from "@/components/primitives/badge";
|
||||
import { Input } from "@/components/primitives/input";
|
||||
import { EmptyState, LoadingSkeleton } from "@/components/shared";
|
||||
import { useUserDetail, useUsers } from "@/hooks";
|
||||
|
||||
const TRUST_TIERS = [
|
||||
{ min: 75, label: "Trusted", tone: "signal" as const },
|
||||
{ min: 40, label: "Neutral", tone: "neutral" as const },
|
||||
{ min: 10, label: "At Risk", tone: "amber" as const },
|
||||
{ min: 0, label: "Critical", tone: "vermilion" as const },
|
||||
];
|
||||
|
||||
function trustTier(score?: number | null) {
|
||||
const s = score ?? 0;
|
||||
return (
|
||||
TRUST_TIERS.find((t) => s >= t.min) ?? TRUST_TIERS[TRUST_TIERS.length - 1]
|
||||
);
|
||||
}
|
||||
|
||||
export function UsersSection() {
|
||||
const [search, setSearch] = useState("");
|
||||
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||
|
||||
const { data: users = [], isLoading } = useUsers(search);
|
||||
const { data: detail } = useUserDetail(selectedId);
|
||||
|
||||
const handleSearch = useCallback((v: string) => setSearch(v), []);
|
||||
|
||||
if (isLoading) return <LoadingSkeleton count={6} />;
|
||||
if (users.length === 0)
|
||||
return (
|
||||
<EmptyState
|
||||
icon={Users}
|
||||
title="No users found"
|
||||
description="Try a different search."
|
||||
/>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="grid gap-3 lg:grid-cols-[1fr_360px]">
|
||||
<div className="surface flex flex-col gap-2 p-3">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 size-4 -translate-y-1/2 text-[var(--color-ink-soft)]" />
|
||||
<Input
|
||||
mono
|
||||
placeholder="search users…"
|
||||
value={search}
|
||||
onChange={(e) => handleSearch(e.target.value)}
|
||||
className="pl-9"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col">
|
||||
{users.map((u) => {
|
||||
const tier = trustTier(u.trust_score);
|
||||
return (
|
||||
<button
|
||||
key={u.user_id}
|
||||
type="button"
|
||||
onClick={() => setSelectedId(u.user_id)}
|
||||
className="flex items-center gap-3 rounded-[var(--radius-r-control)] px-2 py-2 text-left transition-colors hover:bg-[var(--color-surface-2)]"
|
||||
>
|
||||
<Avatar src={u.avatar_url} name={u.username} size={34} />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate text-sm font-medium">
|
||||
{u.username ?? "unknown"}
|
||||
</div>
|
||||
<div className="mono text-xs text-[var(--color-ink-soft)]">
|
||||
{u.total_messages.toLocaleString()} msg
|
||||
</div>
|
||||
</div>
|
||||
<Badge tone={tier.tone}>{tier.label}</Badge>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="surface p-4">
|
||||
{detail ? (
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<Avatar
|
||||
src={detail.avatar_url}
|
||||
name={detail.username}
|
||||
size={44}
|
||||
/>
|
||||
<div>
|
||||
<div className="font-semibold">{detail.username}</div>
|
||||
<div className="text-xs text-[var(--color-ink-soft)]">
|
||||
{detail.total_messages.toLocaleString()} messages
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-2 text-sm">
|
||||
<Stat label="Trust" value={`${detail.trust_score ?? 0}`} />
|
||||
<Stat
|
||||
label="Clean streak"
|
||||
value={`${detail.clean_message_streak ?? 0}`}
|
||||
/>
|
||||
<Stat
|
||||
label="Infractions"
|
||||
value={`${detail.total_infractions ?? 0}`}
|
||||
tone="vermilion"
|
||||
/>
|
||||
<Stat
|
||||
label="Flagged"
|
||||
value={`${detail.flagged_count}`}
|
||||
tone="amber"
|
||||
/>
|
||||
</div>
|
||||
<p className="text-xs text-[var(--color-ink-soft)]">
|
||||
{detail.profile_summary}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-[var(--color-ink-soft)]">
|
||||
Select a user to inspect.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Stat({
|
||||
label,
|
||||
value,
|
||||
tone,
|
||||
}: {
|
||||
label: string;
|
||||
value: string;
|
||||
tone?: "amber" | "vermilion";
|
||||
}) {
|
||||
return (
|
||||
<div className="surface-2 p-2.5">
|
||||
<div className="text-[11px] uppercase tracking-wide text-[var(--color-ink-soft)]">
|
||||
{label}
|
||||
</div>
|
||||
<div
|
||||
className={`mono text-lg font-semibold ${tone === "amber" ? "text-[var(--color-amber)]" : tone === "vermilion" ? "text-[var(--color-vermilion)]" : "text-[var(--color-ink)]"}`}
|
||||
>
|
||||
{value}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,273 +0,0 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* EventFeed — horizontal scroll-snap timeline that ingests live events.
|
||||
*
|
||||
* The feed is the central column of the dashboard. Time runs left → right
|
||||
* (older → newer). New events append at the right edge; the feed scrolls
|
||||
* right when the user is at the live edge and pauses when the user drags
|
||||
* back to inspect history.
|
||||
*
|
||||
* Ring buffer keeps the DOM bounded (200 items). A `NowMarker` is inserted
|
||||
* every 10 events or every 30 seconds to break the row rhythm with a pulse
|
||||
* summary — see `useFeedPulse`.
|
||||
*/
|
||||
|
||||
import {
|
||||
type ReactNode,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { EventRow, type FeedEvent } from "@/components/feed/event-row";
|
||||
import { ClusterMarker, PulseMarker } from "@/components/feed/now-marker";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const RING_BUFFER_MAX = 200;
|
||||
const PULSE_EVERY_N_EVENTS = 10;
|
||||
const PULSE_EVERY_MS = 30_000;
|
||||
|
||||
export type FeedItem =
|
||||
| { kind: "event"; event: FeedEvent }
|
||||
| {
|
||||
kind: "pulse";
|
||||
key: string;
|
||||
ts: number;
|
||||
label: string;
|
||||
summary: string;
|
||||
tone?: "signal" | "amber" | "vermilion";
|
||||
}
|
||||
| {
|
||||
kind: "cluster";
|
||||
key: string;
|
||||
ts: number;
|
||||
label: string;
|
||||
bands: {
|
||||
tone: "neutral" | "signal" | "amber" | "vermilion";
|
||||
ratio: number;
|
||||
}[];
|
||||
tone?: "signal" | "amber" | "vermilion";
|
||||
};
|
||||
|
||||
interface EventFeedProps {
|
||||
initialEvents: FeedEvent[];
|
||||
subscribe: (handler: (e: FeedEvent) => void) => () => void;
|
||||
className?: string;
|
||||
emptyState?: ReactNode;
|
||||
}
|
||||
|
||||
export function EventFeed({
|
||||
initialEvents,
|
||||
subscribe,
|
||||
className,
|
||||
emptyState,
|
||||
}: EventFeedProps) {
|
||||
const [items, setItems] = useState<FeedItem[]>(() =>
|
||||
injectMarkers(initialEvents.slice(-RING_BUFFER_MAX)),
|
||||
);
|
||||
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||
const [following, setFollowing] = useState(true);
|
||||
const scrollerRef = useRef<HTMLDivElement | null>(null);
|
||||
const lastPulseAt = useRef<number>(Date.now());
|
||||
|
||||
// Live WS ingest
|
||||
useEffect(() => {
|
||||
const unsub = subscribe((e) => {
|
||||
setItems((prev) => appendWithMarker(prev, e));
|
||||
});
|
||||
return unsub;
|
||||
}, [subscribe]);
|
||||
|
||||
// Periodic pulse even if traffic is slow — keeps the feed rhythm alive.
|
||||
useEffect(() => {
|
||||
const id = window.setInterval(() => {
|
||||
setItems((prev) => {
|
||||
if (Date.now() - lastPulseAt.current < PULSE_EVERY_MS) return prev;
|
||||
return appendPulse(prev, "system", "live · standing by");
|
||||
});
|
||||
}, PULSE_EVERY_MS);
|
||||
return () => window.clearInterval(id);
|
||||
}, []);
|
||||
|
||||
// Auto-scroll on append when following.
|
||||
useEffect(() => {
|
||||
if (!following) return;
|
||||
const el = scrollerRef.current;
|
||||
if (!el) return;
|
||||
el.scrollTo({ left: el.scrollWidth, behavior: "smooth" });
|
||||
}, [following]);
|
||||
|
||||
const handleScroll = useCallback(() => {
|
||||
const el = scrollerRef.current;
|
||||
if (!el) return;
|
||||
const distFromRight = el.scrollWidth - el.scrollLeft - el.clientWidth;
|
||||
setFollowing(distFromRight < 24);
|
||||
}, []);
|
||||
|
||||
const handleSelect = useCallback((id: string) => {
|
||||
setSelectedId((cur) => (cur === id ? null : id));
|
||||
}, []);
|
||||
|
||||
const visibleItems = useMemo(() => {
|
||||
if (items.length <= RING_BUFFER_MAX) return items;
|
||||
return items.slice(items.length - RING_BUFFER_MAX);
|
||||
}, [items]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"relative h-full w-full overflow-hidden",
|
||||
"border-t border-[var(--color-hairline)]",
|
||||
className,
|
||||
)}
|
||||
data-following={following ? "1" : "0"}
|
||||
>
|
||||
<div className="flex items-center justify-between border-b border-[var(--color-hairline)] bg-[var(--color-surface)] px-3 py-1.5 font-mono text-[10px] uppercase tracking-[0.18em] text-[var(--color-ink-soft)]">
|
||||
<span>event horizon</span>
|
||||
<span>
|
||||
{visibleItems.filter((i) => i.kind === "event").length} events ·{" "}
|
||||
{following ? "live" : "paused"}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div
|
||||
ref={scrollerRef}
|
||||
onScroll={handleScroll}
|
||||
className={cn(
|
||||
"h-[calc(100%-30px)] overflow-y-auto overflow-x-hidden",
|
||||
"snap-y snap-mandatory",
|
||||
"scroll-pt-2",
|
||||
)}
|
||||
role="feed"
|
||||
aria-live="polite"
|
||||
>
|
||||
{visibleItems.length === 0 && emptyState ? (
|
||||
<div className="flex h-full items-center justify-center p-8 text-center font-mono text-[12px] text-[var(--color-ink-soft)]">
|
||||
{emptyState}
|
||||
</div>
|
||||
) : (
|
||||
visibleItems.map((item) => {
|
||||
if (item.kind === "event") {
|
||||
return (
|
||||
<div key={item.event.id} className="snap-start">
|
||||
<EventRow
|
||||
event={item.event}
|
||||
selected={selectedId === item.event.id}
|
||||
onSelect={handleSelect}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (item.kind === "cluster") {
|
||||
return (
|
||||
<div key={item.key} className="snap-start">
|
||||
<ClusterMarker
|
||||
label={item.label}
|
||||
timestamp={item.ts}
|
||||
bands={item.bands}
|
||||
tone={item.tone}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div key={item.key} className="snap-start">
|
||||
<PulseMarker
|
||||
label={item.label}
|
||||
timestamp={item.ts}
|
||||
trailing={item.summary}
|
||||
tone={item.tone}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Ring + pulse helpers ────────────────────────────────────────
|
||||
|
||||
function injectMarkers(events: FeedEvent[]): FeedItem[] {
|
||||
if (events.length === 0) return [];
|
||||
const out: FeedItem[] = [];
|
||||
let count = 0;
|
||||
for (const e of events) {
|
||||
out.push({ kind: "event", event: e });
|
||||
count++;
|
||||
if (count % PULSE_EVERY_N_EVENTS === 0) {
|
||||
out.push({
|
||||
kind: "cluster",
|
||||
key: `cluster-${e.id}`,
|
||||
ts: e.ts,
|
||||
label: "pulse",
|
||||
bands: deriveBands(
|
||||
events.slice(Math.max(0, count - PULSE_EVERY_N_EVENTS), count),
|
||||
),
|
||||
tone: "signal",
|
||||
});
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function deriveBands(
|
||||
window: FeedEvent[],
|
||||
): { tone: "neutral" | "signal" | "amber" | "vermilion"; ratio: number }[] {
|
||||
const counts: Record<"neutral" | "signal" | "amber" | "vermilion", number> = {
|
||||
neutral: 0,
|
||||
signal: 0,
|
||||
amber: 0,
|
||||
vermilion: 0,
|
||||
};
|
||||
for (const e of window) counts[e.severity]++;
|
||||
const total = window.length || 1;
|
||||
return (Object.keys(counts) as Array<keyof typeof counts>).map((k) => ({
|
||||
tone: k,
|
||||
ratio: counts[k] / total,
|
||||
}));
|
||||
}
|
||||
|
||||
function appendWithMarker(prev: FeedItem[], e: FeedEvent): FeedItem[] {
|
||||
const next = [...prev, { kind: "event" as const, event: e }];
|
||||
const eventsSinceLastPulse = next.filter((i) => i.kind === "event").length;
|
||||
if (eventsSinceLastPulse % PULSE_EVERY_N_EVENTS === 0) {
|
||||
const recentEvents = next
|
||||
.filter((i) => i.kind === "event")
|
||||
.slice(-PULSE_EVERY_N_EVENTS)
|
||||
.map((i) => (i as { kind: "event"; event: FeedEvent }).event);
|
||||
next.push({
|
||||
kind: "cluster",
|
||||
key: `cluster-${e.id}`,
|
||||
ts: e.ts,
|
||||
label: "pulse",
|
||||
bands: deriveBands(recentEvents),
|
||||
tone: "signal",
|
||||
});
|
||||
}
|
||||
if (next.length > RING_BUFFER_MAX * 2) {
|
||||
return next.slice(next.length - RING_BUFFER_MAX);
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
||||
function appendPulse(
|
||||
prev: FeedItem[],
|
||||
label: string,
|
||||
summary: string,
|
||||
): FeedItem[] {
|
||||
return [
|
||||
...prev,
|
||||
{
|
||||
kind: "pulse",
|
||||
key: `pulse-${Date.now()}`,
|
||||
ts: Date.now(),
|
||||
label,
|
||||
summary,
|
||||
tone: "signal",
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -1,133 +0,0 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* EventRow — single row in the horizontal event-feed timeline.
|
||||
*
|
||||
* No card chrome. The row is a single typographic line: mono timestamp,
|
||||
* severity dot, actor mention, action verb, channel jump, excerpt.
|
||||
*
|
||||
* Hover reveals full excerpt and selection state; click toggles selection
|
||||
* so the right rail / command line can target the event.
|
||||
*/
|
||||
|
||||
import { type ReactNode, useCallback } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export type EventSeverity = "neutral" | "signal" | "amber" | "vermilion";
|
||||
|
||||
export interface FeedEvent {
|
||||
/** Stable id from the upstream record. Used as React key. */
|
||||
id: string;
|
||||
/** Unix epoch ms. */
|
||||
ts: number;
|
||||
/** Severity tone — drives dot color and zebra fill. */
|
||||
severity: EventSeverity;
|
||||
/** Display label for the actor ("alice", "@everyone", "Carl-bot"). */
|
||||
actor: string;
|
||||
/** Verb describing the action ("sent", "flagged", "joined", "muted"). */
|
||||
action: string;
|
||||
/** Channel reference (monogram display only — no chrome). */
|
||||
channel?: string | null;
|
||||
/** Message excerpt or action payload text. Truncated when long. */
|
||||
excerpt: string;
|
||||
/** Optional metadata tag (e.g. "ai:flag", "voice:join"). */
|
||||
tag?: string | null;
|
||||
}
|
||||
|
||||
interface EventRowProps {
|
||||
event: FeedEvent;
|
||||
selected?: boolean;
|
||||
onSelect?: (id: string) => void;
|
||||
}
|
||||
|
||||
const SEVERITY_DOT: Record<EventSeverity, string> = {
|
||||
neutral: "oklch(0.46 0.02 70)",
|
||||
signal: "var(--color-signal)",
|
||||
amber: "var(--color-amber)",
|
||||
vermilion: "var(--color-vermilion)",
|
||||
};
|
||||
|
||||
const SEVERITY_FILL: Record<EventSeverity, string> = {
|
||||
neutral: "transparent",
|
||||
signal: "oklch(0.78 0.17 125 / 0.06)",
|
||||
amber: "oklch(0.80 0.15 70 / 0.07)",
|
||||
vermilion: "oklch(0.62 0.21 25 / 0.08)",
|
||||
};
|
||||
|
||||
function formatTimestamp(ts: number): string {
|
||||
const d = new Date(ts);
|
||||
const pad = (n: number) => String(n).padStart(2, "0");
|
||||
return `${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`;
|
||||
}
|
||||
|
||||
export function EventRow({ event, selected, onSelect }: EventRowProps) {
|
||||
const handleClick = useCallback(() => {
|
||||
onSelect?.(event.id);
|
||||
}, [event.id, onSelect]);
|
||||
|
||||
const dot: ReactNode = (
|
||||
<span
|
||||
aria-hidden
|
||||
className="inline-block size-1.5 shrink-0 rounded-full"
|
||||
style={{ background: SEVERITY_DOT[event.severity] }}
|
||||
/>
|
||||
);
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleClick}
|
||||
className={cn(
|
||||
"group relative flex w-full items-baseline gap-3 px-3 py-1.5 text-left font-mono text-[12px] leading-5 transition-colors",
|
||||
"hover:bg-[oklch(0.92_0.014_80_/_0.6)] focus-visible:outline focus-visible:outline-2 focus-visible:outline-[var(--color-signal)] focus-visible:outline-offset-[-2px]",
|
||||
selected && "bg-[oklch(0.92_0.014_80_/_0.8)]",
|
||||
)}
|
||||
style={{
|
||||
background: selected ? undefined : SEVERITY_FILL[event.severity],
|
||||
}}
|
||||
data-event-id={event.id}
|
||||
data-severity={event.severity}
|
||||
>
|
||||
<span
|
||||
aria-hidden
|
||||
className={cn(
|
||||
"absolute inset-y-0 left-0 w-[2px] origin-center transition-transform",
|
||||
selected ? "scale-y-100" : "scale-y-0 group-hover:scale-y-100",
|
||||
)}
|
||||
style={{ background: SEVERITY_DOT[event.severity] }}
|
||||
/>
|
||||
|
||||
<span className="w-[68px] shrink-0 text-[var(--color-ink-soft)] tabular-nums">
|
||||
{formatTimestamp(event.ts)}
|
||||
</span>
|
||||
|
||||
{dot}
|
||||
|
||||
<span className="w-[120px] shrink-0 truncate text-[var(--color-ink)]">
|
||||
{event.actor}
|
||||
</span>
|
||||
|
||||
<span className="w-[80px] shrink-0 text-[var(--color-ink-soft)]">
|
||||
{event.action}
|
||||
</span>
|
||||
|
||||
{event.channel ? (
|
||||
<span className="w-[140px] shrink-0 truncate text-[var(--color-ink-soft)]">
|
||||
{event.channel}
|
||||
</span>
|
||||
) : (
|
||||
<span className="w-[140px] shrink-0" aria-hidden />
|
||||
)}
|
||||
|
||||
<span className="min-w-0 flex-1 truncate text-[var(--color-ink)]">
|
||||
{event.excerpt}
|
||||
</span>
|
||||
|
||||
{event.tag ? (
|
||||
<span className="shrink-0 rounded-[var(--radius-r-control)] bg-[var(--color-surface-2)] px-1.5 py-px text-[10px] uppercase tracking-wide text-[var(--color-ink-soft)]">
|
||||
{event.tag}
|
||||
</span>
|
||||
) : null}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -1,138 +0,0 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* NowMarker — inline callout that breaks the feed timeline rhythm.
|
||||
*
|
||||
* Two variants: `pulse` (one-line summary) and `cluster` (horizontal stack bar
|
||||
* visualising severity distribution across a recent window). Both use a
|
||||
* border-tip on the left in signal tone; no card chrome, no shadow.
|
||||
*/
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type Tone = "signal" | "amber" | "vermilion" | "neutral";
|
||||
|
||||
interface PulseMarkerProps {
|
||||
tone?: Tone;
|
||||
label: string;
|
||||
timestamp: number;
|
||||
/** Optional small caps label on the right. */
|
||||
trailing?: string;
|
||||
}
|
||||
|
||||
interface ClusterMarkerProps {
|
||||
tone?: Tone;
|
||||
label: string;
|
||||
timestamp: number;
|
||||
/** Fractions of each severity band; must sum to 1. */
|
||||
bands: { tone: Tone; ratio: number }[];
|
||||
}
|
||||
|
||||
const TONE_TIP: Record<Tone, string> = {
|
||||
signal: "var(--color-signal)",
|
||||
amber: "var(--color-amber)",
|
||||
vermilion: "var(--color-vermilion)",
|
||||
neutral: "oklch(0.46 0.02 70)",
|
||||
};
|
||||
|
||||
const TONE_FILL: Record<Tone, string> = {
|
||||
signal: "oklch(0.78 0.17 125 / 0.12)",
|
||||
amber: "oklch(0.80 0.15 70 / 0.14)",
|
||||
vermilion: "oklch(0.62 0.21 25 / 0.12)",
|
||||
neutral: "oklch(0.46 0.02 70 / 0.08)",
|
||||
};
|
||||
|
||||
function formatTimestamp(ts: number): string {
|
||||
const d = new Date(ts);
|
||||
const pad = (n: number) => String(n).padStart(2, "0");
|
||||
return `${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`;
|
||||
}
|
||||
|
||||
function MarkerShell({
|
||||
tone,
|
||||
label,
|
||||
timestamp,
|
||||
trailing,
|
||||
children,
|
||||
}: {
|
||||
tone: Tone;
|
||||
label: string;
|
||||
timestamp: number;
|
||||
trailing?: string;
|
||||
children?: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className="relative my-2 flex items-center gap-3 px-3 py-2 font-mono text-[11px]"
|
||||
style={{ background: TONE_FILL[tone] }}
|
||||
data-marker={tone}
|
||||
>
|
||||
<span
|
||||
aria-hidden
|
||||
className="absolute inset-y-1 left-0 w-[3px]"
|
||||
style={{ background: TONE_TIP[tone] }}
|
||||
/>
|
||||
<span className="w-[68px] shrink-0 text-[var(--color-ink-soft)] tabular-nums">
|
||||
{formatTimestamp(timestamp)}
|
||||
</span>
|
||||
<span
|
||||
className="shrink-0 text-[10px] font-medium uppercase tracking-[0.18em]"
|
||||
style={{ color: TONE_TIP[tone] }}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
<span className="min-w-0 flex-1 truncate text-[var(--color-ink)]">
|
||||
{children}
|
||||
</span>
|
||||
{trailing ? (
|
||||
<span className="shrink-0 text-[10px] uppercase tracking-wide text-[var(--color-ink-soft)]">
|
||||
{trailing}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function PulseMarker({
|
||||
tone = "signal",
|
||||
label,
|
||||
timestamp,
|
||||
trailing,
|
||||
}: PulseMarkerProps) {
|
||||
return (
|
||||
<MarkerShell
|
||||
tone={tone}
|
||||
label={label}
|
||||
timestamp={timestamp}
|
||||
trailing={trailing}
|
||||
>
|
||||
{/* children rendered by parent via composition — see NowMarker union below */}
|
||||
</MarkerShell>
|
||||
);
|
||||
}
|
||||
|
||||
export function ClusterMarker({
|
||||
tone = "signal",
|
||||
label,
|
||||
timestamp,
|
||||
bands,
|
||||
}: ClusterMarkerProps) {
|
||||
return (
|
||||
<MarkerShell tone={tone} label={label} timestamp={timestamp}>
|
||||
<div className="flex h-3 w-full max-w-[280px] overflow-hidden rounded-[var(--radius-r-control)]">
|
||||
{bands.map((b) => (
|
||||
<span
|
||||
key={b.tone}
|
||||
className={cn("h-full")}
|
||||
style={{
|
||||
width: `${Math.max(0, Math.min(1, b.ratio)) * 100}%`,
|
||||
background: TONE_TIP[b.tone],
|
||||
opacity: b.tone === "neutral" ? 0.4 : 1,
|
||||
}}
|
||||
aria-hidden
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</MarkerShell>
|
||||
);
|
||||
}
|
||||
@@ -1,92 +0,0 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* DashLeftRail — 80px vertical monogram nav.
|
||||
*
|
||||
* Each item is a glyph + label. Active state uses an accent bar on the left
|
||||
* and full ink colour. No backgrounds, no boxes.
|
||||
*/
|
||||
|
||||
import {
|
||||
Activity,
|
||||
BarChart3,
|
||||
Flag,
|
||||
MessagesSquare,
|
||||
Mic,
|
||||
ShieldCheck,
|
||||
Users,
|
||||
} from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface NavItem {
|
||||
href: string;
|
||||
glyph: React.ReactNode;
|
||||
label: string;
|
||||
}
|
||||
|
||||
const ITEMS: NavItem[] = [
|
||||
{
|
||||
href: "/dashboard",
|
||||
glyph: <BarChart3 className="size-4" />,
|
||||
label: "Console",
|
||||
},
|
||||
{
|
||||
href: "/messages",
|
||||
glyph: <MessagesSquare className="size-4" />,
|
||||
label: "Messages",
|
||||
},
|
||||
{
|
||||
href: "/moderation",
|
||||
glyph: <ShieldCheck className="size-4" />,
|
||||
label: "Moderation",
|
||||
},
|
||||
{ href: "/voice", glyph: <Mic className="size-4" />, label: "Voice" },
|
||||
{ href: "/media", glyph: <Activity className="size-4" />, label: "Media" },
|
||||
{
|
||||
href: "/recordings",
|
||||
glyph: <Flag className="size-4" />,
|
||||
label: "Recordings",
|
||||
},
|
||||
{ href: "/analysis", glyph: <Users className="size-4" />, label: "Analysis" },
|
||||
];
|
||||
|
||||
export function DashLeftRail() {
|
||||
const pathname = usePathname();
|
||||
return (
|
||||
<nav
|
||||
aria-label="Console navigation"
|
||||
className="flex h-full w-20 shrink-0 flex-col items-center gap-1 border-r border-[var(--color-hairline)] bg-[var(--color-surface)] py-3"
|
||||
>
|
||||
{ITEMS.map((it) => {
|
||||
const active =
|
||||
pathname === it.href || pathname?.startsWith(`${it.href}/`);
|
||||
return (
|
||||
<Link
|
||||
key={it.href}
|
||||
href={it.href}
|
||||
className={cn(
|
||||
"group relative flex w-full flex-col items-center gap-1 py-2 text-[10px] uppercase tracking-wide transition-colors",
|
||||
active
|
||||
? "text-[var(--color-ink)]"
|
||||
: "text-[var(--color-ink-soft)] hover:text-[var(--color-ink)]",
|
||||
)}
|
||||
data-active={active ? "1" : "0"}
|
||||
>
|
||||
<span
|
||||
aria-hidden
|
||||
className={cn(
|
||||
"absolute inset-y-2 left-0 w-[2px] origin-center transition-transform",
|
||||
active ? "scale-y-100" : "scale-y-0 group-hover:scale-y-100",
|
||||
)}
|
||||
style={{ background: "var(--color-signal)" }}
|
||||
/>
|
||||
{it.glyph}
|
||||
<span className="font-mono">{it.label}</span>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
@@ -1,183 +0,0 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* DashRightRail — 320px collapsible drawer.
|
||||
*
|
||||
* Holds the live AI verdict stream, active voice speakers, and the latest
|
||||
* moderation actions. Reads from existing hooks (`useVoice`, etc.) — no
|
||||
* new fetches; just re-presentation.
|
||||
*/
|
||||
|
||||
import { ChevronRight } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useSpeakers } from "@/hooks/use-voice";
|
||||
import type { ActiveSpeaker } from "@/lib/types";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useWebSocket } from "@/lib/ws/context";
|
||||
|
||||
interface DashRightRailProps {
|
||||
pendingVerdicts?: { id: string; ts: number; text: string }[];
|
||||
recentActions?: { id: string; ts: number; verb: string; target: string }[];
|
||||
}
|
||||
|
||||
export function DashRightRail({
|
||||
pendingVerdicts = [],
|
||||
recentActions = [],
|
||||
}: DashRightRailProps) {
|
||||
const [collapsed, setCollapsed] = useState(false);
|
||||
const { subscribe } = useSpeakers();
|
||||
const ws = useWebSocket();
|
||||
const [speakers, _setSpeakers] = useState<ActiveSpeaker[]>([]);
|
||||
useEffect(() => subscribe(ws), [ws, subscribe]);
|
||||
|
||||
return (
|
||||
<aside
|
||||
className={cn(
|
||||
"relative shrink-0 border-l border-[var(--color-hairline)] bg-[var(--color-surface)] font-mono text-[11px] transition-[width]",
|
||||
collapsed ? "w-9" : "w-[320px]",
|
||||
)}
|
||||
aria-label="Live activity rail"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setCollapsed((c) => !c)}
|
||||
className={cn(
|
||||
"absolute -left-3 top-3 z-10 flex size-6 items-center justify-center rounded-full border border-[var(--color-hairline)] bg-[var(--color-canvas)] text-[var(--color-ink-soft)] transition-colors hover:text-[var(--color-ink)]",
|
||||
)}
|
||||
aria-label={
|
||||
collapsed
|
||||
? "Expand live activity rail"
|
||||
: "Collapse live activity rail"
|
||||
}
|
||||
>
|
||||
<ChevronRight
|
||||
className={cn(
|
||||
"size-3 transition-transform",
|
||||
collapsed ? "" : "rotate-180",
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
|
||||
{collapsed ? (
|
||||
<div className="flex h-full flex-col items-center gap-4 py-4">
|
||||
<Section title="ai" vertical />
|
||||
<Section title="voice" vertical />
|
||||
<Section title="mod" vertical />
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex h-full flex-col overflow-y-auto">
|
||||
<Section title="ai verdicts">
|
||||
{pendingVerdicts.length === 0 ? (
|
||||
<Empty msg="no pending verdicts" />
|
||||
) : (
|
||||
<ul className="flex flex-col gap-1.5">
|
||||
{pendingVerdicts.slice(0, 8).map((v) => (
|
||||
<li key={v.id} className="flex items-baseline gap-2">
|
||||
<span className="shrink-0 text-[var(--color-ink-soft)] tabular-nums">
|
||||
{formatTs(v.ts)}
|
||||
</span>
|
||||
<span className="min-w-0 truncate text-[var(--color-ink)]">
|
||||
{v.text}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</Section>
|
||||
|
||||
<Section title="voice">
|
||||
{speakers.length === 0 ? (
|
||||
<Empty msg="no one speaking" />
|
||||
) : (
|
||||
<ul className="flex flex-col gap-1.5">
|
||||
{speakers.slice(0, 8).map((sp) => (
|
||||
<li key={sp.userId} className="flex items-center gap-2">
|
||||
<span
|
||||
className={cn(
|
||||
"inline-block size-1.5 rounded-full",
|
||||
sp.speaking
|
||||
? "bg-[var(--color-signal)]"
|
||||
: "bg-[var(--color-ink-soft)]",
|
||||
)}
|
||||
aria-hidden
|
||||
/>
|
||||
<span className="truncate text-[var(--color-ink)]">
|
||||
{sp.username ?? sp.userId}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</Section>
|
||||
|
||||
<Section title="mod queue">
|
||||
{recentActions.length === 0 ? (
|
||||
<Empty msg="queue empty" />
|
||||
) : (
|
||||
<ul className="flex flex-col gap-1.5">
|
||||
{recentActions.slice(0, 8).map((a) => (
|
||||
<li key={a.id} className="flex items-baseline gap-2">
|
||||
<span className="shrink-0 text-[var(--color-ink-soft)] tabular-nums">
|
||||
{formatTs(a.ts)}
|
||||
</span>
|
||||
<span className="text-[var(--color-ink-soft)]">
|
||||
{a.verb}
|
||||
</span>
|
||||
<span className="min-w-0 truncate text-[var(--color-ink)]">
|
||||
{a.target}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</Section>
|
||||
|
||||
<Section title="socket">
|
||||
<div className="flex flex-col gap-0.5 text-[10px]">
|
||||
<span className="text-[var(--color-ink-soft)]">status</span>
|
||||
<span className="text-[var(--color-ink)]">{ws.status}</span>
|
||||
</div>
|
||||
</Section>
|
||||
</div>
|
||||
)}
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
function Section({
|
||||
title,
|
||||
children,
|
||||
vertical,
|
||||
}: {
|
||||
title: string;
|
||||
children?: React.ReactNode;
|
||||
vertical?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<section
|
||||
className={cn(
|
||||
"border-b border-[var(--color-hairline)] px-3 py-2.5",
|
||||
vertical && "flex flex-col items-center gap-2 border-b-0 py-4",
|
||||
)}
|
||||
>
|
||||
<h3 className="mb-1.5 text-[10px] uppercase tracking-[0.18em] text-[var(--color-ink-soft)]">
|
||||
{title}
|
||||
</h3>
|
||||
{children}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function Empty({ msg }: { msg: string }) {
|
||||
return (
|
||||
<span className="text-[10px] italic text-[var(--color-ink-soft)]">
|
||||
{msg}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function formatTs(ts: number): string {
|
||||
const d = new Date(ts);
|
||||
const pad = (n: number) => String(n).padStart(2, "0");
|
||||
return `${pad(d.getHours())}:${pad(d.getMinutes())}`;
|
||||
}
|
||||
@@ -1,115 +0,0 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* DashTopBar — 48px utility strip.
|
||||
*
|
||||
* No navigation chrome — just brand monogram, guild indicator, WS connection
|
||||
* state, clock, and focus mode. Designed to read as a single line of
|
||||
* instrument readout, not a navbar.
|
||||
*/
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useWebSocket } from "@/lib/ws/context";
|
||||
|
||||
type FocusMode = "quiet" | "standard" | "triage";
|
||||
const FOCUS_MODES: FocusMode[] = ["quiet", "standard", "triage"];
|
||||
|
||||
interface DashTopBarProps {
|
||||
guildName: string;
|
||||
botName?: string;
|
||||
}
|
||||
|
||||
function formatClock(d: Date): string {
|
||||
const pad = (n: number) => String(n).padStart(2, "0");
|
||||
return `${pad(d.getUTCHours())}:${pad(d.getUTCMinutes())}:${pad(d.getUTCSeconds())}`;
|
||||
}
|
||||
|
||||
export function DashTopBar({ guildName, botName = "GMW" }: DashTopBarProps) {
|
||||
const ws = useWebSocket();
|
||||
const [now, setNow] = useState<Date | null>(null);
|
||||
const [focus, setFocus] = useState<FocusMode>("standard");
|
||||
const [tz, setTz] = useState<"utc" | "local">("local");
|
||||
|
||||
useEffect(() => {
|
||||
setNow(new Date());
|
||||
const id = window.setInterval(() => setNow(new Date()), 1000);
|
||||
return () => window.clearInterval(id);
|
||||
}, []);
|
||||
|
||||
const connected = ws.status === "connected";
|
||||
|
||||
return (
|
||||
<header
|
||||
className={cn(
|
||||
"flex h-12 items-center justify-between gap-4 border-b border-[var(--color-hairline)] bg-[var(--color-surface)] px-4 font-mono text-[11px]",
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="display text-base font-medium text-[var(--color-ink)]">
|
||||
{botName}
|
||||
</span>
|
||||
<span className="text-[var(--color-ink-soft)]">·</span>
|
||||
<span className="text-[var(--color-ink-soft)]">{guildName}</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span
|
||||
aria-hidden
|
||||
className={cn(
|
||||
"inline-block size-1.5 rounded-full",
|
||||
connected
|
||||
? "bg-[var(--color-signal)]"
|
||||
: "bg-[var(--color-vermilion)]",
|
||||
)}
|
||||
style={{
|
||||
boxShadow: connected
|
||||
? "0 0 0 0 oklch(from var(--color-signal) l c h / 0.45)"
|
||||
: "none",
|
||||
}}
|
||||
/>
|
||||
<span className="uppercase tracking-[0.18em] text-[var(--color-ink-soft)]">
|
||||
{ws.status}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setTz((t) => (t === "utc" ? "local" : "utc"))}
|
||||
className="rounded-[var(--radius-r-control)] px-2 py-0.5 text-[var(--color-ink-soft)] transition-colors hover:bg-[var(--color-surface-2)] hover:text-[var(--color-ink)]"
|
||||
aria-label="Toggle UTC / local timezone"
|
||||
>
|
||||
{now
|
||||
? tz === "utc"
|
||||
? `${formatClock(now)} UTC`
|
||||
: formatLocal(now)
|
||||
: "--:--:--"}
|
||||
</button>
|
||||
|
||||
<div className="flex gap-0.5 rounded-[var(--radius-r-control)] bg-[var(--color-surface-2)] p-0.5">
|
||||
{FOCUS_MODES.map((m) => (
|
||||
<button
|
||||
key={m}
|
||||
type="button"
|
||||
onClick={() => setFocus(m)}
|
||||
className={cn(
|
||||
"rounded-[var(--radius-r-control)] px-2 py-0.5 text-[10px] uppercase tracking-wide transition-colors",
|
||||
focus === m
|
||||
? "bg-[var(--color-canvas)] text-[var(--color-ink)]"
|
||||
: "text-[var(--color-ink-soft)] hover:text-[var(--color-ink)]",
|
||||
)}
|
||||
>
|
||||
{m}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
||||
function formatLocal(d: Date): string {
|
||||
const pad = (n: number) => String(n).padStart(2, "0");
|
||||
return `${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`;
|
||||
}
|
||||
@@ -1,99 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { motion } from "motion/react";
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { navItems } from "@/lib/navigation";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const titleFromPath: Record<string, string> = {
|
||||
"/dashboard": "Overview",
|
||||
"/messages": "Messages",
|
||||
"/voice": "Voice",
|
||||
"/media": "Media",
|
||||
"/recordings": "Recordings",
|
||||
"/moderation": "Moderation",
|
||||
"/analysis": "Analysis",
|
||||
};
|
||||
|
||||
export function Spine() {
|
||||
const pathname = usePathname();
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Desktop rail */}
|
||||
<nav className="fixed left-0 top-0 z-30 hidden h-svh w-[68px] flex-col items-center gap-1 border-r border-[var(--color-hairline)] bg-[var(--color-canvas)]/80 py-4 backdrop-blur-md md:flex">
|
||||
<Link
|
||||
href="/dashboard"
|
||||
className="mb-3 flex size-9 items-center justify-center rounded-[var(--radius-r-control)] bg-[var(--color-signal)] text-sm font-black text-[var(--color-signal-ink)]"
|
||||
aria-label="GMW"
|
||||
>
|
||||
B
|
||||
</Link>
|
||||
{navItems.map((item) => {
|
||||
const active = pathname.startsWith(item.matchPrefix);
|
||||
const Icon = item.icon;
|
||||
return (
|
||||
<Link
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
className={cn(
|
||||
"group relative flex size-11 items-center justify-center rounded-[var(--radius-r)] transition-colors",
|
||||
active
|
||||
? "text-[var(--color-signal)]"
|
||||
: "text-[var(--color-ink-soft)] hover:text-[var(--color-ink)]",
|
||||
)}
|
||||
aria-current={active ? "page" : undefined}
|
||||
>
|
||||
{active && (
|
||||
<motion.span
|
||||
layoutId="spine-active"
|
||||
className="absolute left-0 top-1/2 size-1 -translate-y-1/2 rounded-full bg-[var(--color-signal)]"
|
||||
transition={{ type: "spring", stiffness: 380, damping: 30 }}
|
||||
/>
|
||||
)}
|
||||
<Icon className="size-5" />
|
||||
<span className="pointer-events-none absolute left-full ml-2 hidden whitespace-nowrap rounded-[var(--radius-r-control)] bg-[var(--color-ink)] px-2 py-1 text-xs font-medium text-[var(--color-canvas)] opacity-0 transition-opacity group-hover:opacity-100 md:block">
|
||||
{item.label}
|
||||
</span>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
|
||||
{/* Mobile bottom tab-bar */}
|
||||
<nav className="fixed inset-x-0 bottom-0 z-30 flex h-16 items-stretch border-t border-[var(--color-hairline)] bg-[var(--color-canvas)]/90 backdrop-blur-md md:hidden">
|
||||
{navItems.map((item) => {
|
||||
const active = pathname.startsWith(item.matchPrefix);
|
||||
const Icon = item.icon;
|
||||
return (
|
||||
<Link
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
className={cn(
|
||||
"flex flex-1 flex-col items-center justify-center gap-0.5 text-[10px] font-medium transition-colors",
|
||||
active
|
||||
? "text-[var(--color-signal)]"
|
||||
: "text-[var(--color-ink-soft)]",
|
||||
)}
|
||||
aria-current={active ? "page" : undefined}
|
||||
>
|
||||
<Icon className="size-5" />
|
||||
{item.label}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function PageTitle() {
|
||||
const pathname = usePathname();
|
||||
const key =
|
||||
Object.keys(titleFromPath).find((k) => pathname.startsWith(k)) ??
|
||||
"/dashboard";
|
||||
return (
|
||||
<span className="font-semibold max-md:hidden">{titleFromPath[key]}</span>
|
||||
);
|
||||
}
|
||||
@@ -1,66 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useChatbot } from "@/components/chatbot/chatbot-context";
|
||||
import { GuildSelector } from "@/components/shared/guild-selector";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useWebSocket } from "@/lib/ws/context";
|
||||
import { PageTitle } from "./spine";
|
||||
import { ThemeToggle } from "./theme-toggle";
|
||||
|
||||
const statusTone: Record<string, string> = {
|
||||
connected: "bg-[var(--color-signal)]",
|
||||
connecting: "bg-[var(--color-amber)]",
|
||||
disconnected: "bg-[var(--color-ink-soft)]",
|
||||
error: "bg-[var(--color-vermilion)]",
|
||||
};
|
||||
|
||||
export function StatusBar({
|
||||
guildId,
|
||||
onGuildChange,
|
||||
}: {
|
||||
guildId: string;
|
||||
onGuildChange: (g: string) => void;
|
||||
}) {
|
||||
const ws = useWebSocket();
|
||||
const { expression } = useChatbot();
|
||||
const [clock, setClock] = useState("--:--:--");
|
||||
|
||||
useEffect(() => {
|
||||
const tick = () =>
|
||||
setClock(new Date().toLocaleTimeString("en-GB", { hour12: false }));
|
||||
tick();
|
||||
const id = setInterval(tick, 1000);
|
||||
return () => clearInterval(id);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<header className="sticky top-0 z-20 flex h-14 shrink-0 items-center gap-2 border-b border-[var(--color-hairline)] bg-[var(--color-canvas)]/85 px-4 backdrop-blur-md md:px-6">
|
||||
<PageTitle />
|
||||
<div className="ms-auto flex items-center gap-3">
|
||||
<span className="hidden items-center gap-1.5 text-xs text-[var(--color-ink-soft)] sm:flex">
|
||||
<span className={cn("size-2 rounded-full", statusTone[ws.status])} />
|
||||
<span className="mono uppercase">{ws.status}</span>
|
||||
</span>
|
||||
<span className="hidden text-xs text-[var(--color-ink-soft)] md:inline">
|
||||
<span
|
||||
className={cn(
|
||||
"font-mono",
|
||||
expression !== "idle" && "text-[var(--color-signal)]",
|
||||
)}
|
||||
>
|
||||
{expression}
|
||||
</span>
|
||||
</span>
|
||||
<span className="hidden font-mono text-xs text-[var(--color-ink-soft)] lg:inline">
|
||||
{clock}
|
||||
</span>
|
||||
<GuildSelector
|
||||
value={guildId}
|
||||
onChange={(g) => onGuildChange(g ?? "")}
|
||||
/>
|
||||
<ThemeToggle />
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
@@ -1,38 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { Moon, Sun } from "lucide-react";
|
||||
import { motion } from "motion/react";
|
||||
import { useTheme } from "next-themes";
|
||||
import { useEffect, useState } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export function ThemeToggle() {
|
||||
const { resolvedTheme, setTheme } = useTheme();
|
||||
const [mounted, setMounted] = useState(false);
|
||||
useEffect(() => setMounted(true), []);
|
||||
|
||||
const isDark = resolvedTheme === "dark";
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Toggle theme"
|
||||
onClick={() => setTheme(isDark ? "light" : "dark")}
|
||||
className="flex size-9 items-center justify-center rounded-[var(--radius-r-control)] text-[var(--color-ink-soft)] transition-colors hover:bg-[var(--color-surface-2)] hover:text-[var(--color-ink)] focus-visible:ring-2 focus-visible:ring-[var(--color-ring)]"
|
||||
>
|
||||
{mounted && (
|
||||
<motion.span
|
||||
key={isDark ? "moon" : "sun"}
|
||||
initial={{ rotate: -90, opacity: 0 }}
|
||||
animate={{ rotate: 0, opacity: 1 }}
|
||||
transition={{ type: "spring", stiffness: 360, damping: 26 }}
|
||||
className={cn(
|
||||
isDark ? "text-[var(--color-signal)]" : "text-[var(--color-amber)]",
|
||||
)}
|
||||
>
|
||||
{isDark ? <Moon className="size-4" /> : <Sun className="size-4" />}
|
||||
</motion.span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -1,59 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { Pause, Play, SkipForward, Volume2 } from "lucide-react";
|
||||
import { motion } from "motion/react";
|
||||
import { Button } from "@/components/primitives/button";
|
||||
import { useMediaSkip, useMediaState, useMediaWsSync } from "@/hooks";
|
||||
import { useMediaPlayer } from "@/lib/hooks/use-media-player";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useWebSocket } from "@/lib/ws/context";
|
||||
|
||||
export function MiniPlayer() {
|
||||
const ws = useWebSocket();
|
||||
const { data: state } = useMediaState();
|
||||
const { playing, current } = useMediaPlayer();
|
||||
useMediaWsSync(ws);
|
||||
const skip = useMediaSkip();
|
||||
|
||||
if (!current) return null;
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ y: 100, opacity: 0 }}
|
||||
animate={{ y: 0, opacity: 1 }}
|
||||
exit={{ y: 100, opacity: 0 }}
|
||||
transition={{ type: "spring", stiffness: 320, damping: 30 }}
|
||||
className={cn(
|
||||
"pointer-events-auto fixed inset-x-0 bottom-20 z-30 mx-auto w-[calc(100%-2rem)] max-w-[480px]",
|
||||
"surface flex items-center gap-3 px-3 py-2 text-sm",
|
||||
)}
|
||||
>
|
||||
<img
|
||||
src={current.thumbnailUrl ?? "/favicon.ico"}
|
||||
alt={current.title}
|
||||
className="size-9 rounded-[var(--radius-r-control)] object-cover"
|
||||
/>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate font-medium">{current.title}</div>
|
||||
<div className="text-xs text-[var(--color-ink-soft)] mono">
|
||||
{current.source}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<Button size="sm" variant="ghost" onClick={() => skip.mutate()}>
|
||||
<SkipForward className="size-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant={playing ? "primary" : "ghost"}
|
||||
onClick={() =>
|
||||
state?.playing ? void skip.mutate() : void skip.mutate()
|
||||
}
|
||||
>
|
||||
{playing ? <Pause className="size-4" /> : <Play className="size-4" />}
|
||||
</Button>
|
||||
<Volume2 className="size-4 text-[var(--color-ink-soft)]" />
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
@@ -1,159 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Badge } from "@/components/primitives/badge";
|
||||
import { Progress } from "@/components/primitives/progress";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface AiAnalysisPanelProps {
|
||||
status?: string | null;
|
||||
severity?: string | null;
|
||||
confidence?: number | null;
|
||||
flags?: string[] | string | null;
|
||||
categories?: string[] | string | null;
|
||||
action?: string | null;
|
||||
score?: number | null;
|
||||
analysis?: string | null;
|
||||
}
|
||||
|
||||
const severityColor: Record<string, string> = {
|
||||
none: "text-[var(--color-ink-soft)]",
|
||||
low: "text-[var(--color-ink-soft)]",
|
||||
medium: "text-[var(--color-amber)]",
|
||||
high: "text-orange-500",
|
||||
critical: "text-[var(--color-vermilion)]",
|
||||
};
|
||||
|
||||
export function AiAnalysisPanel({
|
||||
status,
|
||||
severity,
|
||||
confidence,
|
||||
flags,
|
||||
categories,
|
||||
action,
|
||||
score,
|
||||
analysis,
|
||||
}: AiAnalysisPanelProps) {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
|
||||
if (!status || status === "pending") {
|
||||
return (
|
||||
<div className="surface-2 p-3">
|
||||
<span className="text-xs text-[var(--color-ink-soft)]/60">
|
||||
AI analysis pending
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const flagsArray =
|
||||
typeof flags === "string" ? (flags ? JSON.parse(flags) : []) : flags || [];
|
||||
const categoriesArray =
|
||||
typeof categories === "string"
|
||||
? categories
|
||||
? JSON.parse(categories)
|
||||
: []
|
||||
: categories || [];
|
||||
|
||||
const statusTone =
|
||||
status === "clean"
|
||||
? "signal"
|
||||
: status === "flagged"
|
||||
? "vermilion"
|
||||
: status === "warn"
|
||||
? "amber"
|
||||
: "neutral";
|
||||
|
||||
return (
|
||||
<div className="surface-2 flex flex-col gap-2.5 p-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs font-semibold uppercase tracking-wide text-[var(--color-ink-soft)]">
|
||||
AI Analysis
|
||||
</span>
|
||||
<Badge tone={statusTone}>{status}</Badge>
|
||||
</div>
|
||||
|
||||
{severity && (
|
||||
<div className="flex items-center gap-2 text-xs">
|
||||
<span className="text-[var(--color-ink-soft)]/60">Severity:</span>
|
||||
<span
|
||||
className={cn(
|
||||
"font-mono font-medium",
|
||||
severityColor[severity] || "",
|
||||
)}
|
||||
>
|
||||
{severity}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{confidence !== null && confidence !== undefined && (
|
||||
<div className="flex items-center gap-2 text-xs">
|
||||
<span className="text-[var(--color-ink-soft)]/60">Confidence</span>
|
||||
<Progress
|
||||
value={confidence * 100}
|
||||
max={100}
|
||||
tone="signal"
|
||||
showLabel
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{score !== null && score !== undefined && (
|
||||
<div className="flex items-center justify-between text-xs">
|
||||
<span className="text-[var(--color-ink-soft)]/60">Score</span>
|
||||
<span className="font-mono">{score.toFixed(2)}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{flagsArray.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{flagsArray.map((f: string) => (
|
||||
<Badge key={f} tone="vermilion">
|
||||
{f}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{categoriesArray.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{categoriesArray.map((c: string) => (
|
||||
<Badge key={c} tone="neutral">
|
||||
{c}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{analysis && (
|
||||
<div className="border-l-2 border-[var(--color-hairline)] pl-2">
|
||||
<p
|
||||
className={cn(
|
||||
"text-xs leading-relaxed text-[var(--color-ink-soft)]",
|
||||
!expanded && "line-clamp-3",
|
||||
)}
|
||||
>
|
||||
{analysis}
|
||||
</p>
|
||||
{analysis.length > 120 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setExpanded((v) => !v)}
|
||||
className="mt-1 text-[10px] font-medium uppercase tracking-wide text-[var(--color-ink-soft)]/50 transition-colors hover:text-[var(--color-ink)]"
|
||||
>
|
||||
{expanded ? "Show less" : "Show more"}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{action && action !== "none" && (
|
||||
<div className="text-xs">
|
||||
<span className="text-[var(--color-ink-soft)]/60">Recommended: </span>
|
||||
<span className="font-mono text-[var(--color-amber)]">{action}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,44 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import type { AiSeverity, AiStatus } from "@/lib/types";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const severityTick: Record<NonNullable<AiSeverity>, string> = {
|
||||
none: "border-[var(--color-signal)]/30",
|
||||
low: "border-[var(--color-amber)]/50",
|
||||
medium: "border-[var(--color-amber)]",
|
||||
high: "border-orange-500/80",
|
||||
critical: "border-[var(--color-vermilion)]",
|
||||
};
|
||||
|
||||
const statusBadge: Record<NonNullable<AiStatus>, string> = {
|
||||
pending: "bg-[var(--color-ink-soft)]/20 text-[var(--color-ink-soft)]",
|
||||
processing: "bg-[var(--color-amber)]/15 text-[var(--color-amber)]",
|
||||
clean: "bg-[var(--color-signal)]/15 text-[var(--color-signal)]",
|
||||
warn: "bg-[var(--color-amber)]/15 text-[var(--color-amber)]",
|
||||
flagged: "bg-[var(--color-vermilion)]/15 text-[var(--color-vermilion)]",
|
||||
error: "bg-[var(--color-vermilion)]/15 text-[var(--color-vermilion)]",
|
||||
};
|
||||
|
||||
export function SeverityTick({ severity }: { severity?: AiSeverity | null }) {
|
||||
const cls = severity ? severityTick[severity] : "border-transparent";
|
||||
return (
|
||||
<span
|
||||
className={cn("absolute left-0 top-0 h-full w-0.5 border-l-2", cls)}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function AiStatusBadge({ status }: { status?: AiStatus | null }) {
|
||||
if (!status) return null;
|
||||
return (
|
||||
<span className={cn("pill", statusBadge[status])}>
|
||||
<span
|
||||
className="size-1.5 rounded-full"
|
||||
style={{ background: "currentColor" }}
|
||||
/>
|
||||
<span className="ml-1 text-[10px] font-medium uppercase">{status}</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -1,35 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import type { AttachmentRef } from "@/lib/types";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export function AttachmentsGrid({
|
||||
attachments,
|
||||
onOpen,
|
||||
}: {
|
||||
attachments: AttachmentRef[];
|
||||
onOpen: (url: string) => void;
|
||||
}) {
|
||||
if (attachments.length === 0) return null;
|
||||
const images = attachments.filter((a) => /image/i.test(a.contentType ?? ""));
|
||||
if (images.length === 0) return null;
|
||||
return (
|
||||
<div className="mt-2 grid grid-cols-2 gap-2 sm:grid-cols-3">
|
||||
{images.map((a, i) => (
|
||||
<button
|
||||
key={`${a.url}-${i}`}
|
||||
type="button"
|
||||
onClick={() => onOpen(a.url)}
|
||||
className="group relative aspect-video overflow-hidden rounded-[var(--radius-r-control)] border border-[var(--color-hairline)]"
|
||||
>
|
||||
<img
|
||||
src={a.url}
|
||||
alt={a.name}
|
||||
loading="lazy"
|
||||
className="size-full object-cover transition-transform duration-200 group-hover:scale-105"
|
||||
/>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,74 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { X } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { Dialog } from "@/components/primitives/dialog";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export function Lightbox({
|
||||
open,
|
||||
onClose,
|
||||
src,
|
||||
alt,
|
||||
images = [],
|
||||
initialIndex = 0,
|
||||
}: {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
src?: string;
|
||||
alt?: string;
|
||||
images?: Array<{ src: string; alt?: string }>;
|
||||
initialIndex?: number;
|
||||
}) {
|
||||
const gallery = images.length > 0 ? images : src ? [{ src, alt }] : [];
|
||||
const [idx, setIdx] = useState(initialIndex);
|
||||
useEffect(() => setIdx(initialIndex), [initialIndex]);
|
||||
if (!gallery.length) return null;
|
||||
const current = gallery[idx];
|
||||
const hasNav = gallery.length > 1;
|
||||
return (
|
||||
<Dialog
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
className="p-0 border-0 bg-transparent shadow-none"
|
||||
>
|
||||
<div className="relative flex items-center justify-center p-4">
|
||||
{hasNav && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
setIdx((i) => (i - 1 + gallery.length) % gallery.length)
|
||||
}
|
||||
className="absolute left-2 top-1/2 -translate-y-1/2 rounded-full bg-black/40 p-2 text-white hover:bg-black/60"
|
||||
aria-label="Previous"
|
||||
>
|
||||
◀
|
||||
</button>
|
||||
)}
|
||||
<img
|
||||
src={current.src}
|
||||
alt={current.alt ?? alt ?? "attachment"}
|
||||
className="max-h-[80vh] max-w-full rounded-[var(--radius-r)] object-contain"
|
||||
/>
|
||||
{hasNav && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIdx((i) => (i + 1) % gallery.length)}
|
||||
className="absolute right-16 top-1/2 -translate-y-1/2 rounded-full bg-black/40 p-2 text-white hover:bg-black/60"
|
||||
aria-label="Next"
|
||||
>
|
||||
▶
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="absolute top-2 right-2 rounded-full bg-black/40 p-1.5 text-white hover:bg-black/60"
|
||||
aria-label="Close"
|
||||
>
|
||||
<X className="size-4" />
|
||||
</button>
|
||||
</div>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -1,124 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Avatar } from "@/components/primitives/avatar";
|
||||
import { Badge } from "@/components/primitives/badge";
|
||||
import {
|
||||
getMessageChannelLabel,
|
||||
renderMessageContent,
|
||||
safeParseJsonArray,
|
||||
} from "@/lib/format";
|
||||
import type { AttachmentRef, MessageRecord } from "@/lib/types";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { AiStatusBadge, SeverityTick } from "./ai-status-badge";
|
||||
import { AttachmentsGrid } from "./attachments-grid";
|
||||
import { Lightbox } from "./lightbox";
|
||||
|
||||
function extractAttachments(metadata?: string | null): AttachmentRef[] {
|
||||
if (!metadata) return [];
|
||||
try {
|
||||
const m = JSON.parse(metadata);
|
||||
return (m?.attachments ?? []) as AttachmentRef[];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function fmtFull(ts?: number): string {
|
||||
if (!ts) return "";
|
||||
return new Date(ts * 1000).toLocaleString();
|
||||
}
|
||||
|
||||
export interface MessageDetailProps {
|
||||
message: MessageRecord;
|
||||
channelLabel?: string;
|
||||
}
|
||||
|
||||
export function MessageDetailView({
|
||||
message: msg,
|
||||
channelLabel,
|
||||
}: MessageDetailProps) {
|
||||
const [img, setImg] = useState<string | null>(null);
|
||||
const severity = msg.ai_severity ?? "none";
|
||||
const flags = safeParseJsonArray(msg.ai_moderation_flags || "[]");
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="mb-4 flex items-start gap-3">
|
||||
<Avatar src={msg.avatar_url} name={msg.username} size={40} />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2.5 flex-wrap">
|
||||
<span className="font-semibold">{msg.username}</span>
|
||||
<span className="mono text-xs text-[var(--color-ink-soft)]">
|
||||
{fmtFull(msg.created_at)}
|
||||
</span>
|
||||
<AiStatusBadge status={msg.ai_status ?? null} />
|
||||
</div>
|
||||
<div className="mt-2 text-xs text-[var(--color-ink-soft)]">
|
||||
#{channelLabel ?? getMessageChannelLabel(msg)}
|
||||
{msg.thread_id && <span className="mx-1 opacity-40">·</span>}
|
||||
{msg.thread_id && <span>Thread {msg.thread_id.slice(0, 8)}</span>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
"relative rounded-[var(--radius-r)] p-4",
|
||||
severity === "critical"
|
||||
? "border-l-2 border-[var(--color-vermilion)]"
|
||||
: severity === "high"
|
||||
? "border-l-2 border-[var(--color-amber)]"
|
||||
: "border border-[var(--color-hairline)]",
|
||||
)}
|
||||
>
|
||||
<SeverityTick severity={severity} />
|
||||
<div className="text-sm leading-relaxed">
|
||||
{msg.deleted_at ? (
|
||||
<span className="italic text-[var(--color-ink-soft)]">
|
||||
message deleted
|
||||
</span>
|
||||
) : (
|
||||
renderMessageContent(
|
||||
msg.edited_content ?? msg.content,
|
||||
msg.metadata,
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{flags.length > 0 && (
|
||||
<div className="mt-3 flex flex-wrap gap-1.5">
|
||||
{flags.map((f) => (
|
||||
<Badge key={f} tone="vermilion">
|
||||
{f}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{msg.ai_analysis && (
|
||||
<div className="mt-3 rounded-[var(--radius-r)] bg-[var(--color-surface-2)] p-3 text-xs">
|
||||
<span className="font-medium text-[var(--color-amber)]">
|
||||
AI analysis:
|
||||
</span>{" "}
|
||||
<span className="text-[var(--color-ink-soft)]">
|
||||
{msg.ai_analysis}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{extractAttachments(msg.metadata).length > 0 && (
|
||||
<AttachmentsGrid
|
||||
attachments={extractAttachments(msg.metadata)}
|
||||
onOpen={(u) => setImg(u)}
|
||||
/>
|
||||
)}
|
||||
<Lightbox
|
||||
open={!!img}
|
||||
onClose={() => setImg(null)}
|
||||
src={img ?? undefined}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,87 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { Avatar } from "@/components/primitives/avatar";
|
||||
import { renderMessageContent } from "@/lib/format";
|
||||
import type { AiSeverity, MessageRecord } from "@/lib/types";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { AiStatusBadge, SeverityTick } from "./ai-status-badge";
|
||||
|
||||
function fmtTime(ts?: number): string {
|
||||
if (!ts) return "";
|
||||
return new Date(ts * 1000).toLocaleTimeString([], {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
hour12: false,
|
||||
});
|
||||
}
|
||||
|
||||
const severityColor: Record<NonNullable<AiSeverity>, string> = {
|
||||
none: "text-[var(--color-ink-soft)]",
|
||||
low: "text-[var(--color-amber)]",
|
||||
medium: "text-[var(--color-amber)]",
|
||||
high: "text-orange-500",
|
||||
critical: "text-[var(--color-vermilion)]",
|
||||
};
|
||||
|
||||
export interface MessageEntryProps {
|
||||
message: MessageRecord;
|
||||
selected: boolean;
|
||||
onSelect: () => void;
|
||||
onAvatarClick?: () => void;
|
||||
}
|
||||
|
||||
export function MessageEntry({
|
||||
message: msg,
|
||||
selected,
|
||||
onSelect,
|
||||
}: MessageEntryProps) {
|
||||
const severity = (msg.ai_severity ?? "none") as AiSeverity;
|
||||
const status = msg.ai_status ?? null;
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
data-selected={selected}
|
||||
onClick={onSelect}
|
||||
className={cn(
|
||||
"group relative mb-1.5 flex w-full items-start gap-2.5 rounded-[var(--radius-r)] p-2.5 text-left cursor-pointer",
|
||||
"transition-all hover:bg-[var(--color-surface-2)]",
|
||||
selected && "bg-[var(--color-signal)]/6",
|
||||
)}
|
||||
>
|
||||
<SeverityTick severity={severity} />
|
||||
<Avatar src={msg.avatar_url} name={msg.username} size={32} />
|
||||
<div className="min-w-0 flex-1 space-y-1">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="text-sm font-medium">{msg.username}</span>
|
||||
<span className="text-xs text-[var(--color-ink-soft)] mono">
|
||||
{fmtTime(msg.created_at)}
|
||||
</span>
|
||||
{status && <AiStatusBadge status={status} />}
|
||||
{severity !== "none" && (
|
||||
<span
|
||||
className={cn(
|
||||
"text-[10px] font-bold uppercase",
|
||||
severityColor[severity],
|
||||
)}
|
||||
>
|
||||
{severity}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-sm leading-relaxed">
|
||||
{msg.deleted_at ? (
|
||||
<span className="italic text-[var(--color-ink-soft)]">
|
||||
message deleted
|
||||
</span>
|
||||
) : (
|
||||
renderMessageContent(
|
||||
msg.edited_content ?? msg.content,
|
||||
msg.metadata,
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -1,56 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import type { MessageRecord } from "@/lib/types";
|
||||
import { MessageEntry } from "./message-entry";
|
||||
|
||||
export { MessageEntry };
|
||||
|
||||
export function MessageList({
|
||||
messages,
|
||||
selectedId,
|
||||
onSelect,
|
||||
hasMore,
|
||||
onLoadMore,
|
||||
isLoadingMore,
|
||||
}: {
|
||||
messages: MessageRecord[];
|
||||
selectedId: string | null;
|
||||
onSelect: (id: string) => void;
|
||||
hasMore?: boolean;
|
||||
onLoadMore?: () => void;
|
||||
isLoadingMore?: boolean;
|
||||
}) {
|
||||
if (messages.length === 0) {
|
||||
return (
|
||||
<div className="py-10 text-center">
|
||||
<p className="text-sm text-[var(--color-ink-soft)]">
|
||||
No messages found.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<>
|
||||
<div className="space-y-0.5">
|
||||
{messages.map((m) => (
|
||||
<MessageEntry
|
||||
key={m.id}
|
||||
message={m}
|
||||
selected={selectedId === m.id}
|
||||
onSelect={() => onSelect(m.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
{hasMore && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onLoadMore}
|
||||
disabled={isLoadingMore}
|
||||
className="mt-3 w-full text-center text-xs text-[var(--color-amber)] hover:underline disabled:opacity-50"
|
||||
>
|
||||
{isLoadingMore ? "Loading…" : "Load more"}
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,82 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { Search } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { Dialog } from "@/components/primitives/dialog";
|
||||
import { Input } from "@/components/primitives/input";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export interface Message {
|
||||
id: string;
|
||||
content: string;
|
||||
username: string;
|
||||
channel: string;
|
||||
time: string;
|
||||
}
|
||||
|
||||
export interface SearchOverlayProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
results: Message[];
|
||||
onSelect: (msg: Message) => void;
|
||||
}
|
||||
|
||||
export function SearchOverlay({
|
||||
open,
|
||||
onClose,
|
||||
results,
|
||||
onSelect,
|
||||
}: SearchOverlayProps) {
|
||||
const [query, setQuery] = useState("");
|
||||
|
||||
const filtered = query
|
||||
? results.filter(
|
||||
(m) =>
|
||||
m.content.toLowerCase().includes(query.toLowerCase()) ||
|
||||
m.username.toLowerCase().includes(query.toLowerCase()),
|
||||
)
|
||||
: results;
|
||||
|
||||
return (
|
||||
<Dialog open={open} onClose={onClose} className="p-0 max-w-xl">
|
||||
<div className="p-4">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 size-4 -translate-y-1/2 text-[var(--color-ink-soft)]" />
|
||||
<Input
|
||||
autoFocus
|
||||
placeholder="Search messages… (Esc to close)"
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
className="pl-9 font-mono"
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-3 max-h-[420px] overflow-y-auto">
|
||||
{filtered.length === 0 ? (
|
||||
<p className="py-6 text-center text-sm text-[var(--color-ink-soft)]">
|
||||
No results.
|
||||
</p>
|
||||
) : (
|
||||
<div className="flex flex-col gap-1">
|
||||
{filtered.slice(0, 32).map((m) => (
|
||||
<button
|
||||
key={m.id}
|
||||
type="button"
|
||||
onClick={() => onSelect(m)}
|
||||
className="group flex flex-col items-start gap-1 rounded-[var(--radius-r-control)] px-2.5 py-2 text-left transition-colors hover:bg-[var(--color-surface-2)]"
|
||||
>
|
||||
<span className="text-xs text-[var(--color-ink-soft)] group-hover:text-[var(--color-ink)]">
|
||||
#{m.channel} · {m.username}
|
||||
</span>
|
||||
<span className="text-sm">{m.content}</span>
|
||||
<span className="text-[10px] text-[var(--color-ink-soft)]/60">
|
||||
{m.time}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -1,294 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
AlertTriangle,
|
||||
Ban,
|
||||
CheckCircle2,
|
||||
Loader2,
|
||||
MicOff,
|
||||
ShieldAlert,
|
||||
Trash2,
|
||||
UserX,
|
||||
XCircle,
|
||||
} from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { Badge } from "@/components/primitives/badge";
|
||||
import { EmptyState, LoadingSkeleton } from "@/components/shared";
|
||||
import { useModerationActions, useModerationStats } from "@/hooks";
|
||||
import { renderMessageContent } from "@/lib/format";
|
||||
import type {
|
||||
ModerationAction,
|
||||
ModerationActionType,
|
||||
ModerationStats,
|
||||
} from "@/lib/types";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const ACTION_META: Record<
|
||||
ModerationActionType,
|
||||
{ label: string; Icon: typeof Trash2; tone: "vermilion" | "amber" }
|
||||
> = {
|
||||
delete_message: { label: "Delete message", Icon: Trash2, tone: "vermilion" },
|
||||
mute_user: { label: "Mute user", Icon: MicOff, tone: "amber" },
|
||||
warn_user: { label: "Warn user", Icon: AlertTriangle, tone: "amber" },
|
||||
kick_user: { label: "Kick user", Icon: UserX, tone: "amber" },
|
||||
ban_user: { label: "Ban user", Icon: Ban, tone: "vermilion" },
|
||||
};
|
||||
|
||||
const STATUS_META: Record<
|
||||
ModerationAction["status"],
|
||||
{ label: string; tone: "signal" | "vermilion" | "amber" }
|
||||
> = {
|
||||
executed: { label: "Executed", tone: "signal" },
|
||||
failed: { label: "Failed", tone: "vermilion" },
|
||||
pending: { label: "Pending", tone: "amber" },
|
||||
};
|
||||
|
||||
function fmtTime(ts: number | null): string {
|
||||
if (!ts) return "—";
|
||||
const d = new Date(ts);
|
||||
const diff = Date.now() - ts;
|
||||
const hours = Math.floor(diff / 3600000);
|
||||
const rel =
|
||||
hours < 1
|
||||
? "baru saja"
|
||||
: hours < 24
|
||||
? `${hours} jam lalu`
|
||||
: `${Math.floor(hours / 24)} hari lalu`;
|
||||
return `${d.toLocaleString("id-ID")} (${rel})`;
|
||||
}
|
||||
|
||||
const EMPTY_ACTION_RATE = {
|
||||
total: 0,
|
||||
executed: 0,
|
||||
failed: 0,
|
||||
pending: 0,
|
||||
failed_rate: 0,
|
||||
};
|
||||
|
||||
export function ModerationSection({
|
||||
initialStats,
|
||||
initialActions,
|
||||
}: {
|
||||
initialStats?: ModerationStats;
|
||||
initialActions?: ModerationAction[];
|
||||
} = {}) {
|
||||
const [status, setStatus] = useState<string>("");
|
||||
const [actionType, setActionType] = useState<string>("");
|
||||
const { data: stats } = useModerationStats(initialStats);
|
||||
const { data: actions, isLoading: actionsLoading } = useModerationActions(
|
||||
status,
|
||||
actionType,
|
||||
initialActions,
|
||||
);
|
||||
|
||||
const s = stats ?? EMPTY_ACTION_RATE;
|
||||
|
||||
const statusFilters = ["", "executed", "failed", "pending"];
|
||||
const typeFilters = [
|
||||
"",
|
||||
"delete_message",
|
||||
"warn_user",
|
||||
"kick_user",
|
||||
"ban_user",
|
||||
"mute_user",
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Summary cards */}
|
||||
<div className="grid grid-cols-2 gap-3 md:grid-cols-4">
|
||||
<SummaryCard label="Total aksi" value={s.total} />
|
||||
<SummaryCard
|
||||
label="Executed"
|
||||
value={s.executed}
|
||||
tone="signal"
|
||||
hint={undefined}
|
||||
/>
|
||||
<SummaryCard
|
||||
label="Failed"
|
||||
value={s.failed}
|
||||
tone="vermilion"
|
||||
hint={s.total > 0 ? `${s.failed_rate}%` : undefined}
|
||||
/>
|
||||
<SummaryCard label="Pending" value={s.pending} tone="amber" />
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
<span className="text-[10px] font-semibold uppercase tracking-wide text-[var(--color-ink-soft)]">
|
||||
Status
|
||||
</span>
|
||||
{statusFilters.map((f) => (
|
||||
<FilterChip
|
||||
key={f || "all"}
|
||||
active={status === f}
|
||||
label={
|
||||
f === ""
|
||||
? "Semua"
|
||||
: STATUS_META[f as keyof typeof STATUS_META].label
|
||||
}
|
||||
onClick={() => setStatus(f)}
|
||||
/>
|
||||
))}
|
||||
<span className="ml-3 text-[10px] font-semibold uppercase tracking-wide text-[var(--color-ink-soft)]">
|
||||
Tipe
|
||||
</span>
|
||||
{typeFilters.map((f) => (
|
||||
<FilterChip
|
||||
key={f || "all"}
|
||||
active={actionType === f}
|
||||
label={
|
||||
f === "" ? "Semua" : ACTION_META[f as ModerationActionType].label
|
||||
}
|
||||
onClick={() => setActionType(f)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Timeline */}
|
||||
{actionsLoading && !actions ? (
|
||||
<LoadingSkeleton count={6} height="h-16" />
|
||||
) : !actions || actions.length === 0 ? (
|
||||
<div className="surface p-6">
|
||||
<EmptyState
|
||||
icon={ShieldAlert}
|
||||
title="Belum ada aksi moderasi"
|
||||
description="Aksi auto-moderasi (delete, warn, kick, ban) akan muncul di sini."
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{actions.map((a) => (
|
||||
<ActionRow key={a.id} action={a} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<p className="text-[10px] text-[var(--color-ink-soft)]">
|
||||
{actions?.length ?? 0} aksi ditampilkan · log moderasi gateway Discord
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SummaryCard({
|
||||
label,
|
||||
value,
|
||||
tone,
|
||||
hint,
|
||||
}: {
|
||||
label: string;
|
||||
value: number;
|
||||
tone?: "signal" | "vermilion" | "amber";
|
||||
hint?: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="surface p-4">
|
||||
<p className="text-[10px] uppercase tracking-wide text-[var(--color-ink-soft)]">
|
||||
{label}
|
||||
</p>
|
||||
<p
|
||||
className={cn(
|
||||
"mt-1 text-2xl font-bold",
|
||||
tone === "signal" && "text-[var(--color-signal)]",
|
||||
tone === "vermilion" && "text-[var(--color-vermilion)]",
|
||||
tone === "amber" && "text-[var(--color-amber)]",
|
||||
!tone && "text-[var(--color-ink)]",
|
||||
)}
|
||||
>
|
||||
{value}
|
||||
{hint && (
|
||||
<span className="ml-1 text-xs font-medium opacity-80">({hint})</span>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function FilterChip({
|
||||
active,
|
||||
label,
|
||||
onClick,
|
||||
}: {
|
||||
active: boolean;
|
||||
label: string;
|
||||
onClick: () => void;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
className={cn(
|
||||
"rounded-full px-3 py-1 text-[11px] transition-colors",
|
||||
active
|
||||
? "bg-[var(--color-signal)] text-[var(--color-signal-ink)]"
|
||||
: "text-[var(--color-ink-soft)] hover:bg-[var(--color-surface-2)] hover:text-[var(--color-ink)]",
|
||||
)}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function ActionRow({ action }: { action: ModerationAction }) {
|
||||
const meta = ACTION_META[action.action_type] ?? ACTION_META.delete_message;
|
||||
const st = STATUS_META[action.status];
|
||||
const Icon = meta.Icon;
|
||||
return (
|
||||
<div className="surface flex items-start gap-3 p-3">
|
||||
<span
|
||||
className={cn(
|
||||
"mt-0.5 shrink-0",
|
||||
meta.tone === "vermilion"
|
||||
? "text-[var(--color-vermilion)]"
|
||||
: "text-[var(--color-amber)]",
|
||||
)}
|
||||
>
|
||||
<Icon className="size-4" />
|
||||
</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="text-xs font-semibold text-[var(--color-ink)]">
|
||||
{meta.label}
|
||||
</span>
|
||||
{action.username && (
|
||||
<span className="text-xs text-[var(--color-ink-soft)]">
|
||||
@{action.username}
|
||||
</span>
|
||||
)}
|
||||
<Badge tone={st.tone}>{st.label}</Badge>
|
||||
</div>
|
||||
{action.content && (
|
||||
<p className="mt-1 line-clamp-2 text-xs text-[var(--color-ink-soft)]">
|
||||
{renderMessageContent(action.content, null)}
|
||||
</p>
|
||||
)}
|
||||
{action.reason && (
|
||||
<p className="mt-1 text-[11px] text-[var(--color-ink-soft)]">
|
||||
Alasan: {action.reason}
|
||||
</p>
|
||||
)}
|
||||
{action.error && (
|
||||
<p className="mt-1 text-[11px] text-[var(--color-vermilion)] line-clamp-2">
|
||||
Error: {action.error}
|
||||
</p>
|
||||
)}
|
||||
<p className="mt-1.5 text-[10px] font-mono text-[var(--color-ink-soft)]">
|
||||
dibuat {fmtTime(action.created_at)}
|
||||
{action.executed_at
|
||||
? ` · dieksekusi ${fmtTime(action.executed_at)}`
|
||||
: ""}
|
||||
</p>
|
||||
</div>
|
||||
{action.status === "executed" ? (
|
||||
<CheckCircle2 className="mt-0.5 size-3.5 shrink-0 text-[var(--color-signal)]" />
|
||||
) : action.status === "failed" ? (
|
||||
<XCircle className="mt-0.5 size-3.5 shrink-0 text-[var(--color-vermilion)]" />
|
||||
) : (
|
||||
<Loader2 className="mt-0.5 size-3.5 shrink-0 animate-spin text-[var(--color-amber)]" />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default ModerationSection;
|
||||
@@ -1,30 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
|
||||
import { usePathname } from "next/navigation";
|
||||
import type { ReactNode } from "react";
|
||||
import { ease } from "./variants";
|
||||
|
||||
export function RouteTransition({ children }: { children: ReactNode }) {
|
||||
const pathname = usePathname();
|
||||
const reduce = useReducedMotion();
|
||||
|
||||
if (reduce) {
|
||||
return <div key={pathname}>{children}</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<AnimatePresence mode="popLayout" initial={false}>
|
||||
<motion.div
|
||||
key={pathname}
|
||||
initial={{ opacity: 0, y: 8, filter: "blur(4px)" }}
|
||||
animate={{ opacity: 1, y: 0, filter: "blur(0px)" }}
|
||||
exit={{ opacity: 0, filter: "blur(4px)" }}
|
||||
transition={{ duration: 0.22, ease }}
|
||||
className="contents"
|
||||
>
|
||||
{children}
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
);
|
||||
}
|
||||
@@ -1,53 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { motion, type Variants } from "motion/react";
|
||||
import type { ReactNode } from "react";
|
||||
import { fadeUp, stagger } from "./variants";
|
||||
|
||||
type V = Variants;
|
||||
|
||||
interface StaggerGroupProps {
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
variants?: V;
|
||||
as?: "div" | "ul" | "section";
|
||||
}
|
||||
|
||||
export function StaggerGroup({
|
||||
children,
|
||||
className,
|
||||
variants = stagger,
|
||||
as = "div",
|
||||
}: StaggerGroupProps) {
|
||||
const Tag = motion[as];
|
||||
return (
|
||||
<Tag
|
||||
className={className}
|
||||
variants={variants}
|
||||
initial="hidden"
|
||||
animate="visible"
|
||||
>
|
||||
{children}
|
||||
</Tag>
|
||||
);
|
||||
}
|
||||
|
||||
interface StaggerItemProps {
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
variants?: V;
|
||||
layout?: boolean;
|
||||
}
|
||||
|
||||
export function StaggerItem({
|
||||
children,
|
||||
className,
|
||||
variants = fadeUp,
|
||||
layout,
|
||||
}: StaggerItemProps) {
|
||||
return (
|
||||
<motion.div className={className} variants={variants} layout={layout}>
|
||||
{children}
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
@@ -1,31 +0,0 @@
|
||||
import type { Transition, Variants } from "motion/react";
|
||||
|
||||
/** Spring tuned for UI micro-interactions. */
|
||||
export const spring: Transition = {
|
||||
type: "spring",
|
||||
stiffness: 260,
|
||||
damping: 24,
|
||||
};
|
||||
|
||||
/** Expressive ease-out for page/section transitions. */
|
||||
export const ease = [0.22, 1, 0.36, 1] as const;
|
||||
|
||||
/** Single-element fade + rise. */
|
||||
export const fadeUp: Variants = {
|
||||
hidden: { opacity: 0, y: 8 },
|
||||
visible: { opacity: 1, y: 0, transition: { duration: 0.32, ease } },
|
||||
};
|
||||
|
||||
/** Parent that staggers its children. */
|
||||
export const stagger: Variants = {
|
||||
hidden: {},
|
||||
visible: {
|
||||
transition: { staggerChildren: 0.06, delayChildren: 0.08 },
|
||||
},
|
||||
};
|
||||
|
||||
/** Scale-in for emphasis blocks. */
|
||||
export const popIn: Variants = {
|
||||
hidden: { opacity: 0, scale: 0.94 },
|
||||
visible: { opacity: 1, scale: 1, transition: spring },
|
||||
};
|
||||
@@ -1,38 +1,51 @@
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export interface AvatarProps {
|
||||
src?: string | null;
|
||||
name?: string | null;
|
||||
size?: number;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
function initials(name?: string | null): string {
|
||||
if (!name) return "?";
|
||||
const parts = name.trim().split(/\s+/);
|
||||
const parts = name.replace(/[^\p{L}\p{N} _]/gu, "").trim().split(/\s+/);
|
||||
if (parts.length === 1) return parts[0].slice(0, 2).toUpperCase();
|
||||
return (parts[0][0] + parts[parts.length - 1][0]).toUpperCase();
|
||||
}
|
||||
|
||||
export function Avatar({ src, name, size = 36, className }: AvatarProps) {
|
||||
export function Avatar({
|
||||
src,
|
||||
name,
|
||||
size = 36,
|
||||
className,
|
||||
ring,
|
||||
}: {
|
||||
src?: string | null;
|
||||
name?: string | null;
|
||||
size?: number;
|
||||
className?: string;
|
||||
ring?: boolean;
|
||||
}) {
|
||||
const dim = { width: size, height: size };
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
"relative inline-flex shrink-0 items-center justify-center overflow-hidden rounded-full",
|
||||
"bg-[var(--color-signal)]/20 text-[var(--color-signal)] font-semibold",
|
||||
"bg-gradient-to-br from-white/10 to-white/[0.02] text-ink-soft",
|
||||
ring && "ring-2 ring-signal/50",
|
||||
className,
|
||||
)}
|
||||
style={{ width: size, height: size, fontSize: size * 0.38 }}
|
||||
style={dim}
|
||||
>
|
||||
{src ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
src={src}
|
||||
alt={name ?? ""}
|
||||
className="size-full object-cover"
|
||||
alt={name ?? "avatar"}
|
||||
className="h-full w-full object-cover"
|
||||
loading="lazy"
|
||||
/>
|
||||
) : (
|
||||
initials(name)
|
||||
<span
|
||||
className="font-semibold"
|
||||
style={{ fontSize: Math.max(10, size * 0.36) }}
|
||||
>
|
||||
{initials(name)}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
|
||||
@@ -1,40 +1,38 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export type BadgeTone = "signal" | "amber" | "vermilion" | "neutral";
|
||||
type Tone = "signal" | "amber" | "vermilion" | "neutral";
|
||||
|
||||
const toneClass: Record<BadgeTone, string> = {
|
||||
signal: "bg-[var(--color-signal)]/15 text-[var(--color-signal)]",
|
||||
amber: "bg-[var(--color-amber)]/15 text-[var(--color-amber)]",
|
||||
vermilion: "bg-[var(--color-vermilion)]/15 text-[var(--color-vermilion)]",
|
||||
neutral: "bg-[var(--color-hairline)] text-[var(--color-ink-soft)]",
|
||||
const tones: Record<Tone, string> = {
|
||||
signal: "bg-signal/12 text-signal border-signal/30",
|
||||
amber: "bg-amber/12 text-amber border-amber/30",
|
||||
vermilion: "bg-vermilion/12 text-vermilion border-vermilion/30",
|
||||
neutral: "bg-white/6 text-ink-soft border-white/10",
|
||||
};
|
||||
|
||||
export interface BadgeProps {
|
||||
tone?: BadgeTone;
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
dot?: boolean;
|
||||
}
|
||||
|
||||
export function Badge({
|
||||
tone = "neutral",
|
||||
children,
|
||||
className,
|
||||
dot,
|
||||
}: BadgeProps) {
|
||||
className,
|
||||
children,
|
||||
}: {
|
||||
tone?: Tone;
|
||||
dot?: boolean;
|
||||
className?: string;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<span className={cn("pill", toneClass[tone], className)}>
|
||||
<span
|
||||
className={cn(
|
||||
"inline-flex items-center gap-1.5 rounded-full border px-2.5 py-0.5 text-[0.7rem] font-semibold tracking-wide",
|
||||
tones[tone],
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{dot && (
|
||||
<span
|
||||
className={cn(
|
||||
"size-1.5 rounded-full",
|
||||
tone === "signal" && "bg-[var(--color-signal)]",
|
||||
tone === "amber" && "bg-[var(--color-amber)]",
|
||||
tone === "vermilion" && "bg-[var(--color-vermilion)]",
|
||||
tone === "neutral" && "bg-[var(--color-ink-soft)]",
|
||||
)}
|
||||
/>
|
||||
<span className="relative flex h-1.5 w-1.5">
|
||||
<span className="absolute inline-flex h-full w-full rounded-full bg-current opacity-60 animate-pulse-ring" />
|
||||
<span className="relative inline-flex h-1.5 w-1.5 rounded-full bg-current" />
|
||||
</span>
|
||||
)}
|
||||
{children}
|
||||
</span>
|
||||
|
||||
@@ -1,55 +1,52 @@
|
||||
"use client";
|
||||
|
||||
import { type HTMLMotionProps, motion, useReducedMotion } from "motion/react";
|
||||
import { forwardRef } from "react";
|
||||
import { Slot } from "./slot";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type Variant = "primary" | "ghost" | "danger" | "outline";
|
||||
type Variant = "primary" | "ghost" | "outline" | "danger" | "subtle";
|
||||
type Size = "sm" | "md" | "lg" | "icon";
|
||||
|
||||
const variantClass: Record<Variant, string> = {
|
||||
const variants: Record<Variant, string> = {
|
||||
primary:
|
||||
"bg-[var(--color-signal)] text-[var(--color-signal-ink)] hover:opacity-90",
|
||||
ghost:
|
||||
"bg-transparent text-[var(--color-ink)] hover:bg-[var(--color-surface-2)]",
|
||||
danger: "bg-[var(--color-vermilion)] text-white hover:opacity-90",
|
||||
"bg-signal text-signal-ink hover:brightness-110 shadow-[0_8px_24px_-10px_var(--color-signal-glow)] font-semibold",
|
||||
ghost: "text-ink-soft hover:text-ink hover:bg-white/5",
|
||||
outline:
|
||||
"bg-transparent text-[var(--color-ink)] border border-[var(--color-hairline)] hover:bg-[var(--color-surface-2)]",
|
||||
"border border-hairline bg-white/0 text-ink hover:bg-white/5 hover:border-signal/40",
|
||||
danger:
|
||||
"bg-vermilion text-white hover:brightness-110 shadow-[0_8px_24px_-10px_var(--color-vermilion-glow)] font-semibold",
|
||||
subtle: "bg-white/5 text-ink hover:bg-white/10",
|
||||
};
|
||||
|
||||
const sizeClass: Record<Size, string> = {
|
||||
sm: "h-8 px-3 text-xs rounded-[var(--radius-r-control)]",
|
||||
md: "h-10 px-4 text-sm rounded-[var(--radius-r-control)]",
|
||||
lg: "h-12 px-6 text-base rounded-[var(--radius-r)]",
|
||||
icon: "size-9 rounded-[var(--radius-r-control)]",
|
||||
const sizes: Record<Size, string> = {
|
||||
sm: "h-8 px-3 text-xs rounded-[9px] gap-1.5",
|
||||
md: "h-10 px-4 text-sm rounded-[11px] gap-2",
|
||||
lg: "h-12 px-6 text-base rounded-[13px] gap-2",
|
||||
icon: "h-10 w-10 rounded-[11px]",
|
||||
};
|
||||
|
||||
export interface ButtonProps extends Omit<HTMLMotionProps<"button">, "ref"> {
|
||||
export interface ButtonProps
|
||||
extends React.ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
variant?: Variant;
|
||||
size?: Size;
|
||||
asChild?: boolean;
|
||||
}
|
||||
|
||||
export const Button = forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
(
|
||||
{ className, variant = "primary", size = "md", children, ...props },
|
||||
ref,
|
||||
) => {
|
||||
const reduce = useReducedMotion();
|
||||
return (
|
||||
<motion.button
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"inline-flex items-center justify-center gap-2 select-none cursor-pointer font-medium outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-ring)] disabled:opacity-50 disabled:pointer-events-none transition-colors duration-150",
|
||||
variantClass[variant],
|
||||
sizeClass[size],
|
||||
className,
|
||||
)}
|
||||
whileTap={reduce ? undefined : { scale: 0.97 }}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</motion.button>
|
||||
);
|
||||
},
|
||||
);
|
||||
Button.displayName = "Button";
|
||||
export const Button = ({
|
||||
className,
|
||||
variant = "subtle",
|
||||
size = "md",
|
||||
asChild,
|
||||
...props
|
||||
}: ButtonProps) => {
|
||||
const Comp = asChild ? Slot : "button";
|
||||
return (
|
||||
<Comp
|
||||
className={cn(
|
||||
"inline-flex items-center justify-center whitespace-nowrap transition-all duration-150 select-none",
|
||||
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-signal/60 disabled:opacity-40 disabled:pointer-events-none",
|
||||
variants[variant],
|
||||
sizes[size],
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
/** Floating glass panel — primary container. */
|
||||
export function GlassPanel({
|
||||
className,
|
||||
children,
|
||||
glow,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement> & { glow?: boolean }) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"glass p-5",
|
||||
glow && "shadow-[0_0_40px_-18px_var(--color-signal-glow)]",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Smaller glass sub-panel. */
|
||||
export function GlassCard({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) {
|
||||
return (
|
||||
<div className={cn("glass-2 p-4", className)} {...props}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,75 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
|
||||
import { type ReactNode, useEffect, useRef } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export interface DialogProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
labelledBy?: string;
|
||||
}
|
||||
|
||||
export function Dialog({
|
||||
open,
|
||||
onClose,
|
||||
children,
|
||||
className,
|
||||
labelledBy,
|
||||
}: DialogProps) {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const reduce = useReducedMotion();
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") onClose();
|
||||
};
|
||||
document.addEventListener("keydown", onKey);
|
||||
document.body.style.overflow = "hidden";
|
||||
return () => {
|
||||
document.removeEventListener("keydown", onKey);
|
||||
document.body.style.overflow = "";
|
||||
};
|
||||
}, [open, onClose]);
|
||||
|
||||
return (
|
||||
<AnimatePresence>
|
||||
{open && (
|
||||
<motion.div
|
||||
className="fixed inset-0 z-50 flex items-center justify-center p-4"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.18 }}
|
||||
>
|
||||
<div
|
||||
className="absolute inset-0 bg-black/55 backdrop-blur-sm"
|
||||
onClick={onClose}
|
||||
aria-hidden
|
||||
/>
|
||||
<motion.div
|
||||
ref={ref}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby={labelledBy}
|
||||
className={cn(
|
||||
"relative z-10 w-full max-w-lg surface-2 shadow-2xl",
|
||||
className,
|
||||
)}
|
||||
initial={
|
||||
reduce ? { opacity: 0 } : { opacity: 0, scale: 0.96, y: 12 }
|
||||
}
|
||||
animate={{ opacity: 1, scale: 1, y: 0 }}
|
||||
exit={reduce ? { opacity: 0 } : { opacity: 0, scale: 0.96, y: 12 }}
|
||||
transition={{ type: "spring", stiffness: 320, damping: 28 }}
|
||||
>
|
||||
{children}
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
);
|
||||
}
|
||||
@@ -1,11 +1,11 @@
|
||||
export { Avatar, type AvatarProps } from "./avatar";
|
||||
export { Badge, type BadgeProps, type BadgeTone } from "./badge";
|
||||
export { Button, type ButtonProps } from "./button";
|
||||
export { Dialog, type DialogProps } from "./dialog";
|
||||
export { Input, type InputProps } from "./input";
|
||||
export { Progress, type ProgressProps } from "./progress";
|
||||
export { Select, type SelectProps } from "./select";
|
||||
export { Sheet, type SheetProps } from "./sheet";
|
||||
export { Skeleton, type SkeletonProps } from "./skeleton";
|
||||
export { Toaster, type ToasterProps, useToast } from "./toast";
|
||||
export { Tooltip, type TooltipProps } from "./tooltip";
|
||||
export { Button } from "./button";
|
||||
export { Badge } from "./badge";
|
||||
export { GlassPanel, GlassCard } from "./card";
|
||||
export { Input, Textarea } from "./input";
|
||||
export { Skeleton } from "./skeleton";
|
||||
export { Avatar } from "./avatar";
|
||||
export { Select } from "./select";
|
||||
export type { SelectOption } from "./select";
|
||||
export { Toaster, toast, useToast } from "./toast";
|
||||
export { Progress, Spinner } from "./progress";
|
||||
export { Tooltip } from "./tooltip";
|
||||
|
||||
@@ -1,23 +1,35 @@
|
||||
import { forwardRef, type InputHTMLAttributes } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export interface InputProps extends InputHTMLAttributes<HTMLInputElement> {
|
||||
mono?: boolean;
|
||||
}
|
||||
|
||||
export const Input = forwardRef<HTMLInputElement, InputProps>(
|
||||
({ className, mono, ...props }, ref) => (
|
||||
export function Input({
|
||||
className,
|
||||
...props
|
||||
}: React.InputHTMLAttributes<HTMLInputElement>) {
|
||||
return (
|
||||
<input
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"w-full bg-[var(--color-surface-2)] text-[var(--color-ink)] placeholder:text-[var(--color-ink-soft)]/60",
|
||||
"rounded-[var(--radius-r-control)] border border-[var(--color-hairline)] px-3 py-2 text-sm",
|
||||
"outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-ring)] transition-colors",
|
||||
mono && "font-mono tracking-tight",
|
||||
"h-10 w-full rounded-[11px] bg-white/5 border border-hairline px-3.5 text-sm text-ink",
|
||||
"placeholder:text-ink-faint transition-colors",
|
||||
"focus:outline-none focus:border-signal/50 focus:bg-white/8",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
);
|
||||
Input.displayName = "Input";
|
||||
);
|
||||
}
|
||||
|
||||
export function Textarea({
|
||||
className,
|
||||
...props
|
||||
}: React.TextareaHTMLAttributes<HTMLTextAreaElement>) {
|
||||
return (
|
||||
<textarea
|
||||
className={cn(
|
||||
"w-full rounded-[11px] bg-white/5 border border-hairline px-3.5 py-2.5 text-sm text-ink",
|
||||
"placeholder:text-ink-faint transition-colors resize-none",
|
||||
"focus:outline-none focus:border-signal/50 focus:bg-white/8",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,39 +1,38 @@
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export interface ProgressProps {
|
||||
value: number;
|
||||
max?: number;
|
||||
className?: string;
|
||||
tone?: "signal" | "amber" | "vermilion";
|
||||
showLabel?: boolean;
|
||||
}
|
||||
|
||||
export function Progress({
|
||||
value,
|
||||
max = 100,
|
||||
className,
|
||||
tone = "signal",
|
||||
showLabel,
|
||||
}: ProgressProps) {
|
||||
const pct = Math.max(0, Math.min(100, (value / max) * 100));
|
||||
const stroke = {
|
||||
signal: "var(--color-signal)",
|
||||
amber: "var(--color-amber)",
|
||||
vermilion: "var(--color-vermilion)",
|
||||
}[tone];
|
||||
className,
|
||||
}: {
|
||||
value: number;
|
||||
tone?: "signal" | "amber" | "vermilion";
|
||||
className?: string;
|
||||
}) {
|
||||
const pct = Math.max(0, Math.min(100, value));
|
||||
const color =
|
||||
tone === "vermilion"
|
||||
? "var(--color-vermilion)"
|
||||
: tone === "amber"
|
||||
? "var(--color-amber)"
|
||||
: "var(--color-signal)";
|
||||
return (
|
||||
<div className={cn("flex items-center gap-2", className)}>
|
||||
<div className="relative h-1.5 flex-1 overflow-hidden rounded-full bg-[var(--color-hairline)]">
|
||||
<div
|
||||
className="absolute inset-y-0 left-0 rounded-full transition-[width] duration-300"
|
||||
style={{ width: `${pct}%`, background: stroke }}
|
||||
/>
|
||||
</div>
|
||||
{showLabel && (
|
||||
<span className="mono text-xs text-[var(--color-ink-soft)]">
|
||||
{Math.round(pct)}%
|
||||
</span>
|
||||
)}
|
||||
<div className={cn("h-1.5 w-full overflow-hidden rounded-full bg-white/8", className)}>
|
||||
<div
|
||||
className="h-full rounded-full transition-[width] duration-500"
|
||||
style={{ width: `${pct}%`, background: color, boxShadow: `0 0 12px -2px ${color}` }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function Spinner({ className }: { className?: string }) {
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
"inline-block size-4 animate-spin rounded-full border-2 border-white/20 border-t-signal",
|
||||
className,
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,26 +1,91 @@
|
||||
import { forwardRef, type SelectHTMLAttributes } from "react";
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { Check, ChevronDown } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export interface SelectProps extends SelectHTMLAttributes<HTMLSelectElement> {
|
||||
mono?: boolean;
|
||||
export interface SelectOption {
|
||||
value: string;
|
||||
label: string;
|
||||
hint?: string;
|
||||
}
|
||||
|
||||
export const Select = forwardRef<HTMLSelectElement, SelectProps>(
|
||||
({ className, mono, children, ...props }, ref) => (
|
||||
<select
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"w-full bg-[var(--color-surface-2)] text-[var(--color-ink)] appearance-none cursor-pointer",
|
||||
"rounded-[var(--radius-r-control)] border border-[var(--color-hairline)] px-3 py-2 text-sm",
|
||||
"outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-ring)] transition-colors",
|
||||
"bg-[url('data:image/svg+xml;utf8,<svg xmlns=%22http://www.w3.org/2000/svg%22 width=%2212%22 height=%2212%22 fill=%22none%22 stroke=%22%23aaa%22 stroke-width=%222%22><path d=%22M2 4l4 4 4-4%22/></svg>')] bg-[length:12px] bg-[right_0.75rem_center] bg-no-repeat pr-9",
|
||||
mono && "font-mono tracking-tight",
|
||||
className,
|
||||
export function Select({
|
||||
value,
|
||||
onChange,
|
||||
options,
|
||||
placeholder = "Select…",
|
||||
className,
|
||||
size = "md",
|
||||
}: {
|
||||
value: string | null;
|
||||
onChange: (value: string) => void;
|
||||
options: SelectOption[];
|
||||
placeholder?: string;
|
||||
className?: string;
|
||||
size?: "sm" | "md";
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const onDoc = (e: MouseEvent) => {
|
||||
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false);
|
||||
};
|
||||
document.addEventListener("mousedown", onDoc);
|
||||
return () => document.removeEventListener("mousedown", onDoc);
|
||||
}, [open]);
|
||||
|
||||
const selected = options.find((o) => o.value === value);
|
||||
|
||||
return (
|
||||
<div ref={ref} className={cn("relative", className)}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen((o) => !o)}
|
||||
className={cn(
|
||||
"flex w-full items-center justify-between gap-2 rounded-[11px] border border-hairline bg-white/5 text-left text-ink transition-colors hover:border-signal/40",
|
||||
"focus:outline-none focus:border-signal/60",
|
||||
size === "sm" ? "h-9 px-3 text-xs" : "h-10 px-3.5 text-sm",
|
||||
)}
|
||||
>
|
||||
<span className={cn("truncate", !selected && "text-ink-faint")}>
|
||||
{selected?.label ?? placeholder}
|
||||
</span>
|
||||
<ChevronDown
|
||||
className={cn("size-4 shrink-0 text-ink-faint transition-transform", open && "rotate-180")}
|
||||
/>
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<div
|
||||
className="glass absolute z-50 mt-1.5 max-h-72 w-full overflow-auto p-1.5"
|
||||
style={{ animation: "fade-up 0.14s ease" }}
|
||||
>
|
||||
{options.length === 0 && (
|
||||
<div className="px-3 py-2 text-xs text-ink-faint">No options</div>
|
||||
)}
|
||||
{options.map((o) => (
|
||||
<button
|
||||
key={o.value}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
onChange(o.value);
|
||||
setOpen(false);
|
||||
}}
|
||||
className={cn(
|
||||
"flex w-full items-center justify-between gap-2 rounded-[9px] px-3 py-2 text-left text-sm transition-colors",
|
||||
o.value === value ? "bg-signal/15 text-signal" : "text-ink hover:bg-white/6",
|
||||
)}
|
||||
>
|
||||
<span className="truncate">{o.label}</span>
|
||||
{o.hint && <span className="mono text-[0.65rem] text-ink-faint">{o.hint}</span>}
|
||||
{o.value === value && <Check className="size-3.5 shrink-0" />}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</select>
|
||||
),
|
||||
);
|
||||
Select.displayName = "Select";
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,74 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
|
||||
import { type ReactNode, useEffect } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export interface SheetProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
children: ReactNode;
|
||||
side?: "left" | "right" | "bottom";
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function Sheet({
|
||||
open,
|
||||
onClose,
|
||||
children,
|
||||
side = "left",
|
||||
className,
|
||||
}: SheetProps) {
|
||||
const reduce = useReducedMotion();
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const onKey = (e: KeyboardEvent) => e.key === "Escape" && onClose();
|
||||
document.addEventListener("keydown", onKey);
|
||||
return () => document.removeEventListener("keydown", onKey);
|
||||
}, [open, onClose]);
|
||||
|
||||
const dir =
|
||||
side === "left"
|
||||
? { initial: { x: "-100%" }, animate: { x: 0 } }
|
||||
: side === "right"
|
||||
? { initial: { x: "100%" }, animate: { x: 0 } }
|
||||
: { initial: { y: "100%" }, animate: { y: 0 } };
|
||||
|
||||
return (
|
||||
<AnimatePresence>
|
||||
{open && (
|
||||
<motion.div
|
||||
className="fixed inset-0 z-50"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
>
|
||||
<div
|
||||
className="absolute inset-0 bg-black/55 backdrop-blur-sm"
|
||||
onClick={onClose}
|
||||
aria-hidden
|
||||
/>
|
||||
<motion.aside
|
||||
className={cn(
|
||||
"absolute bg-[var(--color-canvas)] shadow-2xl",
|
||||
side === "left" &&
|
||||
"left-0 top-0 h-full w-72 border-r border-[var(--color-hairline)]",
|
||||
side === "right" &&
|
||||
"right-0 top-0 h-full w-72 border-l border-[var(--color-hairline)]",
|
||||
side === "bottom" &&
|
||||
"bottom-0 left-0 w-full rounded-t-2xl border-t border-[var(--color-hairline)]",
|
||||
className,
|
||||
)}
|
||||
initial={reduce ? { opacity: 0 } : dir.initial}
|
||||
animate={reduce ? { opacity: 1 } : dir.animate}
|
||||
exit={reduce ? { opacity: 0 } : dir.initial}
|
||||
transition={{ type: "spring", stiffness: 320, damping: 32 }}
|
||||
>
|
||||
{children}
|
||||
</motion.aside>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
);
|
||||
}
|
||||
@@ -1,20 +1,16 @@
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export interface SkeletonProps {
|
||||
className?: string;
|
||||
rounded?: boolean;
|
||||
}
|
||||
|
||||
export function Skeleton({ className, rounded }: SkeletonProps) {
|
||||
export function Skeleton({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) {
|
||||
return (
|
||||
<div
|
||||
aria-hidden
|
||||
className={cn(
|
||||
"animate-shimmer rounded-[var(--radius-r-control)]",
|
||||
"bg-[var(--color-surface-2)]",
|
||||
rounded && "rounded-full",
|
||||
"rounded-[10px] bg-white/[0.06] animate-shimmer relative overflow-hidden",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
|
||||
/**
|
||||
* Minimal Slot — merges its props onto its single child element (Radix-style
|
||||
* `asChild`). Enough for wrapping <Link>/<a> in a Button.
|
||||
*/
|
||||
export const Slot = React.forwardRef<HTMLElement, React.HTMLAttributes<HTMLElement> & { children?: React.ReactNode }>(
|
||||
({ children, ...slotProps }, ref) => {
|
||||
if (!React.isValidElement(children)) return null;
|
||||
const childProps = children.props as Record<string, unknown>;
|
||||
const merged: Record<string, unknown> = { ...childProps, ...slotProps, ref };
|
||||
// Merge className
|
||||
if (slotProps.className || childProps.className) {
|
||||
merged.className = [childProps.className, slotProps.className]
|
||||
.filter(Boolean)
|
||||
.join(" ");
|
||||
}
|
||||
// Merge style
|
||||
if (slotProps.style || childProps.style) {
|
||||
merged.style = { ...(childProps.style as object), ...(slotProps.style as object) };
|
||||
}
|
||||
return React.cloneElement(children, merged);
|
||||
},
|
||||
);
|
||||
Slot.displayName = "Slot";
|
||||
@@ -1,147 +1,104 @@
|
||||
"use client";
|
||||
|
||||
import { X } from "lucide-react";
|
||||
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
|
||||
import {
|
||||
createContext,
|
||||
type ReactNode,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useState,
|
||||
} from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { CheckCircle2, AlertTriangle, Info, X } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type ToastTone = "signal" | "amber" | "vermilion" | "neutral";
|
||||
|
||||
interface ToastItem {
|
||||
type ToastTone = "signal" | "vermilion" | "neutral";
|
||||
interface Toast {
|
||||
id: number;
|
||||
title?: string;
|
||||
title: string;
|
||||
description?: string;
|
||||
tone: ToastTone;
|
||||
}
|
||||
|
||||
interface ToastContextValue {
|
||||
toast: (t: {
|
||||
title?: string;
|
||||
description?: string;
|
||||
tone?: ToastTone;
|
||||
}) => void;
|
||||
let nextId = 1;
|
||||
const listeners = new Set<(t: Toast[]) => void>();
|
||||
let store: Toast[] = [];
|
||||
|
||||
function emit() {
|
||||
store = [...store];
|
||||
listeners.forEach((l) => l(store));
|
||||
}
|
||||
|
||||
const ToastContext = createContext<ToastContextValue | null>(null);
|
||||
export function toast(t: {
|
||||
title: string;
|
||||
description?: string;
|
||||
tone?: ToastTone;
|
||||
}) {
|
||||
const item: Toast = { id: nextId++, tone: t.tone ?? "neutral", ...t };
|
||||
store = [...store, item];
|
||||
listeners.forEach((l) => l(store));
|
||||
setTimeout(() => {
|
||||
store = store.filter((x) => x.id !== item.id);
|
||||
emit();
|
||||
}, 4200);
|
||||
}
|
||||
|
||||
export function useToast() {
|
||||
const ctx = useContext(ToastContext);
|
||||
if (!ctx) {
|
||||
return {
|
||||
toast: (_: {
|
||||
title?: string;
|
||||
description?: string;
|
||||
tone?: ToastTone;
|
||||
}) => {},
|
||||
};
|
||||
}
|
||||
return ctx;
|
||||
return { toast };
|
||||
}
|
||||
|
||||
const toneBar: Record<ToastTone, string> = {
|
||||
signal: "bg-[var(--color-signal)]",
|
||||
amber: "bg-[var(--color-amber)]",
|
||||
vermilion: "bg-[var(--color-vermilion)]",
|
||||
neutral: "bg-[var(--color-ink-soft)]",
|
||||
const icons = {
|
||||
signal: CheckCircle2,
|
||||
vermilion: AlertTriangle,
|
||||
neutral: Info,
|
||||
};
|
||||
|
||||
export interface ToasterProps {
|
||||
position?: "bottom-right" | "bottom-left" | "top-right" | "top-left";
|
||||
}
|
||||
|
||||
export function Toaster({ position = "bottom-right" }: ToasterProps) {
|
||||
const [items, setItems] = useState<ToastItem[]>([]);
|
||||
const reduce = useReducedMotion();
|
||||
|
||||
const toast = useCallback(
|
||||
(t: { title?: string; description?: string; tone?: ToastTone }) => {
|
||||
const id = Date.now() + Math.random();
|
||||
const item: ToastItem = {
|
||||
id,
|
||||
tone: t.tone ?? "neutral",
|
||||
title: t.title,
|
||||
description: t.description,
|
||||
};
|
||||
setItems((prev) => [...prev, item]);
|
||||
setTimeout(() => {
|
||||
setItems((prev) => prev.filter((i) => i.id !== id));
|
||||
}, 4500);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
export function Toaster({ position = "bottom-right" }: { position?: string }) {
|
||||
const [items, setItems] = useState<Toast[]>([]);
|
||||
useEffect(() => {
|
||||
// expose a no-op provider only; actual provider wraps below
|
||||
listeners.add(setItems);
|
||||
setItems(store);
|
||||
return () => {
|
||||
listeners.delete(setItems);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const posClass =
|
||||
const pos =
|
||||
position === "bottom-right"
|
||||
? "bottom-4 right-4"
|
||||
: position === "bottom-left"
|
||||
? "bottom-4 left-4"
|
||||
: position === "top-right"
|
||||
? "top-4 right-4"
|
||||
: "top-4 left-4";
|
||||
: position === "top-right"
|
||||
? "top-4 right-4"
|
||||
: "bottom-4 left-1/2 -translate-x-1/2";
|
||||
|
||||
return (
|
||||
<ToastContext.Provider value={{ toast }}>
|
||||
<div
|
||||
className={cn(
|
||||
"fixed z-[60] flex w-[min(92vw,360px)] flex-col gap-2",
|
||||
posClass,
|
||||
)}
|
||||
>
|
||||
<AnimatePresence>
|
||||
{items.map((it) => (
|
||||
<motion.div
|
||||
key={it.id}
|
||||
layout
|
||||
initial={
|
||||
reduce ? { opacity: 0 } : { opacity: 0, x: 40, scale: 0.96 }
|
||||
}
|
||||
animate={{ opacity: 1, x: 0, scale: 1 }}
|
||||
exit={
|
||||
reduce ? { opacity: 0 } : { opacity: 0, x: 40, scale: 0.96 }
|
||||
}
|
||||
transition={{ type: "spring", stiffness: 360, damping: 30 }}
|
||||
className="surface-2 relative flex gap-3 overflow-hidden p-3 pr-9 shadow-xl"
|
||||
<div className={cn("pointer-events-none fixed z-[100] flex w-[min(92vw,360px)] flex-col gap-2", pos)}>
|
||||
{items.map((t) => {
|
||||
const Icon = icons[t.tone];
|
||||
return (
|
||||
<div
|
||||
key={t.id}
|
||||
className="glass pointer-events-auto flex items-start gap-3 p-3.5"
|
||||
style={{ animation: "fade-up 0.18s ease" }}
|
||||
>
|
||||
<Icon
|
||||
className={cn(
|
||||
"mt-0.5 size-4 shrink-0",
|
||||
t.tone === "signal" && "text-signal",
|
||||
t.tone === "vermilion" && "text-vermilion",
|
||||
t.tone === "neutral" && "text-ink-soft",
|
||||
)}
|
||||
/>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-sm font-semibold text-ink">{t.title}</div>
|
||||
{t.description && (
|
||||
<div className="mt-0.5 text-xs text-ink-soft">{t.description}</div>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
store = store.filter((x) => x.id !== t.id);
|
||||
emit();
|
||||
}}
|
||||
className="text-ink-faint hover:text-ink"
|
||||
>
|
||||
<span
|
||||
className={cn("w-1 shrink-0 rounded-full", toneBar[it.tone])}
|
||||
/>
|
||||
<div className="min-w-0 flex-1">
|
||||
{it.title && (
|
||||
<div className="text-sm font-semibold text-[var(--color-ink)]">
|
||||
{it.title}
|
||||
</div>
|
||||
)}
|
||||
{it.description && (
|
||||
<div className="text-xs text-[var(--color-ink-soft)]">
|
||||
{it.description}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
setItems((prev) => prev.filter((i) => i.id !== it.id))
|
||||
}
|
||||
className="absolute right-2 top-2 text-[var(--color-ink-soft)] hover:text-[var(--color-ink)]"
|
||||
aria-label="Dismiss"
|
||||
>
|
||||
<X className="size-3.5" />
|
||||
</button>
|
||||
</motion.div>
|
||||
))}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</ToastContext.Provider>
|
||||
<X className="size-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,31 +1,20 @@
|
||||
"use client";
|
||||
|
||||
import { type ReactNode, useState } from "react";
|
||||
import { useState } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export interface TooltipProps {
|
||||
content: ReactNode;
|
||||
children: ReactNode;
|
||||
side?: "top" | "bottom" | "left" | "right";
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const sidePos: Record<NonNullable<TooltipProps["side"]>, string> = {
|
||||
top: "bottom-full left-1/2 -translate-x-1/2 mb-2",
|
||||
bottom: "top-full left-1/2 -translate-x-1/2 mt-2",
|
||||
left: "right-full top-1/2 -translate-y-1/2 mr-2",
|
||||
right: "left-full top-1/2 -translate-y-1/2 ml-2",
|
||||
};
|
||||
|
||||
/** Lightweight hover/focus tooltip. */
|
||||
export function Tooltip({
|
||||
content,
|
||||
label,
|
||||
children,
|
||||
side = "top",
|
||||
className,
|
||||
}: TooltipProps) {
|
||||
}: {
|
||||
label: string;
|
||||
children: React.ReactNode;
|
||||
side?: "top" | "bottom";
|
||||
}) {
|
||||
const [show, setShow] = useState(false);
|
||||
return (
|
||||
// biome-ignore lint/a11y/noStaticElementInteractions: tooltip wrapper — reveals on hover AND focus (keyboard-accessible via focus handlers above)
|
||||
<span
|
||||
className="relative inline-flex"
|
||||
onMouseEnter={() => setShow(true)}
|
||||
@@ -34,18 +23,18 @@ export function Tooltip({
|
||||
onBlur={() => setShow(false)}
|
||||
>
|
||||
{children}
|
||||
<span
|
||||
role="tooltip"
|
||||
className={cn(
|
||||
"pointer-events-none absolute z-50 whitespace-nowrap rounded-[var(--radius-r-control)] px-2.5 py-1 text-xs font-medium",
|
||||
"bg-[var(--color-ink)] text-[var(--color-canvas)] opacity-0 transition-opacity duration-150",
|
||||
sidePos[side],
|
||||
show && "opacity-100",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{content}
|
||||
</span>
|
||||
{show && (
|
||||
<span
|
||||
role="tooltip"
|
||||
className={cn(
|
||||
"glass pointer-events-none absolute left-1/2 z-50 -translate-x-1/2 whitespace-nowrap px-2.5 py-1 text-xs text-ink",
|
||||
side === "top" ? "bottom-[calc(100%+6px)]" : "top-[calc(100%+6px)]",
|
||||
)}
|
||||
style={{ animation: "fade-up 0.12s ease" }}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,78 +0,0 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* RecordingPlayer — canonical single-recording row component.
|
||||
*/
|
||||
import { Delete, Download, Play } from "lucide-react";
|
||||
import { Waveform } from "@/components/charts/waveform";
|
||||
import { Avatar } from "@/components/primitives/avatar";
|
||||
import { Badge } from "@/components/primitives/badge";
|
||||
import { Button } from "@/components/primitives/button";
|
||||
import type { VoiceRecording } from "@/lib/types";
|
||||
|
||||
export interface RecordingPlayerProps {
|
||||
recording: VoiceRecording;
|
||||
onSelect?: (rec: VoiceRecording) => void;
|
||||
onDelete?: (rec: VoiceRecording) => void;
|
||||
deleting?: boolean;
|
||||
}
|
||||
|
||||
export function RecordingPlayer({
|
||||
recording,
|
||||
onSelect,
|
||||
onDelete,
|
||||
deleting,
|
||||
}: RecordingPlayerProps) {
|
||||
return (
|
||||
<div className="flex items-center gap-3 rounded-[var(--radius-r)] bg-[var(--color-surface)] p-3">
|
||||
<Waveform
|
||||
seed={recording.id}
|
||||
bars={16}
|
||||
height={36}
|
||||
className="w-20 shrink-0"
|
||||
/>
|
||||
<Avatar name={recording.username} src={recording.avatar_url} size={32} />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="text-sm font-medium">
|
||||
{recording.username ?? "unknown"}
|
||||
</span>
|
||||
<Badge tone="neutral">
|
||||
.{recording.filename.split(".").pop() ?? "mp3"}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="mono text-xs text-[var(--color-ink-soft)]">
|
||||
{(recording.size_bytes / 1024).toFixed(1)} KB
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
{recording.download_url && onSelect && (
|
||||
<Button size="sm" variant="ghost" onClick={() => onSelect(recording)}>
|
||||
<Play className="size-4" />
|
||||
</Button>
|
||||
)}
|
||||
{recording.download_url && (
|
||||
<a
|
||||
href={recording.download_url}
|
||||
download={recording.filename}
|
||||
className="flex size-8 items-center justify-center rounded-[var(--radius-r-control)] text-[var(--color-ink-soft)] hover:bg-[var(--color-surface-2)]"
|
||||
aria-label="Download"
|
||||
>
|
||||
<Download className="size-4" />
|
||||
</a>
|
||||
)}
|
||||
{onDelete && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="danger"
|
||||
disabled={deleting}
|
||||
onClick={() => onDelete(recording)}
|
||||
aria-label="Delete"
|
||||
>
|
||||
<Delete className="size-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
import { Inbox } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface EmptyStateProps {
|
||||
icon?: LucideIcon;
|
||||
title?: string;
|
||||
description?: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function EmptyState({
|
||||
icon: Icon = Inbox,
|
||||
title = "No data yet",
|
||||
description = "Nothing to display here yet.",
|
||||
className,
|
||||
}: EmptyStateProps) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"surface flex flex-col items-center gap-2 py-12 text-center",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<Icon className="size-8 text-[var(--color-ink-soft)]" />
|
||||
<p className="text-sm font-medium text-[var(--color-ink)]">{title}</p>
|
||||
<p className="text-xs text-[var(--color-ink-soft)]">{description}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,43 +0,0 @@
|
||||
import { AlertCircle, RefreshCw } from "lucide-react";
|
||||
import { Component, type ReactNode } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface Props {
|
||||
children: ReactNode;
|
||||
fallback?: ReactNode;
|
||||
}
|
||||
interface State {
|
||||
hasError: boolean;
|
||||
error?: Error;
|
||||
}
|
||||
|
||||
export class ErrorBoundary extends Component<Props, State> {
|
||||
state: State = { hasError: false };
|
||||
|
||||
static getDerivedStateFromError(error: Error): State {
|
||||
return { hasError: true, error };
|
||||
}
|
||||
|
||||
render() {
|
||||
if (this.state.hasError) {
|
||||
return (
|
||||
this.props.fallback || (
|
||||
<div className={cn("surface flex flex-col items-center gap-2 py-8")}>
|
||||
<AlertCircle className="size-6 text-[var(--color-vermilion)]" />
|
||||
<p className="text-sm text-[var(--color-ink)]">
|
||||
{this.state.error?.message || "Something went wrong"}
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => this.setState({ hasError: false })}
|
||||
className="flex items-center gap-1 text-xs text-[var(--color-signal)] hover:opacity-80 transition-colors"
|
||||
>
|
||||
<RefreshCw className="size-3" /> Try again
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
return this.props.children;
|
||||
}
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
import { AlertCircle, RefreshCw } from "lucide-react";
|
||||
import { Button } from "@/components/primitives/button";
|
||||
|
||||
interface ErrorStateProps {
|
||||
message: string;
|
||||
onRetry?: () => void;
|
||||
}
|
||||
|
||||
export function ErrorState({ message, onRetry }: ErrorStateProps) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center py-20 text-center">
|
||||
<AlertCircle className="size-10 text-[var(--color-vermilion)] mb-3" />
|
||||
<p className="text-sm text-[var(--color-ink-soft)] mb-4 max-w-sm">
|
||||
{message}
|
||||
</p>
|
||||
{onRetry && (
|
||||
<Button variant="outline" onClick={onRetry}>
|
||||
<RefreshCw className="size-4 mr-2" />
|
||||
Retry
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useGuilds, useTextChannels, useVoiceChannels } from "@/hooks";
|
||||
import { Select, type SelectOption } from "@/components/primitives";
|
||||
import type { Guild } from "@/lib/types";
|
||||
|
||||
export function GuildChannelPicker({
|
||||
mode,
|
||||
guildsInitial,
|
||||
guildId,
|
||||
channelId,
|
||||
onChange,
|
||||
}: {
|
||||
mode: "voice" | "text";
|
||||
guildsInitial?: Guild[];
|
||||
guildId: string | null;
|
||||
channelId: string | null;
|
||||
onChange: (guildId: string, channelId: string | null) => void;
|
||||
}) {
|
||||
const { data: guilds } = useGuilds(guildsInitial);
|
||||
// Call both hooks unconditionally (rules of hooks); select by mode.
|
||||
const voiceChannels = useVoiceChannels(guildId ?? "");
|
||||
const textChannels = useTextChannels(guildId ?? "");
|
||||
const channels = mode === "voice" ? voiceChannels.data : textChannels.data;
|
||||
|
||||
const [g, setG] = useState(guildId);
|
||||
const [c, setC] = useState(channelId);
|
||||
|
||||
useEffect(() => setG(guildId), [guildId]);
|
||||
useEffect(() => setC(channelId), [channelId]);
|
||||
|
||||
const guildOpts: SelectOption[] = (guilds ?? []).map((x) => ({
|
||||
value: x.id,
|
||||
label: x.name,
|
||||
}));
|
||||
const channelOpts: SelectOption[] = (channels ?? []).map((x) => ({
|
||||
value: x.id,
|
||||
label: x.name,
|
||||
hint: x.type,
|
||||
}));
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Select
|
||||
value={g}
|
||||
onChange={(v) => {
|
||||
setG(v);
|
||||
setC(null);
|
||||
onChange(v, null);
|
||||
}}
|
||||
options={guildOpts}
|
||||
placeholder="Guild"
|
||||
size="sm"
|
||||
className="w-44"
|
||||
/>
|
||||
<Select
|
||||
value={c}
|
||||
onChange={(v) => {
|
||||
setC(v);
|
||||
if (g) onChange(g, v);
|
||||
}}
|
||||
options={channelOpts}
|
||||
placeholder={mode === "voice" ? "Voice channel" : "Text channel"}
|
||||
size="sm"
|
||||
className="w-52"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,97 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { AlertCircle, RefreshCw } from "lucide-react";
|
||||
import { useEffect, useRef } from "react";
|
||||
|
||||
import { Badge } from "@/components/primitives/badge";
|
||||
import { Button } from "@/components/primitives/button";
|
||||
import { Select } from "@/components/primitives/select";
|
||||
import { Skeleton } from "@/components/primitives/skeleton";
|
||||
import { useConfig, useGuilds } from "@/hooks";
|
||||
|
||||
export interface GuildSelectorProps {
|
||||
/** Currently selected guild ID */
|
||||
value: string;
|
||||
/** Called when user selects a different guild */
|
||||
onChange: (guildId: string) => void;
|
||||
/** If true, the bar is hidden when there's only one guild */
|
||||
autoHide?: boolean;
|
||||
}
|
||||
|
||||
export function GuildSelector({
|
||||
value,
|
||||
onChange,
|
||||
autoHide = true,
|
||||
}: GuildSelectorProps) {
|
||||
const { data: guilds = [], isLoading, error, mutate: refetch } = useGuilds();
|
||||
const { data: config } = useConfig();
|
||||
|
||||
const initDone = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (value || guilds.length === 0 || initDone.current) return;
|
||||
initDone.current = true;
|
||||
const preferred = config?.monitorGuildId ?? guilds[0].id;
|
||||
if (preferred) onChange(preferred);
|
||||
}, [value, guilds, config, onChange]);
|
||||
|
||||
if (autoHide && guilds.length <= 1 && !isLoading && !error) return null;
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center gap-3 rounded-[var(--radius-r)] bg-[var(--color-surface)] p-3">
|
||||
<Skeleton className="h-8 w-36" />
|
||||
<Skeleton rounded className="h-8 w-8" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="flex items-center justify-between rounded-[var(--radius-r)] bg-[var(--color-vermilion)]/10 p-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<AlertCircle className="size-4 text-[var(--color-vermilion)] shrink-0" />
|
||||
<p className="text-sm text-[var(--color-ink-soft)]">
|
||||
Could not load guilds: {error?.message ?? "Failed to load"}
|
||||
</p>
|
||||
</div>
|
||||
<Button variant="outline" size="sm" onClick={() => refetch()}>
|
||||
<RefreshCw className="size-3 mr-1" />
|
||||
Retry
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (guilds.length === 0) {
|
||||
return (
|
||||
<div className="rounded-[var(--radius-r)] bg-[var(--color-amber)]/10 p-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<AlertCircle className="size-4 text-[var(--color-amber)] shrink-0" />
|
||||
<p className="text-sm text-[var(--color-ink-soft)]">
|
||||
No guilds available. Make sure the Discord gateway is connected.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-3 rounded-[var(--radius-r)] bg-[var(--color-surface)] p-3">
|
||||
<Badge tone="neutral" className="shrink-0 text-xs font-normal">
|
||||
Guild
|
||||
</Badge>
|
||||
<Select
|
||||
value={value}
|
||||
onChange={(e) => e.target.value && onChange(e.target.value)}
|
||||
className="h-10 w-full max-w-sm"
|
||||
>
|
||||
{guilds.map((g) => (
|
||||
<option key={g.id} value={g.id}>
|
||||
{g.name}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,3 @@
|
||||
export { EmptyState } from "./empty-state";
|
||||
export { ErrorBoundary } from "./error-boundary";
|
||||
export { ErrorState } from "./error-state";
|
||||
export { LoadingSkeleton } from "./loading-skeleton";
|
||||
export { SectionHeader, MetricTile } from "./section";
|
||||
export { EmptyState, ErrorState, LoadingState } from "./states";
|
||||
export { GuildChannelPicker } from "./guild-picker";
|
||||
|
||||
@@ -1,43 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface LoadingSkeletonProps {
|
||||
count?: number;
|
||||
height?: string;
|
||||
width?: string;
|
||||
columns?: number;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function LoadingSkeleton({
|
||||
count = 4,
|
||||
height = "h-24",
|
||||
width,
|
||||
columns,
|
||||
className,
|
||||
}: LoadingSkeletonProps) {
|
||||
const items = Array.from({ length: count }, (_, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className={cn(
|
||||
"surface-2 overflow-hidden",
|
||||
height,
|
||||
width,
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<div className="w-full h-full animate-shimmer" />
|
||||
</div>
|
||||
));
|
||||
|
||||
if (columns) {
|
||||
return (
|
||||
<div className={`grid grid-cols-1 md:grid-cols-${columns} gap-3`}>
|
||||
{items}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return <div className="space-y-2">{items}</div>;
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export function SectionHeader({
|
||||
eyebrow,
|
||||
title,
|
||||
action,
|
||||
className,
|
||||
}: {
|
||||
eyebrow?: string;
|
||||
title: React.ReactNode;
|
||||
action?: React.ReactNode;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<div className={cn("mb-3 flex items-end justify-between gap-3", className)}>
|
||||
<div className="min-w-0">
|
||||
{eyebrow && <div className="eyebrow mb-1">{eyebrow}</div>}
|
||||
<h2 className="display text-xl text-ink">{title}</h2>
|
||||
</div>
|
||||
{action}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function MetricTile({
|
||||
label,
|
||||
value,
|
||||
hint,
|
||||
tone = "neutral",
|
||||
spark,
|
||||
icon,
|
||||
className,
|
||||
}: {
|
||||
label: string;
|
||||
value: React.ReactNode;
|
||||
hint?: React.ReactNode;
|
||||
tone?: "neutral" | "signal" | "amber" | "vermilion";
|
||||
spark?: number[];
|
||||
icon?: React.ReactNode;
|
||||
className?: string;
|
||||
}) {
|
||||
const toneColor =
|
||||
tone === "vermilion"
|
||||
? "var(--color-vermilion)"
|
||||
: tone === "amber"
|
||||
? "var(--color-amber)"
|
||||
: tone === "signal"
|
||||
? "var(--color-signal)"
|
||||
: "var(--color-ink)";
|
||||
return (
|
||||
<div className={cn("glass p-4", className)}>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="eyebrow">{label}</div>
|
||||
{icon && <span className="text-ink-faint">{icon}</span>}
|
||||
</div>
|
||||
<div
|
||||
className="display mt-1 text-[1.9rem] leading-none"
|
||||
style={{ color: tone === "neutral" ? undefined : toneColor }}
|
||||
>
|
||||
{value}
|
||||
</div>
|
||||
{hint && <div className="mono mt-1 text-[0.68rem] text-ink-faint">{hint}</div>}
|
||||
{spark && spark.length > 1 && (
|
||||
<div className="mt-2">
|
||||
<div
|
||||
className="h-1 w-full overflow-hidden rounded-full"
|
||||
style={{ background: "oklch(1 0 0 / 0.08)" }}
|
||||
>
|
||||
<div
|
||||
className="h-full rounded-full"
|
||||
style={{ width: `${Math.min(100, (spark[spark.length - 1] / (Math.max(...spark) || 1)) * 100)}%`, background: toneColor, opacity: 0.7 }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import { AlertTriangle, Inbox, Loader2 } from "lucide-react";
|
||||
import { Spinner } from "@/components/primitives";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export function EmptyState({
|
||||
title = "Nothing here yet",
|
||||
description,
|
||||
icon,
|
||||
className,
|
||||
}: {
|
||||
title?: string;
|
||||
description?: string;
|
||||
icon?: React.ReactNode;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<div className={cn("flex flex-col items-center justify-center gap-2 py-12 text-center", className)}>
|
||||
<div className="text-ink-faint">{icon ?? <Inbox className="size-7" />}</div>
|
||||
<div className="text-sm font-medium text-ink-soft">{title}</div>
|
||||
{description && <div className="max-w-xs text-xs text-ink-faint">{description}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ErrorState({
|
||||
title = "Couldn't load",
|
||||
error,
|
||||
onRetry,
|
||||
}: {
|
||||
title?: string;
|
||||
error?: unknown;
|
||||
onRetry?: () => void;
|
||||
}) {
|
||||
const msg = error instanceof Error ? error.message : String(error ?? "");
|
||||
return (
|
||||
<div className="glass flex flex-col items-center gap-3 p-8 text-center">
|
||||
<AlertTriangle className="size-7 text-vermilion" />
|
||||
<div className="text-sm font-medium text-ink">{title}</div>
|
||||
{msg && <div className="mono max-w-md break-words text-xs text-ink-faint">{msg}</div>}
|
||||
{onRetry && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onRetry}
|
||||
className="mt-1 rounded-[10px] border border-hairline px-3 py-1.5 text-xs text-ink-soft hover:text-ink hover:border-signal/40"
|
||||
>
|
||||
Retry
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function LoadingState({ label = "Syncing" }: { label?: string }) {
|
||||
return (
|
||||
<div className="flex items-center justify-center gap-2 py-12 text-ink-faint">
|
||||
<Spinner />
|
||||
<span className="mono text-xs uppercase tracking-wider">{label}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export { Loader2 };
|
||||
@@ -0,0 +1,21 @@
|
||||
import { NavRail } from "./nav-rail";
|
||||
import { TopBar } from "./topbar";
|
||||
|
||||
/**
|
||||
* App chrome: slim nav rail + sticky top bar + scrollable content region.
|
||||
* Sits above the fixed AmbientCanvas. Providers (Ambient + WS) are mounted in
|
||||
* the route layout so every page shares one live link and signal context.
|
||||
*/
|
||||
export function AppFrame({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="flex h-dvh w-full overflow-hidden">
|
||||
<NavRail />
|
||||
<div className="flex min-w-0 flex-1 flex-col">
|
||||
<TopBar />
|
||||
<main className="min-h-0 flex-1 overflow-y-auto px-5 pb-8">
|
||||
{children}
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
export { AppFrame } from "./ambient-app";
|
||||
export { NavRail } from "./nav-rail";
|
||||
export { TopBar } from "./topbar";
|
||||
export { ConnectionStatus } from "./status-dot";
|
||||
@@ -0,0 +1,50 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { navItems, isActivePath } from "@/lib/navigation";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
Tooltip,
|
||||
} from "@/components/primitives/tooltip";
|
||||
|
||||
export function NavRail() {
|
||||
const pathname = usePathname();
|
||||
return (
|
||||
<nav className="glass m-3 mr-0 flex w-[68px] flex-col items-center gap-1 rounded-[18px] py-4">
|
||||
<Link
|
||||
href="/dashboard"
|
||||
className="mb-3 flex size-11 items-center justify-center rounded-[14px] bg-signal/15 text-signal glow-signal"
|
||||
aria-label="GMW home"
|
||||
>
|
||||
<span className="display text-xl">G</span>
|
||||
</Link>
|
||||
<div className="flex flex-1 flex-col gap-1">
|
||||
{navItems.map((item) => {
|
||||
const active = isActivePath(pathname, item.matchPrefix);
|
||||
const Icon = item.icon;
|
||||
return (
|
||||
<Tooltip key={item.href} label={item.label} side="bottom">
|
||||
<Link
|
||||
href={item.href}
|
||||
aria-label={item.label}
|
||||
aria-current={active ? "page" : undefined}
|
||||
className={cn(
|
||||
"group relative flex size-11 items-center justify-center rounded-[13px] transition-all",
|
||||
active
|
||||
? "bg-signal/15 text-signal"
|
||||
: "text-ink-faint hover:bg-white/5 hover:text-ink-soft",
|
||||
)}
|
||||
>
|
||||
{active && (
|
||||
<span className="absolute -left-3 h-6 w-1 rounded-full bg-signal shadow-[0_0_12px_var(--color-signal-glow)]" />
|
||||
)}
|
||||
<Icon className="size-[18px]" strokeWidth={active ? 2.4 : 2} />
|
||||
</Link>
|
||||
</Tooltip>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
"use client";
|
||||
|
||||
import { useWebSocket } from "@/lib/ws/context";
|
||||
import { Tooltip } from "@/components/primitives/tooltip";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const MAP = {
|
||||
connected: { color: "bg-signal", label: "Live link" },
|
||||
connecting: { color: "bg-amber animate-breathe", label: "Connecting…" },
|
||||
disconnected: { color: "bg-ink-faint", label: "Offline" },
|
||||
error: { color: "bg-vermilion", label: "Link error" },
|
||||
} as const;
|
||||
|
||||
export function ConnectionStatus({ compact = false }: { compact?: boolean }) {
|
||||
const { status } = useWebSocket();
|
||||
const s = MAP[status];
|
||||
return (
|
||||
<Tooltip label={s.label}>
|
||||
<span className="inline-flex items-center gap-2">
|
||||
<span className="relative flex size-2.5">
|
||||
<span className={cn("absolute inline-flex h-full w-full rounded-full opacity-60 animate-pulse-ring", s.color)} />
|
||||
<span className={cn("relative inline-flex size-2.5 rounded-full", s.color)} />
|
||||
</span>
|
||||
{!compact && (
|
||||
<span className="mono text-[0.7rem] uppercase tracking-wider text-ink-soft">
|
||||
{s.label}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
"use client";
|
||||
|
||||
import { usePathname } from "next/navigation";
|
||||
import { useTheme } from "next-themes";
|
||||
import { Moon, Sun } from "lucide-react";
|
||||
import { navItems } from "@/lib/navigation";
|
||||
import { useAmbient } from "@/components/ambient/ambient-context";
|
||||
import { ConnectionStatus } from "./status-dot";
|
||||
import { useEffect, useState } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function useActiveLabel() {
|
||||
const pathname = usePathname();
|
||||
const item = [...navItems]
|
||||
.sort((a, b) => b.matchPrefix.length - a.matchPrefix.length)
|
||||
.find((i) => pathname.startsWith(i.matchPrefix));
|
||||
return item?.label ?? "Console";
|
||||
}
|
||||
|
||||
export function TopBar() {
|
||||
const label = useActiveLabel();
|
||||
const { state } = useAmbient();
|
||||
const { theme, setTheme } = useTheme();
|
||||
const [mounted, setMounted] = useState(false);
|
||||
useEffect(() => setMounted(true), []);
|
||||
|
||||
const signalTone =
|
||||
state.tone === "vermilion"
|
||||
? "text-vermilion"
|
||||
: state.tone === "amber"
|
||||
? "text-amber"
|
||||
: "text-signal";
|
||||
|
||||
return (
|
||||
<header className="sticky top-0 z-40 flex items-center gap-4 px-5 py-3.5">
|
||||
<div className="flex min-w-0 items-baseline gap-3">
|
||||
<span className="eyebrow">GMW</span>
|
||||
<h1 className="display truncate text-[1.5rem] text-ink">{label}</h1>
|
||||
</div>
|
||||
|
||||
<div className="ml-auto flex items-center gap-3">
|
||||
<span className={cn("pill", signalTone)}>
|
||||
<span
|
||||
className={cn(
|
||||
"size-1.5 rounded-full bg-current animate-breathe",
|
||||
)}
|
||||
/>
|
||||
{state.label ?? "nominal"}
|
||||
</span>
|
||||
<ConnectionStatus />
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Open command palette"
|
||||
onClick={() => window.dispatchEvent(new Event("command-palette:open"))}
|
||||
className="hidden items-center gap-1.5 rounded-[11px] border border-hairline bg-white/5 px-2.5 py-1.5 text-xs text-ink-soft transition-colors hover:text-ink hover:border-signal/40 sm:flex"
|
||||
>
|
||||
<span className="mono text-[0.65rem]">⌘K</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Toggle theme"
|
||||
onClick={() => setTheme(theme === "light" ? "dark" : "light")}
|
||||
className="flex size-9 items-center justify-center rounded-[11px] border border-hairline bg-white/5 text-ink-soft transition-colors hover:text-ink hover:border-signal/40"
|
||||
>
|
||||
{mounted && theme === "light" ? (
|
||||
<Moon className="size-4" />
|
||||
) : (
|
||||
<Sun className="size-4" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
@@ -1,49 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import dynamic from "next/dynamic";
|
||||
import type { OrbFieldProps } from "./orb-field";
|
||||
import type { SignalFieldProps } from "./signal-field";
|
||||
import { StaticFallback } from "./static-fallback";
|
||||
import { WebGLGuard } from "./webgl-guard";
|
||||
|
||||
const SignalFieldImpl = dynamic(
|
||||
() => import("./signal-field").then((m) => m.SignalField),
|
||||
{
|
||||
ssr: false,
|
||||
loading: () => null,
|
||||
},
|
||||
);
|
||||
|
||||
const OrbFieldImpl = dynamic(
|
||||
() => import("./orb-field").then((m) => m.OrbField),
|
||||
{
|
||||
ssr: false,
|
||||
loading: () => null,
|
||||
},
|
||||
);
|
||||
|
||||
export function SignalField(props: SignalFieldProps) {
|
||||
return (
|
||||
<WebGLGuard
|
||||
fallback={<StaticFallback variant="signal" className={props.className} />}
|
||||
>
|
||||
<SignalFieldImpl {...props} />
|
||||
</WebGLGuard>
|
||||
);
|
||||
}
|
||||
|
||||
export function OrbField(props: OrbFieldProps) {
|
||||
return (
|
||||
<WebGLGuard
|
||||
fallback={
|
||||
<StaticFallback
|
||||
variant="orb"
|
||||
count={props.speakers.length}
|
||||
className={props.className}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<OrbFieldImpl {...props} />
|
||||
</WebGLGuard>
|
||||
);
|
||||
}
|
||||
@@ -1,136 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useRef } from "react";
|
||||
import * as THREE from "three";
|
||||
import { useThreeScene } from "./use-three-scene";
|
||||
|
||||
export interface OrbSpeaker {
|
||||
id: string;
|
||||
name: string;
|
||||
speaking: boolean;
|
||||
severity?: "none" | "low" | "medium" | "high" | "critical";
|
||||
}
|
||||
|
||||
export interface OrbFieldProps {
|
||||
speakers: OrbSpeaker[];
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const severityColor: Record<string, string> = {
|
||||
none: "oklch(0.82 0.18 125)",
|
||||
low: "oklch(0.80 0.15 70)",
|
||||
medium: "oklch(0.78 0.16 70)",
|
||||
high: "oklch(0.70 0.2 35)",
|
||||
critical: "oklch(0.66 0.22 25)",
|
||||
};
|
||||
|
||||
/**
|
||||
* Voice page hero. Each speaker is a glowing orb; when speaking it rises and
|
||||
* its ring radius expands. Warm palette only.
|
||||
*/
|
||||
export function OrbField({ speakers, className }: OrbFieldProps) {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const speakersRef = useRef(speakers);
|
||||
speakersRef.current = speakers;
|
||||
|
||||
useThreeScene(ref, {
|
||||
setup: (ctx) => {
|
||||
const group = new THREE.Group();
|
||||
ctx.scene.add(group);
|
||||
|
||||
const orbMeshes: Record<string, THREE.Mesh> = {};
|
||||
const ringMeshes: Record<string, THREE.Mesh> = {};
|
||||
|
||||
const layout = () => {
|
||||
const list = speakersRef.current;
|
||||
const n = Math.max(list.length, 1);
|
||||
list.forEach((sp, i) => {
|
||||
const angle = (i / n) * Math.PI * 2;
|
||||
const radius = n === 1 ? 0 : 2.6;
|
||||
const x = Math.cos(angle) * radius;
|
||||
const z = Math.sin(angle) * radius;
|
||||
|
||||
if (!orbMeshes[sp.id]) {
|
||||
const geo = new THREE.SphereGeometry(0.5, 32, 32);
|
||||
const mat = new THREE.MeshStandardMaterial({
|
||||
color: new THREE.Color(severityColor[sp.severity ?? "none"]),
|
||||
emissive: new THREE.Color(severityColor[sp.severity ?? "none"]),
|
||||
emissiveIntensity: 0.6,
|
||||
roughness: 0.4,
|
||||
metalness: 0,
|
||||
});
|
||||
const orb = new THREE.Mesh(geo, mat);
|
||||
orb.position.set(x, 0, z);
|
||||
group.add(orb);
|
||||
orbMeshes[sp.id] = orb;
|
||||
|
||||
const ringGeo = new THREE.TorusGeometry(0.75, 0.03, 16, 64);
|
||||
const ringMat = new THREE.MeshBasicMaterial({
|
||||
color: new THREE.Color(severityColor[sp.severity ?? "none"]),
|
||||
transparent: true,
|
||||
opacity: 0.5,
|
||||
});
|
||||
const ring = new THREE.Mesh(ringGeo, ringMat);
|
||||
ring.rotation.x = Math.PI / 2;
|
||||
ring.position.set(x, 0, z);
|
||||
group.add(ring);
|
||||
ringMeshes[sp.id] = ring;
|
||||
} else {
|
||||
orbMeshes[sp.id].position.x = x;
|
||||
orbMeshes[sp.id].position.z = z;
|
||||
ringMeshes[sp.id].position.x = x;
|
||||
ringMeshes[sp.id].position.z = z;
|
||||
}
|
||||
});
|
||||
// remove orbs no longer present
|
||||
for (const id of Object.keys(orbMeshes)) {
|
||||
if (!list.find((s) => s.id === id)) {
|
||||
group.remove(orbMeshes[id]);
|
||||
(orbMeshes[id].geometry as THREE.BufferGeometry).dispose();
|
||||
group.remove(ringMeshes[id]);
|
||||
(ringMeshes[id].geometry as THREE.BufferGeometry).dispose();
|
||||
delete orbMeshes[id];
|
||||
delete ringMeshes[id];
|
||||
}
|
||||
}
|
||||
};
|
||||
layout();
|
||||
|
||||
const light = new THREE.PointLight(0xffffff, 1.2, 50);
|
||||
light.position.set(0, 4, 6);
|
||||
ctx.scene.add(light);
|
||||
const amb = new THREE.AmbientLight(0xffffff, 0.4);
|
||||
ctx.scene.add(amb);
|
||||
|
||||
(ctx as any)._layout = layout;
|
||||
(ctx as any)._orbs = orbMeshes;
|
||||
(ctx as any)._rings = ringMeshes;
|
||||
|
||||
return () => {};
|
||||
},
|
||||
onFrame: (ctx, t) => {
|
||||
const layout = (ctx as any)._layout as () => void;
|
||||
const orbs = (ctx as any)._orbs as Record<string, THREE.Mesh>;
|
||||
const rings = (ctx as any)._rings as Record<string, THREE.Mesh>;
|
||||
// relayout in case speaker set changed
|
||||
layout();
|
||||
for (const sp of speakersRef.current) {
|
||||
const orb = orbs[sp.id];
|
||||
const ring = rings[sp.id];
|
||||
if (!orb || !ring) continue;
|
||||
const targetY = sp.speaking
|
||||
? 0.6 + Math.sin(t * 4 + (sp.id.charCodeAt(0) || 1)) * 0.15
|
||||
: 0;
|
||||
orb.position.y += (targetY - orb.position.y) * 0.1;
|
||||
const ringScale = sp.speaking ? 1.25 + Math.sin(t * 5) * 0.1 : 1;
|
||||
ring.scale.setScalar(ringScale);
|
||||
(ring.material as THREE.MeshBasicMaterial).opacity = sp.speaking
|
||||
? 0.7
|
||||
: 0.3;
|
||||
}
|
||||
ctx.scene.rotation.y = t * 0.08;
|
||||
},
|
||||
});
|
||||
|
||||
return <div ref={ref} className={className} aria-hidden />;
|
||||
}
|
||||
@@ -1,91 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useRef } from "react";
|
||||
import * as THREE from "three";
|
||||
import { useThreeScene } from "./use-three-scene";
|
||||
|
||||
export interface SignalFieldProps {
|
||||
/** 0..1 — scales particle pulse speed + opacity */
|
||||
activity?: number;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Dashboard hero. A particle field whose idle rotation + breathing pulse
|
||||
* reflects live activity. Warm signal-lime palette, additive glow, no harsh
|
||||
* white. Pointer parallax via camera lerp.
|
||||
*/
|
||||
export function SignalField({ activity = 0.4, className }: SignalFieldProps) {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const activityRef = useRef(activity);
|
||||
activityRef.current = activity;
|
||||
|
||||
useThreeScene(ref, {
|
||||
setup: (ctx) => {
|
||||
const w = ctx.width;
|
||||
const h = ctx.height;
|
||||
const area = w * h;
|
||||
const count = Math.min(900, Math.max(220, Math.floor(area / 2000)));
|
||||
|
||||
const positions = new Float32Array(count * 3);
|
||||
const phases = new Float32Array(count);
|
||||
const radius = 4.2;
|
||||
for (let i = 0; i < count; i++) {
|
||||
const r = radius * (0.25 + Math.random() * 0.75);
|
||||
const theta = Math.random() * Math.PI * 2;
|
||||
const phi = Math.acos(2 * Math.random() - 1);
|
||||
positions[i * 3] = r * Math.sin(phi) * Math.cos(theta);
|
||||
positions[i * 3 + 1] = r * Math.sin(phi) * Math.sin(theta) * 0.6;
|
||||
positions[i * 3 + 2] = r * Math.cos(phi);
|
||||
phases[i] = Math.random() * Math.PI * 2;
|
||||
}
|
||||
|
||||
const geo = new THREE.BufferGeometry();
|
||||
geo.setAttribute("position", new THREE.BufferAttribute(positions, 3));
|
||||
|
||||
const mat = new THREE.PointsMaterial({
|
||||
color: new THREE.Color("oklch(0.82 0.18 125)"),
|
||||
size: 0.045,
|
||||
transparent: true,
|
||||
opacity: 0.85,
|
||||
blending: THREE.AdditiveBlending,
|
||||
depthWrite: false,
|
||||
});
|
||||
|
||||
const points = new THREE.Points(geo, mat);
|
||||
ctx.scene.add(points);
|
||||
|
||||
const key = { x: 0, y: 0 };
|
||||
const onMove = (e: PointerEvent) => {
|
||||
const rect = ctx.container.getBoundingClientRect();
|
||||
key.x = ((e.clientX - rect.left) / rect.width - 0.5) * 2;
|
||||
key.y = ((e.clientY - rect.top) / rect.height - 0.5) * 2;
|
||||
};
|
||||
ctx.container.addEventListener("pointermove", onMove);
|
||||
|
||||
(ctx as any)._key = key;
|
||||
(ctx as any)._points = points;
|
||||
(ctx as any)._phases = phases;
|
||||
|
||||
return () => {
|
||||
ctx.container.removeEventListener("pointermove", onMove);
|
||||
};
|
||||
},
|
||||
onFrame: (ctx, t) => {
|
||||
const points = (ctx as any)._points as THREE.Points;
|
||||
const key = (ctx as any)._key as { x: number; y: number };
|
||||
const phases = (ctx as any)._phases as Float32Array;
|
||||
const act = activityRef.current;
|
||||
const pulse = 1 + Math.sin(t * (1.2 + act * 2.2)) * 0.08 * (0.5 + act);
|
||||
points.scale.setScalar(pulse);
|
||||
points.rotation.y = t * (0.05 + act * 0.12);
|
||||
points.rotation.x = Math.sin(t * 0.2) * 0.1;
|
||||
// parallax
|
||||
ctx.camera.position.x += (key.x * 1.4 - ctx.camera.position.x) * 0.04;
|
||||
ctx.camera.position.y += (-key.y * 1.0 - ctx.camera.position.y) * 0.04;
|
||||
ctx.camera.lookAt(0, 0, 0);
|
||||
},
|
||||
});
|
||||
|
||||
return <div ref={ref} className={className} aria-hidden />;
|
||||
}
|
||||
@@ -1,96 +0,0 @@
|
||||
"use client";
|
||||
|
||||
export interface StaticFallbackProps {
|
||||
variant?: "signal" | "orb";
|
||||
count?: number;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 2D SVG silhouette used when WebGL is unavailable — so the hero still reads
|
||||
* as a living visual, never blank. `variant="signal"` = drifting dot grid;
|
||||
* `variant="orb"` = speaker orbs.
|
||||
*/
|
||||
export function StaticFallback({
|
||||
variant = "signal",
|
||||
count = 60,
|
||||
className,
|
||||
}: StaticFallbackProps) {
|
||||
if (variant === "orb") {
|
||||
const orbs = Array.from({ length: count > 12 ? 12 : count }, (_, i) => {
|
||||
const angle = (i / 12) * Math.PI * 2;
|
||||
const r = 60;
|
||||
return {
|
||||
x: 100 + Math.cos(angle) * r,
|
||||
y: 100 + Math.sin(angle) * r,
|
||||
d: i * 0.3,
|
||||
};
|
||||
});
|
||||
return (
|
||||
<svg
|
||||
viewBox="0 0 200 200"
|
||||
className={className}
|
||||
aria-hidden
|
||||
preserveAspectRatio="xMidYMid slice"
|
||||
>
|
||||
<title>Speaker orbs</title>
|
||||
<rect width="200" height="200" fill="oklch(0.18 0.02 70)" />
|
||||
{orbs.map((o, i) => (
|
||||
<g
|
||||
key={i}
|
||||
style={{ animation: `fade-up 1.2s ${o.d}s infinite alternate` }}
|
||||
>
|
||||
<circle
|
||||
cx={o.x}
|
||||
cy={o.y}
|
||||
r={12}
|
||||
fill="oklch(0.82 0.18 125 / 0.5)"
|
||||
/>
|
||||
<circle
|
||||
cx={o.x}
|
||||
cy={o.y}
|
||||
r={20}
|
||||
fill="none"
|
||||
stroke="oklch(0.82 0.18 125 / 0.3)"
|
||||
strokeWidth={1}
|
||||
/>
|
||||
</g>
|
||||
))}
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
const dots = Array.from({ length: count }, (_, i) => ({
|
||||
x: (i * 53) % 200,
|
||||
y: (i * 89) % 200,
|
||||
r: 1.5 + ((i * 7) % 3),
|
||||
}));
|
||||
return (
|
||||
<svg
|
||||
viewBox="0 0 200 200"
|
||||
className={className}
|
||||
aria-hidden
|
||||
preserveAspectRatio="xMidYMid slice"
|
||||
>
|
||||
<title>Signal dots</title>
|
||||
<rect width="200" height="200" fill="oklch(0.18 0.02 70)" />
|
||||
{dots.map((d, i) => (
|
||||
<circle
|
||||
key={i}
|
||||
cx={d.x}
|
||||
cy={d.y}
|
||||
r={d.r}
|
||||
fill="oklch(0.82 0.18 125 / 0.4)"
|
||||
>
|
||||
<animate
|
||||
attributeName="opacity"
|
||||
values="0.2;0.8;0.2"
|
||||
dur="3s"
|
||||
begin={`${i * 0.05}s`}
|
||||
repeatCount="indefinite"
|
||||
/>
|
||||
</circle>
|
||||
))}
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -1,135 +0,0 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
import * as THREE from "three";
|
||||
|
||||
export interface ThreeSceneOptions {
|
||||
/** Called once after renderer/scene/camera are created. */
|
||||
setup: (ctx: ThreeSceneCtx) => (() => void) | void;
|
||||
/** Optional per-frame callback. */
|
||||
onFrame?: (ctx: ThreeSceneCtx, t: number) => void;
|
||||
background?: string;
|
||||
}
|
||||
|
||||
export interface ThreeSceneCtx {
|
||||
renderer: THREE.WebGLRenderer;
|
||||
scene: THREE.Scene;
|
||||
camera: THREE.PerspectiveCamera;
|
||||
container: HTMLDivElement;
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared Three.js lifecycle hook:
|
||||
* - capped DPR [1, 1.75], high-performance hint
|
||||
* - RAF loop paused when tab hidden
|
||||
* - resize observer
|
||||
* - full geometry/material/renderer dispose on unmount
|
||||
*
|
||||
* `setup` may return a cleanup fn (e.g. to remove its own listeners).
|
||||
*/
|
||||
export function useThreeScene(
|
||||
containerRef: React.RefObject<HTMLDivElement | null>,
|
||||
opts: ThreeSceneOptions,
|
||||
) {
|
||||
const optsRef = useRef(opts);
|
||||
optsRef.current = opts;
|
||||
|
||||
useEffect(() => {
|
||||
const container = containerRef.current;
|
||||
if (!container) return;
|
||||
|
||||
let disposed = false;
|
||||
let cleanup: (() => void) | void;
|
||||
let raf = 0;
|
||||
let ctx: ThreeSceneCtx;
|
||||
|
||||
const init = () => {
|
||||
const width = container.clientWidth || 1;
|
||||
const height = container.clientHeight || 1;
|
||||
|
||||
const renderer = new THREE.WebGLRenderer({
|
||||
antialias: true,
|
||||
alpha: true,
|
||||
powerPreference: "high-performance",
|
||||
});
|
||||
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 1.75));
|
||||
renderer.setSize(width, height);
|
||||
container.appendChild(renderer.domElement);
|
||||
renderer.domElement.style.display = "block";
|
||||
renderer.domElement.style.width = "100%";
|
||||
renderer.domElement.style.height = "100%";
|
||||
|
||||
const scene = new THREE.Scene();
|
||||
if (optsRef.current.background) {
|
||||
scene.background = new THREE.Color(optsRef.current.background);
|
||||
}
|
||||
scene.fog = new THREE.FogExp2(0x000000, 0.06);
|
||||
|
||||
const camera = new THREE.PerspectiveCamera(55, width / height, 0.1, 100);
|
||||
camera.position.set(0, 0, 6);
|
||||
|
||||
ctx = { renderer, scene, camera, container, width, height };
|
||||
const c = optsRef.current.setup(ctx);
|
||||
if (typeof c === "function") cleanup = c;
|
||||
|
||||
const start = performance.now();
|
||||
const loop = () => {
|
||||
if (disposed || document.hidden) {
|
||||
raf = requestAnimationFrame(loop);
|
||||
return;
|
||||
}
|
||||
const t = (performance.now() - start) / 1000;
|
||||
optsRef.current.onFrame?.(ctx, t);
|
||||
renderer.render(scene, camera);
|
||||
raf = requestAnimationFrame(loop);
|
||||
};
|
||||
raf = requestAnimationFrame(loop);
|
||||
|
||||
const ro = new ResizeObserver(() => {
|
||||
const w = container.clientWidth || 1;
|
||||
const h = container.clientHeight || 1;
|
||||
ctx.width = w;
|
||||
ctx.height = h;
|
||||
renderer.setSize(w, h);
|
||||
camera.aspect = w / h;
|
||||
camera.updateProjectionMatrix();
|
||||
});
|
||||
ro.observe(container);
|
||||
|
||||
const onVis = () => {
|
||||
/* loop checks document.hidden */
|
||||
};
|
||||
document.addEventListener("visibilitychange", onVis);
|
||||
|
||||
(ctx as any)._ro = ro;
|
||||
(ctx as any)._onVis = onVis;
|
||||
};
|
||||
|
||||
init();
|
||||
|
||||
return () => {
|
||||
disposed = true;
|
||||
cancelAnimationFrame(raf);
|
||||
if (typeof cleanup === "function") cleanup();
|
||||
const c = ctx as any;
|
||||
if (c?._ro) c._ro.disconnect();
|
||||
if (c?._onVis) document.removeEventListener("visibilitychange", c._onVis);
|
||||
if (ctx) {
|
||||
ctx.scene.traverse((obj) => {
|
||||
const mesh = obj as THREE.Mesh;
|
||||
if (mesh.geometry) mesh.geometry.dispose?.();
|
||||
const mat = mesh.material as
|
||||
| THREE.Material
|
||||
| THREE.Material[]
|
||||
| undefined;
|
||||
if (Array.isArray(mat)) mat.forEach((m) => m.dispose());
|
||||
else mat?.dispose?.();
|
||||
});
|
||||
ctx.renderer.dispose();
|
||||
if (ctx.renderer.domElement.parentNode === container) {
|
||||
container.removeChild(ctx.renderer.domElement);
|
||||
}
|
||||
}
|
||||
};
|
||||
}, [containerRef]);
|
||||
}
|
||||
@@ -1,35 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { type ReactNode, useEffect, useRef } from "react";
|
||||
|
||||
export interface WebGLGuardProps {
|
||||
children: ReactNode;
|
||||
fallback: ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Detects WebGL support. If unavailable (old device / privacy browser /
|
||||
* headless without GPU), renders `fallback` instead of the 3D scene so the
|
||||
* page is never blank.
|
||||
*/
|
||||
export function WebGLGuard({ children, fallback }: WebGLGuardProps) {
|
||||
const supported = useRef<boolean | null>(null);
|
||||
|
||||
if (supported.current === null) {
|
||||
if (typeof window === "undefined") {
|
||||
supported.current = false;
|
||||
} else {
|
||||
try {
|
||||
const canvas = document.createElement("canvas");
|
||||
supported.current = !!(
|
||||
window.WebGLRenderingContext &&
|
||||
(canvas.getContext("webgl2") || canvas.getContext("webgl"))
|
||||
);
|
||||
} catch {
|
||||
supported.current = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return <>{supported.current ? children : fallback}</>;
|
||||
}
|
||||
@@ -1,62 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo } from "react";
|
||||
import { Avatar } from "@/components/primitives/avatar";
|
||||
import { Badge } from "@/components/primitives/badge";
|
||||
import type { ActiveSpeaker } from "@/lib/types";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export interface ActiveSpeakersPanelProps {
|
||||
speakers: ActiveSpeaker[];
|
||||
}
|
||||
|
||||
export function ActiveSpeakersPanel({ speakers }: ActiveSpeakersPanelProps) {
|
||||
const sorted = useMemo(
|
||||
() => [...speakers].sort((a, b) => Number(b.speaking) - Number(a.speaking)),
|
||||
[speakers],
|
||||
);
|
||||
|
||||
if (sorted.length === 0) {
|
||||
return (
|
||||
<div className="surface p-5 text-center text-sm text-[var(--color-ink-soft)]">
|
||||
No speakers in range.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="surface divide-y divide-[var(--color-hairline)] overflow-hidden">
|
||||
<div className="px-4 py-2.5 text-xs font-medium uppercase tracking-wide text-[var(--color-ink-soft)]">
|
||||
Active speakers ({sorted.length})
|
||||
</div>
|
||||
<div className="flex flex-col">
|
||||
{sorted.map((s) => (
|
||||
<div
|
||||
key={s.userId}
|
||||
className={cn(
|
||||
"flex items-center gap-3 px-4 py-2.5 transition-colors",
|
||||
s.speaking && "bg-[var(--color-signal)]/5",
|
||||
)}
|
||||
>
|
||||
<div className="relative">
|
||||
<Avatar src={s.avatar} name={s.username} size={34} />
|
||||
{s.speaking && (
|
||||
<span className="absolute -bottom-0.5 -right-0.5 size-2.5 rounded-full bg-[var(--color-signal)] ring-2 ring-[var(--color-canvas)]" />
|
||||
)}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="text-sm font-medium">{s.username}</span>
|
||||
{s.speaking ? (
|
||||
<Badge tone="signal">speaking</Badge>
|
||||
) : (
|
||||
<Badge tone="neutral">idle</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,79 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { Mic, MicOff } from "lucide-react";
|
||||
import type { ActiveSpeaker } from "@/lib/types";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface ActivityTimelineProps {
|
||||
data?: ActiveSpeaker[];
|
||||
}
|
||||
|
||||
export function VoiceActivityTimeline({ data = [] }: ActivityTimelineProps) {
|
||||
const sorted = [...data].sort((a, b) =>
|
||||
a.speaking === b.speaking
|
||||
? String(a.username).localeCompare(b.username)
|
||||
: a.speaking
|
||||
? -1
|
||||
: 1,
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="surface p-5">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<span className="text-xs font-semibold tracking-wide uppercase text-[var(--color-ink-soft)]">
|
||||
Voice Activity
|
||||
</span>
|
||||
<span className="text-[10px] text-[var(--color-ink-soft)] ml-auto">
|
||||
{sorted.length} speaker{sorted.length !== 1 ? "s" : ""} · live
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{sorted.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-12 text-center">
|
||||
<MicOff className="size-8 text-[var(--color-ink-soft)] mb-2" />
|
||||
<p className="text-xs text-[var(--color-ink-soft)]">
|
||||
No speakers in the monitored voice channel.
|
||||
</p>
|
||||
<p className="mt-1 text-[10px] text-[var(--color-ink-soft)]">
|
||||
Connect to a voice channel to see live activity here.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-1.5">
|
||||
{sorted.map((s) => (
|
||||
<div
|
||||
key={s.userId}
|
||||
className="flex items-center gap-2 rounded-[var(--radius-r-panel)] bg-[var(--color-surface-2)] px-3 py-2"
|
||||
>
|
||||
{s.speaking ? (
|
||||
<Mic className="size-3.5 text-[var(--color-signal)] shrink-0" />
|
||||
) : (
|
||||
<MicOff className="size-3.5 text-[var(--color-ink-soft)] shrink-0" />
|
||||
)}
|
||||
<span
|
||||
className={cn(
|
||||
"truncate text-sm",
|
||||
s.speaking
|
||||
? "text-[var(--color-ink)] font-medium"
|
||||
: "text-[var(--color-ink-soft)]",
|
||||
)}
|
||||
>
|
||||
{s.username}
|
||||
</span>
|
||||
<span
|
||||
className={cn(
|
||||
"ml-auto shrink-0 text-[9px] font-semibold uppercase tracking-widest",
|
||||
s.speaking
|
||||
? "text-[var(--color-signal)]"
|
||||
: "text-[var(--color-ink-soft)]",
|
||||
)}
|
||||
>
|
||||
{s.speaking ? "Speaking" : "Listening"}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,53 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { Pause, Play, Volume2 } from "lucide-react";
|
||||
import { Button } from "@/components/primitives/button";
|
||||
import { Progress } from "@/components/primitives/progress";
|
||||
|
||||
export interface ListenControlProps {
|
||||
listening: boolean;
|
||||
onToggle: (on: boolean) => void;
|
||||
volume: number;
|
||||
onVolume: (v: number) => void;
|
||||
}
|
||||
|
||||
export function ListenControl({
|
||||
listening,
|
||||
onToggle,
|
||||
volume,
|
||||
onVolume,
|
||||
}: ListenControlProps) {
|
||||
return (
|
||||
<div className="surface flex items-center gap-3 p-4">
|
||||
<Button
|
||||
size="icon"
|
||||
variant={listening ? "danger" : "primary"}
|
||||
onClick={() => onToggle(!listening)}
|
||||
aria-pressed={listening}
|
||||
>
|
||||
{listening ? <Pause className="size-5" /> : <Play className="size-5" />}
|
||||
</Button>
|
||||
<div className="flex items-center gap-2 flex-1">
|
||||
<Volume2 className="size-4 text-[var(--color-ink-soft)]" />
|
||||
<Progress
|
||||
value={volume}
|
||||
max={100}
|
||||
tone={listening ? "signal" : undefined}
|
||||
className="flex-1 h-1"
|
||||
/>
|
||||
<input
|
||||
type="range"
|
||||
min={0}
|
||||
max={100}
|
||||
value={volume}
|
||||
onChange={(e) => onVolume(Number(e.target.value))}
|
||||
aria-label="Volume"
|
||||
className="w-24 accent-[var(--color-signal)]"
|
||||
/>
|
||||
</div>
|
||||
<span className="mono text-xs text-[var(--color-ink-soft)]">
|
||||
{listening ? `${volume}%` : "off"}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,56 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { Mic, MicOff } from "lucide-react";
|
||||
import { Button } from "@/components/primitives/button";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export interface MicControlProps {
|
||||
micOn: boolean;
|
||||
onToggle: (on: boolean) => void;
|
||||
levels: Map<number, number>;
|
||||
}
|
||||
|
||||
export function MicControl({ micOn, onToggle, levels }: MicControlProps) {
|
||||
return (
|
||||
<div className="surface flex items-center gap-3 p-4">
|
||||
<Button
|
||||
size="icon"
|
||||
variant={micOn ? "primary" : "danger"}
|
||||
onClick={() => onToggle(!micOn)}
|
||||
aria-pressed={micOn}
|
||||
>
|
||||
{micOn ? <MicOff className="size-5" /> : <Mic className="size-5" />}
|
||||
</Button>
|
||||
<div className="flex-1">
|
||||
<div className="mb-1 flex justify-between text-xs">
|
||||
<span className="text-[var(--color-ink-soft)]">
|
||||
{micOn ? "Mic active" : "Mic muted"}
|
||||
</span>
|
||||
<span className="mono text-[var(--color-ink-soft)]">
|
||||
{levels.size > 0 ? `${levels.size} active` : "ready"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-end gap-0.5 h-5">
|
||||
{Array.from(levels.entries())
|
||||
.slice(0, 10)
|
||||
.map(([uid, level]) => (
|
||||
<div
|
||||
key={uid}
|
||||
className={cn(
|
||||
"w-0.5 rounded-t-[2px] bg-[var(--color-signal)] transition-[height]",
|
||||
micOn ? "h-4" : "h-1 opacity-30",
|
||||
)}
|
||||
style={{ height: micOn ? `${level * 16}px` : "4px" }}
|
||||
/>
|
||||
))}
|
||||
{Array.from({ length: Math.max(0, 10 - levels.size) }).map((_, i) => (
|
||||
<div
|
||||
key={`empty-${i}`}
|
||||
className="w-0.5 opacity-15 h-1 rounded-[2px] bg-[var(--color-ink-soft)]"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,41 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { Avatar } from "@/components/primitives/avatar";
|
||||
import type { ActiveSpeaker } from "@/lib/types";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export function SpeakerWaveform({ speakers }: { speakers: ActiveSpeaker[] }) {
|
||||
if (speakers.length === 0) {
|
||||
return (
|
||||
<div className="flex items-center gap-2 rounded-[var(--radius-r-control)] bg-[var(--color-surface-2)] px-3 py-1.5 text-xs text-[var(--color-ink-soft)]">
|
||||
No active speakers
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className="flex items-end gap-1.5 rounded-[var(--radius-r-control)] bg-[var(--color-surface-2)] px-3 py-2">
|
||||
{speakers.map((s) => (
|
||||
<div key={s.userId} className="flex flex-col items-center gap-1">
|
||||
<Avatar src={s.avatar} name={s.username} size={28} />
|
||||
<div className="flex items-end gap-0.5">
|
||||
{Array.from({ length: 5 }).map((_, i) => (
|
||||
<span
|
||||
key={i}
|
||||
className={cn(
|
||||
"w-0.5 rounded-t-[2px] bg-[var(--color-signal)] transition-[height]",
|
||||
s.speaking ? "h-3 animate-eq" : "h-1 opacity-30",
|
||||
)}
|
||||
style={
|
||||
s.speaking ? { animationDelay: `${i * 0.08}s` } : undefined
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<span className="mono text-[9px] text-[var(--color-ink-soft)]">
|
||||
{s.username.split(/[\s.#]/)[0]}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import { Radio } from "lucide-react";
|
||||
import { Avatar } from "@/components/primitives";
|
||||
import type { ActiveSpeaker } from "@/lib/types";
|
||||
|
||||
export function VoiceStage({ speakers }: { speakers: ActiveSpeaker[] }) {
|
||||
const n = speakers.length;
|
||||
const speaking = speakers.filter((s) => s.speaking).length;
|
||||
const live = speaking > 0;
|
||||
|
||||
return (
|
||||
<div className="relative mx-auto aspect-square w-full max-w-[360px]">
|
||||
{/* orbiting dashed ring */}
|
||||
<div className="absolute inset-8 rounded-full border border-dashed border-hairline opacity-40 animate-spin-disc" />
|
||||
{/* ripple halos */}
|
||||
<div className="absolute left-1/2 top-1/2 size-72 -translate-x-1/2 -translate-y-1/2 rounded-full border border-hairline" />
|
||||
<div
|
||||
className="absolute left-1/2 top-1/2 size-48 -translate-x-1/2 -translate-y-1/2 rounded-full bg-signal/5 animate-breathe"
|
||||
style={{ opacity: live ? 1 : 0.4 }}
|
||||
/>
|
||||
|
||||
{/* central core */}
|
||||
<div
|
||||
className="absolute left-1/2 top-1/2 flex size-24 -translate-x-1/2 -translate-y-1/2 flex-col items-center justify-center rounded-full border backdrop-blur transition-colors"
|
||||
style={{
|
||||
borderColor: live ? "var(--color-signal)" : "var(--color-hairline)",
|
||||
boxShadow: live ? "0 0 50px -8px var(--color-signal-glow)" : "none",
|
||||
background: "oklch(1 0 0 / 0.04)",
|
||||
}}
|
||||
>
|
||||
<Radio className={`size-7 ${live ? "text-signal" : "text-ink-faint"}`} />
|
||||
<span className="mono mt-1 text-xs text-ink-soft">{n} live</span>
|
||||
</div>
|
||||
|
||||
{/* speakers on orbit */}
|
||||
{speakers.map((s, i) => {
|
||||
const angle = (i / Math.max(n, 1)) * Math.PI * 2 - Math.PI / 2;
|
||||
const radius = 42;
|
||||
const x = 50 + radius * Math.cos(angle);
|
||||
const y = 50 + radius * Math.sin(angle);
|
||||
return (
|
||||
<div
|
||||
key={s.userId}
|
||||
className="absolute -translate-x-1/2 -translate-y-1/2"
|
||||
style={{ left: `${x}%`, top: `${y}%` }}
|
||||
>
|
||||
<div className="relative flex flex-col items-center gap-1">
|
||||
<span className="relative">
|
||||
<Avatar src={s.avatar} name={s.username} size={46} ring={s.speaking} />
|
||||
{s.speaking && (
|
||||
<span className="absolute inset-0 rounded-full ring-2 ring-signal animate-pulse-ring" />
|
||||
)}
|
||||
</span>
|
||||
<span className="mono max-w-[88px] truncate rounded-full bg-black/40 px-2 py-0.5 text-[0.6rem] text-ink-soft">
|
||||
{s.username}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user