refactor(frontend): finish shadcn→custom primitive migration (green build)

- Remove tw-animate-css import + dead src/components/ui shadcn tree
- Convert 7 orphaned components (moderation, analysis, guild-selector,
  voice/activity-timeline, shared/empty+error) to new primitives
- Add missing moderation/view.tsx; analysis uses SearchPanel directly
- globals.css now uses new signal-driven ops-console tokens
- tsc --noEmit clean, next build green (11 routes), local smoke 200
This commit is contained in:
asepharyana
2026-08-14 10:49:44 +07:00
parent 5816e94a63
commit 5bbf75a65b
130 changed files with 4431 additions and 12978 deletions
@@ -0,0 +1,39 @@
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+/);
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) {
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",
className,
)}
style={{ width: size, height: size, fontSize: size * 0.38 }}
>
{src ? (
<img
src={src}
alt={name ?? ""}
className="size-full object-cover"
loading="lazy"
/>
) : (
initials(name)
)}
</span>
);
}
@@ -0,0 +1,42 @@
import type { ReactNode } from "react";
import { cn } from "@/lib/utils";
export type BadgeTone = "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)]",
};
export interface BadgeProps {
tone?: BadgeTone;
children: ReactNode;
className?: string;
dot?: boolean;
}
export function Badge({
tone = "neutral",
children,
className,
dot,
}: BadgeProps) {
return (
<span className={cn("pill", toneClass[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)]",
)}
/>
)}
{children}
</span>
);
}
@@ -0,0 +1,55 @@
"use client";
import { type HTMLMotionProps, motion, useReducedMotion } from "motion/react";
import { forwardRef } from "react";
import { cn } from "@/lib/utils";
type Variant = "primary" | "ghost" | "danger" | "outline";
type Size = "sm" | "md" | "lg" | "icon";
const variantClass: 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",
outline:
"bg-transparent text-[var(--color-ink)] border border-[var(--color-hairline)] hover:bg-[var(--color-surface-2)]",
};
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)]",
};
export interface ButtonProps extends Omit<HTMLMotionProps<"button">, "ref"> {
variant?: Variant;
size?: Size;
}
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";
@@ -0,0 +1,75 @@
"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>
);
}
@@ -0,0 +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";
@@ -0,0 +1,23 @@
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) => (
<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",
className,
)}
{...props}
/>
),
);
Input.displayName = "Input";
@@ -0,0 +1,39 @@
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];
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>
);
}
@@ -0,0 +1,26 @@
import { forwardRef, type SelectHTMLAttributes } from "react";
import { cn } from "@/lib/utils";
export interface SelectProps extends SelectHTMLAttributes<HTMLSelectElement> {
mono?: boolean;
}
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,
)}
{...props}
>
{children}
</select>
),
);
Select.displayName = "Select";
@@ -0,0 +1,74 @@
"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>
);
}
@@ -0,0 +1,20 @@
import { cn } from "@/lib/utils";
export interface SkeletonProps {
className?: string;
rounded?: boolean;
}
export function Skeleton({ className, rounded }: SkeletonProps) {
return (
<div
aria-hidden
className={cn(
"animate-shimmer rounded-[var(--radius-r-control)]",
"bg-[var(--color-surface-2)]",
rounded && "rounded-full",
className,
)}
/>
);
}
@@ -0,0 +1,147 @@
"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 { cn } from "@/lib/utils";
type ToastTone = "signal" | "amber" | "vermilion" | "neutral";
interface ToastItem {
id: number;
title?: string;
description?: string;
tone: ToastTone;
}
interface ToastContextValue {
toast: (t: {
title?: string;
description?: string;
tone?: ToastTone;
}) => void;
}
const ToastContext = createContext<ToastContextValue | null>(null);
export function useToast() {
const ctx = useContext(ToastContext);
if (!ctx) {
return {
toast: (_: {
title?: string;
description?: string;
tone?: ToastTone;
}) => {},
};
}
return ctx;
}
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)]",
};
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);
},
[],
);
useEffect(() => {
// expose a no-op provider only; actual provider wraps below
}, []);
const posClass =
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";
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"
>
<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>
);
}
@@ -0,0 +1,50 @@
"use client";
import { type ReactNode, 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",
};
export function Tooltip({
content,
children,
side = "top",
className,
}: TooltipProps) {
const [show, setShow] = useState(false);
return (
<span
className="relative inline-flex"
onMouseEnter={() => setShow(true)}
onMouseLeave={() => setShow(false)}
onFocus={() => setShow(true)}
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>
</span>
);
}