chore(lint): biome cleanup across services — format, sort imports, drop unused

- discord-gateway: 74 lint errors -> 0 (format, import sorting, unused
  imports/vars, dead breath var)
- backend: format + sort imports (11 warnings left: noExplicitAny)
- frontend: remove unused imports, drop dead breathing var, fix
  useExhaustiveDependencies (scroll keyed on messages), a11y biome-ignore
  for drag surface + stopPropagation container (mouse-only gestures)
- remaining warnings are false positives: index keys on static lists,
  <img> in static export (next/image unsupported), noExplicitAny

tsc --noEmit clean on all 3 services; vitest green (60+36).
This commit is contained in:
asepharyana
2026-08-01 22:09:02 +07:00
parent 2357421841
commit 6293d588bc
123 changed files with 466 additions and 308 deletions
@@ -12,7 +12,6 @@ import { Input } from "@/components/ui/input";
import { Progress } from "@/components/ui/progress";
import { useMessageSearch, useReanalyze } from "@/hooks";
import { renderMessageContent, safeParseJsonArray } from "@/lib/format";
import type { MessageRecord } from "@/lib/types";
import { cn } from "@/lib/utils";
export function SearchPanel() {
@@ -1,7 +1,7 @@
"use client";
import { useRef, useEffect } from "react";
import { Send } from "lucide-react";
import { useEffect, useRef } from "react";
import { useChatbot } from "./chatbot-context";
interface ChatPanelProps {
@@ -15,11 +15,12 @@ export function ChatPanel({ inputRef: externalInputRef }: ChatPanelProps) {
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]);
}, [messages]);
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
@@ -35,7 +36,9 @@ export function ChatPanel({ inputRef: externalInputRef }: ChatPanelProps) {
<div ref={listRef} className="flex-1 overflow-y-auto px-2 py-1 space-y-1">
{messages.length === 0 && (
<div className="flex items-center justify-center h-full">
<p className="text-[10px] text-text-secondary/40">Ask chatbot anything</p>
<p className="text-[10px] text-text-secondary/40">
Ask chatbot anything
</p>
</div>
)}
{messages.slice(-8).map((msg, i) => (
@@ -58,9 +61,18 @@ export function ChatPanel({ inputRef: externalInputRef }: ChatPanelProps) {
<div className="flex justify-start">
<div className="glass rounded-lg px-2 py-1">
<span className="inline-flex gap-0.5">
<span className="size-1 rounded-full bg-text-secondary animate-bounce" style={{ animationDelay: "0ms" }} />
<span className="size-1 rounded-full bg-text-secondary animate-bounce" style={{ animationDelay: "150ms" }} />
<span className="size-1 rounded-full bg-text-secondary animate-bounce" style={{ animationDelay: "300ms" }} />
<span
className="size-1 rounded-full bg-text-secondary animate-bounce"
style={{ animationDelay: "0ms" }}
/>
<span
className="size-1 rounded-full bg-text-secondary animate-bounce"
style={{ animationDelay: "150ms" }}
/>
<span
className="size-1 rounded-full bg-text-secondary animate-bounce"
style={{ animationDelay: "300ms" }}
/>
</span>
</div>
</div>
@@ -68,7 +80,10 @@ export function ChatPanel({ inputRef: externalInputRef }: ChatPanelProps) {
</div>
{/* Input bar */}
<form onSubmit={handleSubmit} className="flex items-center gap-1 px-2 py-1.5 border-t border-glass-border shrink-0">
<form
onSubmit={handleSubmit}
className="flex items-center gap-1 px-2 py-1.5 border-t border-glass-border shrink-0"
>
<input
ref={inputRef}
type="text"
@@ -33,7 +33,14 @@ export function ChatbotCanvas() {
ctx.clearRect(0, 0, w, h);
// Background circle
const gradient = ctx.createRadialGradient(w / 2, h / 2 - 10, 10, w / 2, h / 2, 80);
const gradient = ctx.createRadialGradient(
w / 2,
h / 2 - 10,
10,
w / 2,
h / 2,
80,
);
gradient.addColorStop(0, "oklch(0.62 0.17 215 / 0.8)");
gradient.addColorStop(0.6, "oklch(0.12 0.02 245 / 0.9)");
gradient.addColorStop(1, "oklch(0.07 0.015 250 / 1)");
@@ -112,11 +119,6 @@ export function ChatbotCanvas() {
ctx.arc(w / 2, 75, 6, 0.1, Math.PI - 0.1);
ctx.stroke();
}
// Breathing animation — subtle canvas shift
const breath = Math.sin(Date.now() / 1000) * 1.5;
// Applied via CSS transform on container instead
}, [expression]);
return (
@@ -1,10 +1,10 @@
"use client";
import { useRef, useState, useCallback, useEffect } from "react";
import { Bot, MessageCircle, Minimize2 } from "lucide-react";
import { useChatbot } from "./chatbot-context";
import { ChatbotCanvas } from "./chatbot-canvas";
import { useCallback, useEffect, useRef, useState } from "react";
import { ChatPanel } from "./chat-panel";
import { ChatbotCanvas } from "./chatbot-canvas";
import { useChatbot } from "./chatbot-context";
export function ChatbotContainer() {
const { minimized, setMinimized, chatOpen, setChatOpen } = useChatbot();
@@ -13,15 +13,21 @@ export function ChatbotContainer() {
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 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 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), []);
@@ -35,6 +41,7 @@ export function ChatbotContainer() {
}, [chatOpen]);
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)` }}
@@ -62,6 +69,7 @@ export function ChatbotContainer() {
) : (
<>
{/* Drag handle + controls */}
{/* biome-ignore lint/a11y/noStaticElementInteractions: drag handle — mouse-only gesture, keyboard users use the buttons in this header */}
<div
className="flex items-center justify-between px-3 py-1.5 border-b border-glass-border cursor-grab active:cursor-grabbing"
onMouseDown={handleMouseDown}
@@ -10,7 +10,6 @@ import {
useState,
} from "react";
import { chatbotApi } from "@/lib/api";
import type { ChatbotHistoryRow } from "@/lib/types";
export type ChatbotExpression =
| "idle"
@@ -1,4 +1,4 @@
export { ChatbotProvider, useChatbot } from "./chatbot-context";
export { ChatbotContainer } from "./chatbot-container";
export { ChatbotCanvas } from "./chatbot-canvas";
export { ChatPanel } from "./chat-panel";
export { ChatbotCanvas } from "./chatbot-canvas";
export { ChatbotContainer } from "./chatbot-container";
export { ChatbotProvider, useChatbot } from "./chatbot-context";
@@ -1,7 +1,7 @@
"use client";
import { cn } from "@/lib/utils";
import type { ComponentPropsWithoutRef } from "react";
import { cn } from "@/lib/utils";
type GlassVariant = "base" | "elevated" | "interactive" | "danger";
@@ -11,12 +11,10 @@ interface GlassCardProps extends ComponentPropsWithoutRef<"div"> {
const variantStyles: Record<GlassVariant, string> = {
base: "glass rounded-[var(--radius-card)]",
elevated:
"glass-elevated rounded-[var(--radius-card)]",
elevated: "glass-elevated rounded-[var(--radius-card)]",
interactive:
"glass rounded-[var(--radius-card)] transition-all duration-150 hover:scale-[1.01] hover:border-[var(--color-border-glow)] cursor-pointer",
danger:
"glass rounded-[var(--radius-card)] border-red-500/30",
danger: "glass rounded-[var(--radius-card)] border-red-500/30",
};
export function GlassCard({
@@ -26,10 +24,7 @@ export function GlassCard({
...props
}: GlassCardProps) {
return (
<div
className={cn(variantStyles[variant], "p-5", className)}
{...props}
>
<div className={cn(variantStyles[variant], "p-5", className)} {...props}>
{children}
</div>
);
@@ -1,7 +1,7 @@
"use client";
import { cn } from "@/lib/utils";
import type { ComponentPropsWithoutRef } from "react";
import { cn } from "@/lib/utils";
interface GlassPanelProps extends ComponentPropsWithoutRef<"div"> {
dense?: boolean;
@@ -26,7 +26,9 @@ export function MobileNav() {
)}
>
<Icon className="size-5" />
<span className="text-[10px] font-medium leading-tight">{label}</span>
<span className="text-[10px] font-medium leading-tight">
{label}
</span>
{active && (
<span className="absolute -top-0.5 left-1/2 -translate-x-1/2 size-1 rounded-full bg-primary shadow-[0_0_6px] shadow-primary/80" />
)}
@@ -1,9 +1,9 @@
"use client";
import { usePathname, useRouter } from "next/navigation";
import { Moon, Sun } from "lucide-react";
import { usePathname, useRouter } from "next/navigation";
import { useEffect, useState } from "react";
import { navItems, isActivePath } from "@/lib/navigation";
import { isActivePath, navItems } from "@/lib/navigation";
export function TopNav() {
const pathname = usePathname();
@@ -1,6 +1,6 @@
"use client";
import { Disc3, Music, Play, SkipForward, Square, Volume2 } from "lucide-react";
import { Disc3, Music, SkipForward, Square, Volume2 } from "lucide-react";
import { useMediaPlayer } from "@/lib/hooks/use-media-player";
export function MiniPlayer() {
@@ -16,7 +16,10 @@ export function MiniPlayer() {
<div className="flex items-center gap-2.5 min-w-0 flex-1 max-w-[280px]">
<div className="size-8 rounded-md bg-gradient-to-br from-primary/20 to-primary/5 border border-primary/10 flex items-center justify-center shrink-0">
{playing ? (
<Disc3 className="size-4 text-primary animate-spin" style={{ animationDuration: "4s" }} />
<Disc3
className="size-4 text-primary animate-spin"
style={{ animationDuration: "4s" }}
/>
) : (
<Music className="size-4 text-text-secondary" />
)}
@@ -27,9 +30,7 @@ export function MiniPlayer() {
</p>
{queue.length > 0 && (
<p className="text-[10px] text-text-secondary/60">
{queue.length > 1
? `${queue.length} in queue`
: "1 in queue"}
{queue.length > 1 ? `${queue.length} in queue` : "1 in queue"}
</p>
)}
</div>
@@ -35,25 +35,37 @@ export function AiAnalysisPanel({
if (!status || status === "pending") {
return (
<GlassPanel dense>
<span className="text-xs text-text-secondary/50">AI analysis pending</span>
<span className="text-xs text-text-secondary/50">
AI analysis pending
</span>
</GlassPanel>
);
}
const flagsArray = typeof flags === "string" ? (flags ? JSON.parse(flags) : []) : (flags || []);
const categoriesArray = typeof categories === "string" ? (categories ? JSON.parse(categories) : []) : (categories || []);
const flagsArray =
typeof flags === "string" ? (flags ? JSON.parse(flags) : []) : flags || [];
const categoriesArray =
typeof categories === "string"
? categories
? JSON.parse(categories)
: []
: categories || [];
return (
<GlassPanel dense className="space-y-2">
<div className="flex items-center gap-2">
<span className="text-xs font-semibold tracking-wide uppercase text-text-secondary">AI Analysis</span>
<span className={cn(
"text-[10px] font-mono px-1.5 py-0.5 rounded",
status === "clean" && "bg-emerald-500/10 text-emerald-500",
status === "flagged" && "bg-accent-purple/10 text-accent-purple",
status === "warn" && "bg-accent-amber/10 text-accent-amber",
status === "error" && "bg-destructive/10 text-destructive",
)}>
<span className="text-xs font-semibold tracking-wide uppercase text-text-secondary">
AI Analysis
</span>
<span
className={cn(
"text-[10px] font-mono px-1.5 py-0.5 rounded",
status === "clean" && "bg-emerald-500/10 text-emerald-500",
status === "flagged" && "bg-accent-purple/10 text-accent-purple",
status === "warn" && "bg-accent-amber/10 text-accent-amber",
status === "error" && "bg-destructive/10 text-destructive",
)}
>
{status}
</span>
</div>
@@ -61,7 +73,14 @@ export function AiAnalysisPanel({
{severity && (
<div className="flex items-center gap-2 text-xs">
<span className="text-text-secondary/60">Severity:</span>
<span className={cn("font-mono font-medium", severityColor[severity] || "")}>{severity}</span>
<span
className={cn(
"font-mono font-medium",
severityColor[severity] || "",
)}
>
{severity}
</span>
</div>
)}
@@ -82,7 +101,12 @@ export function AiAnalysisPanel({
{flagsArray.length > 0 && (
<div className="flex flex-wrap gap-1">
{flagsArray.map((f: string) => (
<span key={f} className="text-[10px] font-mono px-1.5 py-0.5 rounded bg-destructive/10 text-destructive">{f}</span>
<span
key={f}
className="text-[10px] font-mono px-1.5 py-0.5 rounded bg-destructive/10 text-destructive"
>
{f}
</span>
))}
</div>
)}
@@ -90,7 +114,12 @@ export function AiAnalysisPanel({
{categoriesArray.length > 0 && (
<div className="flex flex-wrap gap-1">
{categoriesArray.map((c: string) => (
<span key={c} className="text-[10px] font-mono px-1.5 py-0.5 rounded bg-primary/10 text-primary">{c}</span>
<span
key={c}
className="text-[10px] font-mono px-1.5 py-0.5 rounded bg-primary/10 text-primary"
>
{c}
</span>
))}
</div>
)}
@@ -3,11 +3,13 @@
import { cn } from "@/lib/utils";
const STATUS_STYLES: Record<string, string> = {
clean: "bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 border-emerald-500/20",
clean:
"bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 border-emerald-500/20",
flagged: "bg-red-500/10 text-red-600 dark:text-red-400 border-red-500/20",
warn: "bg-amber-500/10 text-amber-600 dark:text-amber-400 border-amber-500/20",
pending: "bg-muted text-muted-foreground border-border",
processing: "bg-sky-500/10 text-sky-600 dark:text-sky-400 border-sky-500/20 animate-pulse",
processing:
"bg-sky-500/10 text-sky-600 dark:text-sky-400 border-sky-500/20 animate-pulse",
error: "bg-destructive/10 text-destructive border-destructive/20",
};
@@ -12,7 +12,10 @@ export function AttachmentsGrid({ attachments }: AttachmentsGridProps) {
return (
<div className="grid grid-cols-2 gap-2">
{attachments.map((att) => (
<div key={att.id} className="glass rounded-lg overflow-hidden group relative">
<div
key={att.id}
className="glass rounded-lg overflow-hidden group relative"
>
{att.type?.startsWith("image/") ? (
<img
src={att.uploaded_url || att.discord_url}
@@ -2,10 +2,10 @@
import { ArrowLeft, MessageSquare, MessagesSquare } from "lucide-react";
import { GlassCard } from "@/components/glass/card";
import { AttachmentsGrid } from "./attachments-grid";
import { AiAnalysisPanel } from "./ai-analysis-panel";
import { getMessageChannelLabel, renderMessageContent } from "@/lib/format";
import type { AttachmentRecord, MessageRecord } from "@/lib/types";
import { AiAnalysisPanel } from "./ai-analysis-panel";
import { AttachmentsGrid } from "./attachments-grid";
interface MessageDetailViewProps {
message: MessageRecord;
@@ -13,11 +13,19 @@ interface MessageDetailViewProps {
onBack?: () => void;
}
export function MessageDetailView({ message, attachments, onBack }: MessageDetailViewProps) {
export function MessageDetailView({
message,
attachments,
onBack,
}: MessageDetailViewProps) {
return (
<GlassCard variant="base" className="h-full">
{onBack && (
<button type="button" onClick={onBack} className="flex items-center gap-1 text-xs text-text-secondary/60 hover:text-text-primary mb-3 transition-colors">
<button
type="button"
onClick={onBack}
className="flex items-center gap-1 text-xs text-text-secondary/60 hover:text-text-primary mb-3 transition-colors"
>
<ArrowLeft className="size-3" /> Back
</button>
)}
@@ -25,7 +33,9 @@ export function MessageDetailView({ message, attachments, onBack }: MessageDetai
{/* Message header */}
<div className="flex items-center gap-2 mb-3">
<MessageSquare className="size-4 text-primary" />
<span className="font-semibold text-sm text-text-primary">{message.username}</span>
<span className="font-semibold text-sm text-text-primary">
{message.username}
</span>
<span className="text-[10px] text-text-secondary/40 font-mono inline-flex items-center gap-1">
{message.thread_id && <MessagesSquare className="size-3" />}
{getMessageChannelLabel(message)}
@@ -34,7 +44,8 @@ export function MessageDetailView({ message, attachments, onBack }: MessageDetai
{/* Content */}
<div className="text-sm text-text-primary/90 leading-relaxed mb-4 whitespace-pre-wrap">
{renderMessageContent(message.content, message.metadata) || "(no text content)"}
{renderMessageContent(message.content, message.metadata) ||
"(no text content)"}
</div>
{/* Attachments */}
@@ -2,10 +2,10 @@
import { ArrowLeft, MessageSquare, MessagesSquare } from "lucide-react";
import { GlassCard } from "@/components/glass/card";
import { AttachmentsGrid } from "./attachments-grid";
import { AiAnalysisPanel } from "./ai-analysis-panel";
import { getMessageChannelLabel, renderMessageContent } from "@/lib/format";
import type { AttachmentRecord, MessageRecord } from "@/lib/types";
import { AiAnalysisPanel } from "./ai-analysis-panel";
import { AttachmentsGrid } from "./attachments-grid";
interface MessageDetailProps {
message: MessageRecord;
@@ -13,11 +13,19 @@ interface MessageDetailProps {
onBack?: () => void;
}
export function MessageDetail({ message, attachments, onBack }: MessageDetailProps) {
export function MessageDetail({
message,
attachments,
onBack,
}: MessageDetailProps) {
return (
<GlassCard variant="base" className="h-full">
{onBack && (
<button type="button" onClick={onBack} className="flex items-center gap-1 text-xs text-text-secondary/60 hover:text-text-primary mb-3 transition-colors">
<button
type="button"
onClick={onBack}
className="flex items-center gap-1 text-xs text-text-secondary/60 hover:text-text-primary mb-3 transition-colors"
>
<ArrowLeft className="size-3" /> Back
</button>
)}
@@ -25,7 +33,9 @@ export function MessageDetail({ message, attachments, onBack }: MessageDetailPro
{/* Message header */}
<div className="flex items-center gap-2 mb-3">
<MessageSquare className="size-4 text-primary" />
<span className="font-semibold text-sm text-text-primary">{message.username}</span>
<span className="font-semibold text-sm text-text-primary">
{message.username}
</span>
<span className="text-[10px] text-text-secondary/40 font-mono inline-flex items-center gap-1">
{message.thread_id && <MessagesSquare className="size-3" />}
{getMessageChannelLabel(message)}
@@ -34,7 +44,8 @@ export function MessageDetail({ message, attachments, onBack }: MessageDetailPro
{/* Content */}
<div className="text-sm text-text-primary/90 leading-relaxed mb-4 whitespace-pre-wrap">
{renderMessageContent(message.content, message.metadata) || "(no text content)"}
{renderMessageContent(message.content, message.metadata) ||
"(no text content)"}
</div>
{/* Attachments */}
@@ -1,9 +1,9 @@
"use client";
import { Loader2 } from "lucide-react";
import { MessageCard } from "./message-card";
import { Button } from "@/components/ui/button";
import type { MessageRecord } from "@/lib/types";
import { MessageCard } from "./message-card";
interface MessageListProps {
messages: MessageRecord[];
@@ -4,7 +4,6 @@ import { Search, X } from "lucide-react";
import { useEffect, useRef, useState } from "react";
import { useMessageSearch } from "@/hooks";
import { getMessageChannelLabel, renderMessageContent } from "@/lib/format";
import type { MessageRecord } from "@/lib/types";
interface SearchOverlayProps {
open: boolean;
@@ -1,7 +1,7 @@
"use client";
import { useState } from "react";
import { Download, Loader2, Pause, Play } from "lucide-react";
import { useState } from "react";
import { GlassCard } from "@/components/glass/card";
import type { VoiceRecording } from "@/lib/types";
@@ -94,11 +94,7 @@ export function RecordingCard({
</span>
{active && (
<span className="ml-auto inline-flex items-center gap-1 text-[9px] font-semibold uppercase tracking-widest text-primary/90">
{loading
? "Loading"
: playing
? "Now Playing"
: "Paused"}
{loading ? "Loading" : playing ? "Now Playing" : "Paused"}
</span>
)}
</div>
@@ -131,7 +127,12 @@ export function RecordingCard({
</div>
</div>
<div className="flex shrink-0 gap-1" onClick={(e) => e.stopPropagation()}>
{/* biome-ignore lint/a11y/noStaticElementInteractions: stopPropagation container — prevents card play toggle when clicking action buttons */}
{/* biome-ignore lint/a11y/useKeyWithClickEvents: no keyboard interaction — container only swallows clicks destined for the action buttons */}
<div
className="flex shrink-0 gap-1"
onClick={(e) => e.stopPropagation()}
>
{recording.download_url && (
<button
type="button"
@@ -1,8 +1,8 @@
"use client";
import { Loader2, Pause, Play, X } from "lucide-react";
import { useEffect, useRef, useState } from "react";
import { GlassPanel } from "@/components/glass/panel";
import { Loader2, Pause, Play, X } from "lucide-react";
interface RecordingPlayerProps {
url?: string;
@@ -50,7 +50,8 @@ export function RecordingPlayer({
timerRef.current = setInterval(() => {
const audio = audioRef.current;
if (!audio) return;
if (duration === 0 && !Number.isNaN(audio.duration)) setDuration(audio.duration);
if (duration === 0 && !Number.isNaN(audio.duration))
setDuration(audio.duration);
if (!Number.isNaN(audio.currentTime)) setProgress(audio.currentTime);
}, 250);
return () => {
@@ -69,7 +70,10 @@ export function RecordingPlayer({
const pct = duration > 0 ? Math.min(100, (progress / duration) * 100) : 0;
return (
<GlassPanel dense className="fixed bottom-20 left-4 z-30 w-80 flex flex-col gap-1.5">
<GlassPanel
dense
className="fixed bottom-20 left-4 z-30 w-80 flex flex-col gap-1.5"
>
<div className="flex items-center gap-2.5">
<button
type="button"
@@ -99,7 +103,9 @@ export function RecordingPlayer({
<span className="text-[10px] text-primary/80">loading</span>
)}
{error && (
<span className="text-[10px] text-red-400/90">playback failed</span>
<span className="text-[10px] text-red-400/90">
playback failed
</span>
)}
</div>
</div>
@@ -1,11 +1,17 @@
"use client";
import { AlertCircle, RefreshCw } from "lucide-react";
import { Component, type ReactNode } from "react";
import { GlassCard } from "@/components/glass/card";
import { AlertCircle, RefreshCw } from "lucide-react";
interface Props { children: ReactNode; fallback?: ReactNode; }
interface State { hasError: boolean; error?: Error; }
interface Props {
children: ReactNode;
fallback?: ReactNode;
}
interface State {
hasError: boolean;
error?: Error;
}
export class ErrorBoundary extends Component<Props, State> {
state: State = { hasError: false };
@@ -16,18 +22,25 @@ export class ErrorBoundary extends Component<Props, State> {
render() {
if (this.state.hasError) {
return this.props.fallback || (
<GlassCard variant="danger" className="flex flex-col items-center gap-2 py-8">
<AlertCircle className="size-6 text-destructive" />
<p className="text-sm text-text-secondary">{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-primary hover:text-primary/80 transition-colors"
return (
this.props.fallback || (
<GlassCard
variant="danger"
className="flex flex-col items-center gap-2 py-8"
>
<RefreshCw className="size-3" /> Try again
</button>
</GlassCard>
<AlertCircle className="size-6 text-destructive" />
<p className="text-sm text-text-secondary">
{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-primary hover:text-primary/80 transition-colors"
>
<RefreshCw className="size-3" /> Try again
</button>
</GlassCard>
)
);
}
return this.props.children;
@@ -42,11 +42,18 @@ export function VoiceConnectionCard({
return (
<GlassCard variant={connected ? "elevated" : "base"}>
<div className="flex items-center gap-3 mb-4">
<span className={cn("relative flex size-3", connected && "text-emerald-500")}>
<span
className={cn(
"relative flex size-3",
connected && "text-emerald-500",
)}
>
<span
className={cn(
"absolute inline-flex size-full rounded-full opacity-75",
connected ? "bg-emerald-500 animate-pulse-ring" : "bg-destructive",
connected
? "bg-emerald-500 animate-pulse-ring"
: "bg-destructive",
)}
/>
<span
@@ -57,9 +64,13 @@ export function VoiceConnectionCard({
/>
</span>
<div>
<span className="text-sm font-semibold text-text-primary">Voice Connection</span>
<span className="text-sm font-semibold text-text-primary">
Voice Connection
</span>
{activeChannelName && (
<span className="text-xs text-text-secondary/60 ml-2 font-mono">{activeChannelName}</span>
<span className="text-xs text-text-secondary/60 ml-2 font-mono">
{activeChannelName}
</span>
)}
</div>
<div className="ml-auto flex items-center gap-2">
@@ -68,7 +79,11 @@ export function VoiceConnectionCard({
Disconnect
</Button>
) : (
<Button size="sm" onClick={onConnect} disabled={!selectedGuild || !selectedChannel || connecting}>
<Button
size="sm"
onClick={onConnect}
disabled={!selectedGuild || !selectedChannel || connecting}
>
{connecting ? "Connecting..." : "Connect"}
</Button>
)}
@@ -1,11 +1,11 @@
"use client";
import { HeadphoneOff, Headphones } from "lucide-react";
import { useEffect, useMemo, useState } from "react";
import { GlassCard } from "@/components/glass/card";
import { Button } from "@/components/ui/button";
import { hashUserId } from "@/hooks";
import type { ActiveSpeaker } from "@/lib/types";
import { Headphones, HeadphoneOff } from "lucide-react";
interface ListenControlProps {
connected: boolean;
@@ -103,7 +103,9 @@ export function ListenControl({
{active ? "Listening" : "Listen"}
</Button>
<div className="flex-1 flex items-center gap-2">
<span className="text-[10px] text-text-secondary/60 font-mono">Vol</span>
<span className="text-[10px] text-text-secondary/60 font-mono">
Vol
</span>
<input
type="range"
min={0}
@@ -1,8 +1,8 @@
"use client";
import { Mic, MicOff } from "lucide-react";
import { GlassCard } from "@/components/glass/card";
import { Button } from "@/components/ui/button";
import { Mic, MicOff } from "lucide-react";
interface MicControlProps {
connected: boolean;
@@ -29,11 +29,17 @@ export function MicControl({
disabled={!connected}
className="h-9"
>
{active ? <Mic className="size-4 mr-1" /> : <MicOff className="size-4 mr-1" />}
{active ? (
<Mic className="size-4 mr-1" />
) : (
<MicOff className="size-4 mr-1" />
)}
{active ? "Live" : "Muted"}
</Button>
<div className="flex-1 flex items-center gap-2">
<span className="text-[10px] text-text-secondary/60 font-mono">Vol</span>
<span className="text-[10px] text-text-secondary/60 font-mono">
Vol
</span>
<input
type="range"
min={0}
@@ -42,7 +48,9 @@ export function MicControl({
onChange={(e) => onVolumeChange(Number(e.target.value))}
className="flex-1 h-1 appearance-none bg-glass-border rounded-full accent-primary [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:size-3 [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-primary [&::-webkit-slider-thumb]:shadow-[0_0_8px] [&::-webkit-slider-thumb]:shadow-primary/60"
/>
<span className="text-[10px] font-mono text-text-secondary w-8 text-right">{volume}%</span>
<span className="text-[10px] font-mono text-text-secondary w-8 text-right">
{volume}%
</span>
</div>
</div>
</GlassCard>
@@ -46,7 +46,9 @@ export function SpeakerWaveform({ speakers }: SpeakerWaveformProps) {
if (speakers.length === 0) {
return (
<GlassPanel dense>
<span className="text-xs text-text-secondary/40">No speakers detected</span>
<span className="text-xs text-text-secondary/40">
No speakers detected
</span>
</GlassPanel>
);
}
@@ -57,7 +59,11 @@ export function SpeakerWaveform({ speakers }: SpeakerWaveformProps) {
{speakers.map((s) => (
<div key={s.userId} className="flex items-center gap-2 text-xs">
<span
className={s.speaking ? "text-primary font-medium" : "text-text-secondary/60"}
className={
s.speaking
? "text-primary font-medium"
: "text-text-secondary/60"
}
>
{s.username}
</span>
+2 -3
View File
@@ -24,9 +24,8 @@ function useMediaAction<TArgs>(fn: (args: TArgs) => Promise<MediaState>) {
}
export function useMediaQueue() {
return useMediaAction(
(input: { url: string; mode?: "music" | "screen" }) =>
mediaApi.queue(input.url, input.mode ?? "music"),
return useMediaAction((input: { url: string; mode?: "music" | "screen" }) =>
mediaApi.queue(input.url, input.mode ?? "music"),
);
}
+6 -1
View File
@@ -138,7 +138,12 @@ export function useMessageDetail(id: string | null) {
if (!cid) return [];
// messageId filter: attachment list must show only this message's
// images, not the latest images from everyone in the channel.
const res = await messagesApi.getAttachments(cid, 10, undefined, id ?? "");
const res = await messagesApi.getAttachments(
cid,
10,
undefined,
id ?? "",
);
return res.data;
},
);
@@ -66,7 +66,12 @@ export class PcmPlayer {
if (!this.started || samples.length === 0) return;
let ring = this.rings.get(userIdHash);
if (!ring) {
ring = { data: new Float32Array(RING_LEN), write: 0, readPos: 0, lastActive: Date.now() };
ring = {
data: new Float32Array(RING_LEN),
write: 0,
readPos: 0,
lastActive: Date.now(),
};
this.rings.set(userIdHash, ring);
}
ring.lastActive = Date.now();
@@ -9,9 +9,9 @@ import {
useRef,
useState,
} from "react";
import { useWebSocket } from "@/lib/ws/context";
import { mediaApi } from "@/lib/api";
import type { MediaState, MediaItem } from "@/lib/types";
import type { MediaItem, MediaState } from "@/lib/types";
import { useWebSocket } from "@/lib/ws/context";
interface MediaPlayerContextValue {
/** Current play state */
@@ -52,11 +52,14 @@ export function MediaPlayerProvider({ children }: { children: ReactNode }) {
useEffect(() => {
if (fetched.current) return;
fetched.current = true;
mediaApi.getStatus().then((data) => {
if (data) setState(data as MediaState);
}).catch(() => {
// API not yet available
});
mediaApi
.getStatus()
.then((data) => {
if (data) setState(data as MediaState);
})
.catch(() => {
// API not yet available
});
}, []);
// Subscribe to live media_state events via WS
@@ -69,37 +72,52 @@ export function MediaPlayerProvider({ children }: { children: ReactNode }) {
const skip = useCallback(() => {
setPending(true);
mediaApi.skip().then((data) => {
if (data) setState(data as MediaState);
}).catch(() => {
// ignore
}).finally(() => setPending(false));
mediaApi
.skip()
.then((data) => {
if (data) setState(data as MediaState);
})
.catch(() => {
// ignore
})
.finally(() => setPending(false));
}, []);
const stop = useCallback(() => {
setPending(true);
mediaApi.stop().then((data) => {
if (data) setState(data as MediaState);
}).catch(() => {
// ignore
}).finally(() => setPending(false));
mediaApi
.stop()
.then((data) => {
if (data) setState(data as MediaState);
})
.catch(() => {
// ignore
})
.finally(() => setPending(false));
}, []);
const setVolume = useCallback((vol: number) => {
mediaApi.volume(vol).then((data) => {
if (data) setState(data as MediaState);
}).catch(() => {
// ignore
});
mediaApi
.volume(vol)
.then((data) => {
if (data) setState(data as MediaState);
})
.catch(() => {
// ignore
});
}, []);
const queueUrl = useCallback((url: string) => {
setPending(true);
mediaApi.queue(url, "music").then((data) => {
if (data) setState(data as MediaState);
}).catch(() => {
// ignore
}).finally(() => setPending(false));
mediaApi
.queue(url, "music")
.then((data) => {
if (data) setState(data as MediaState);
})
.catch(() => {
// ignore
})
.finally(() => setPending(false));
}, []);
return (