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:
@@ -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>
|
||||
|
||||
@@ -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"),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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 (
|
||||
|
||||
Reference in New Issue
Block a user