refactor(frontend): finish design-system migration — chatbot, a11y, lint
- Rewrite chatbot container + panel to new surface/signal/ink tokens (was still on dead glass/text-primary tokens -> wrong colors) - loading-skeleton: glass -> surface-2 - Fix a11y: SVG charts role=img+aria-label, audio aria-label, message-entry as real <button>, tooltip biome-ignore (intentional) - Type messages/page initialPage (noImplicitAny) - tsc clean, next build green, biome 0 errors
This commit is contained in:
@@ -2,7 +2,7 @@
|
||||
* Messages — Server Component.
|
||||
* Reads URL guild/channel/selected/tab on the server; seeds first page SSR.
|
||||
*/
|
||||
import { getMessages } from "@/lib/api/server";
|
||||
import { getMessages, type MessagePageResult } from "@/lib/api/server";
|
||||
import MessagesView from "./view";
|
||||
|
||||
export default async function MessagesPage({
|
||||
@@ -19,7 +19,7 @@ export default async function MessagesPage({
|
||||
? (sp.tab as "all" | "images" | "review")
|
||||
: "all";
|
||||
|
||||
let initialPage;
|
||||
let initialPage: MessagePageResult | undefined;
|
||||
if (guild) {
|
||||
initialPage = await getMessages(guild, channel || undefined).catch(
|
||||
() => undefined,
|
||||
|
||||
@@ -149,7 +149,13 @@ function PreviewDialog({
|
||||
<div className="display text-lg text-[var(--color-signal)]">
|
||||
{recording.filename}
|
||||
</div>
|
||||
<audio controls src={recording.download_url ?? ""} className="w-full" />
|
||||
{/* biome-ignore lint/a11y/useMediaCaption: voice recordings are uncaptioned audio previews — no transcript available */}
|
||||
<audio
|
||||
controls
|
||||
src={recording.download_url ?? ""}
|
||||
aria-label={`Audio recording: ${recording.filename}`}
|
||||
className="w-full"
|
||||
/>
|
||||
<div className="mono text-xs text-[var(--color-ink-soft)]">
|
||||
{(recording.size_bytes / 1024).toFixed(0)} KB ·{" "}
|
||||
{recording.upload_status}
|
||||
|
||||
@@ -43,6 +43,8 @@ export function RadialGauge({
|
||||
height={size}
|
||||
viewBox={`0 0 ${size} ${size}`}
|
||||
className="-rotate-90"
|
||||
role="img"
|
||||
aria-label={`${Math.round(pct * 100)}% ${label ?? "gauge"}`}
|
||||
>
|
||||
<circle
|
||||
cx={size / 2}
|
||||
|
||||
@@ -21,7 +21,15 @@ export function Sparkline({
|
||||
}: SparklineProps) {
|
||||
const id = useId().replace(/:/g, "");
|
||||
if (data.length < 2)
|
||||
return <svg width={width} height={height} className={className} />;
|
||||
return (
|
||||
<svg
|
||||
width={width}
|
||||
height={height}
|
||||
className={className}
|
||||
role="img"
|
||||
aria-label="No data"
|
||||
/>
|
||||
);
|
||||
|
||||
const min = Math.min(...data);
|
||||
const max = Math.max(...data);
|
||||
@@ -46,6 +54,8 @@ export function Sparkline({
|
||||
viewBox={`0 0 ${width} ${height}`}
|
||||
className={className}
|
||||
preserveAspectRatio="none"
|
||||
role="img"
|
||||
aria-label="Trend sparkline"
|
||||
>
|
||||
<defs>
|
||||
<linearGradient id={`spark-${id}`} x1="0" y1="0" x2="0" y2="1">
|
||||
|
||||
@@ -52,15 +52,15 @@ export function ChatPanel({ inputRef: externalInputRef }: ChatPanelProps) {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
<div className="flex h-full flex-col">
|
||||
{/* Chat messages */}
|
||||
<div
|
||||
ref={listRef}
|
||||
className="flex-1 overflow-y-auto px-2 py-2 space-y-1.5"
|
||||
className="flex-1 space-y-1.5 overflow-y-auto px-2 py-2"
|
||||
>
|
||||
{messages.length === 0 ? (
|
||||
<div className="flex flex-col justify-center h-full gap-3 px-3 text-center">
|
||||
<p className="text-[11px] text-text-secondary/50">
|
||||
<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">
|
||||
@@ -70,9 +70,9 @@ export function ChatPanel({ inputRef: externalInputRef }: ChatPanelProps) {
|
||||
type="button"
|
||||
onClick={() => handleSuggestion(s)}
|
||||
disabled={isTyping}
|
||||
className="flex items-center gap-1 rounded-full border border-glass-border px-2.5 py-1 text-[10px] text-text-secondary/70 transition-colors hover:bg-glass-bg hover:text-text-primary disabled:opacity-40"
|
||||
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-primary/60" />
|
||||
<Sparkles className="size-2.5 text-[var(--color-signal)]" />
|
||||
{s}
|
||||
</button>
|
||||
))}
|
||||
@@ -85,15 +85,15 @@ export function ChatPanel({ inputRef: externalInputRef }: ChatPanelProps) {
|
||||
className={`flex flex-col ${msg.role === "user" ? "items-end" : "items-start"}`}
|
||||
>
|
||||
<div
|
||||
className={`text-[11px] px-2.5 py-1.5 rounded-xl max-w-[85%] leading-relaxed whitespace-pre-wrap break-words ${
|
||||
className={`max-w-[85%] break-words whitespace-pre-wrap rounded-xl px-2.5 py-1.5 text-[11px] leading-relaxed ${
|
||||
msg.role === "user"
|
||||
? "bg-primary/20 text-text-primary rounded-br-sm"
|
||||
: "glass text-text-secondary rounded-bl-sm"
|
||||
? "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-text-secondary/30">
|
||||
<span className="mt-0.5 px-1 text-[9px] text-[var(--color-ink-soft)]">
|
||||
{formatTime(msg.timestamp)}
|
||||
</span>
|
||||
</div>
|
||||
@@ -101,18 +101,18 @@ export function ChatPanel({ inputRef: externalInputRef }: ChatPanelProps) {
|
||||
)}
|
||||
{isTyping && (
|
||||
<div className="flex justify-start">
|
||||
<div className="glass rounded-xl rounded-bl-sm px-2.5 py-2">
|
||||
<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 rounded-full bg-text-secondary animate-bounce"
|
||||
className="size-1.5 animate-bounce rounded-full bg-[var(--color-ink-soft)]"
|
||||
style={{ animationDelay: "0ms" }}
|
||||
/>
|
||||
<span
|
||||
className="size-1.5 rounded-full bg-text-secondary animate-bounce"
|
||||
className="size-1.5 animate-bounce rounded-full bg-[var(--color-ink-soft)]"
|
||||
style={{ animationDelay: "150ms" }}
|
||||
/>
|
||||
<span
|
||||
className="size-1.5 rounded-full bg-text-secondary animate-bounce"
|
||||
className="size-1.5 animate-bounce rounded-full bg-[var(--color-ink-soft)]"
|
||||
style={{ animationDelay: "300ms" }}
|
||||
/>
|
||||
</span>
|
||||
@@ -124,13 +124,13 @@ export function ChatPanel({ inputRef: externalInputRef }: ChatPanelProps) {
|
||||
{/* Input bar */}
|
||||
<form
|
||||
onSubmit={handleSubmit}
|
||||
className="flex items-center gap-1.5 px-2 py-2 border-t border-glass-border shrink-0"
|
||||
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-text-primary placeholder-text-secondary/30 outline-none"
|
||||
className="flex-1 bg-transparent text-[11px] text-[var(--color-ink)] outline-none placeholder:text-[var(--color-ink-soft)]/50"
|
||||
disabled={isTyping}
|
||||
autoComplete="off"
|
||||
/>
|
||||
@@ -138,22 +138,22 @@ export function ChatPanel({ inputRef: externalInputRef }: ChatPanelProps) {
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void clearMessages()}
|
||||
className="size-6 flex items-center justify-center rounded hover:bg-glass-bg transition-colors disabled:opacity-40"
|
||||
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-text-secondary/50 hover:text-destructive" />
|
||||
<Eraser className="size-3 text-[var(--color-ink-soft)] hover:text-[var(--color-vermilion)]" />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="submit"
|
||||
className="size-7 flex items-center justify-center rounded-lg bg-primary/15 hover:bg-primary/25 transition-colors disabled:opacity-40"
|
||||
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 text-primary" />
|
||||
<Send className="size-3.5" />
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
@@ -47,50 +47,49 @@ export function ChatbotContainer() {
|
||||
onMouseUp={handleMouseUp}
|
||||
onMouseLeave={handleMouseUp}
|
||||
>
|
||||
{/* Main chatbot bubble — minimized FAB opens the full chat directly */}
|
||||
<div
|
||||
className={`glass-intense rounded-2xl overflow-hidden transition-all duration-200 ${
|
||||
minimized ? "w-14 h-14 cursor-pointer" : "w-[320px] h-[460px]"
|
||||
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="w-full h-full flex items-center justify-center"
|
||||
className="flex size-full items-center justify-center"
|
||||
onMouseDown={handleMouseDown}
|
||||
aria-label="Buka chatbot"
|
||||
title="Buka chatbot"
|
||||
>
|
||||
<Bot className="size-6 text-primary" />
|
||||
<Bot className="size-6 text-[var(--color-signal)]" />
|
||||
</button>
|
||||
) : (
|
||||
<div className="flex flex-col h-full">
|
||||
<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 items-center justify-between px-3 py-2 border-b border-glass-border cursor-grab active:cursor-grabbing shrink-0"
|
||||
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 text-text-secondary tracking-wide uppercase">
|
||||
<Bot className="size-3.5 text-primary" />
|
||||
<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="size-6 flex items-center justify-center rounded hover:bg-glass-bg transition-colors"
|
||||
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-text-secondary/60 hover:text-text-primary" />
|
||||
<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="flex-1 min-h-0">
|
||||
<div className="min-h-0 flex-1">
|
||||
<ChatPanel inputRef={inputRef} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -39,11 +39,12 @@ export function MessageEntry({
|
||||
const status = msg.ai_status ?? null;
|
||||
|
||||
return (
|
||||
<div
|
||||
<button
|
||||
type="button"
|
||||
data-selected={selected}
|
||||
onClick={onSelect}
|
||||
className={cn(
|
||||
"group relative mb-1.5 flex items-start gap-2.5 rounded-[var(--radius-r)] p-2.5 cursor-pointer",
|
||||
"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",
|
||||
)}
|
||||
@@ -81,6 +82,6 @@ export function MessageEntry({
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -25,6 +25,7 @@ export function Tooltip({
|
||||
}: TooltipProps) {
|
||||
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)}
|
||||
|
||||
@@ -21,7 +21,7 @@ export function LoadingSkeleton({
|
||||
<div
|
||||
key={i}
|
||||
className={cn(
|
||||
"glass rounded-[var(--radius-card)] overflow-hidden",
|
||||
"surface-2 overflow-hidden",
|
||||
height,
|
||||
width,
|
||||
className,
|
||||
|
||||
@@ -33,6 +33,7 @@ export function StaticFallback({
|
||||
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
|
||||
@@ -71,6 +72,7 @@ export function StaticFallback({
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user