First Commit
ci / go (push) Waiting to run
ci / go-db (agent) (push) Waiting to run
ci / go-db (config) (push) Waiting to run
ci / go-db (db) (push) Waiting to run
ci / go-db (evidence) (push) Waiting to run
ci / go-db (llmrec) (push) Waiting to run
ci / go-db (server) (push) Waiting to run
web / web (push) Waiting to run
docs / links (push) Canceled after 0s
detections / detections (push) Canceled after 0s

This commit is contained in:
dela
2026-10-09 08:38:16 +08:00
commit 0335d572de
756 changed files with 201663 additions and 0 deletions
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,144 @@
"use client";
import * as React from "react";
import { useTranslations } from "next-intl";
import { Button } from "@/components/ui/button";
import { api } from "@/lib/api";
import type { Activity, InterceptExecution } from "@/lib/types";
// Resolve one exact persisted call, independently of transcript pagination.
export function useApprovalFocus({ taskId, conversationId }: { taskId?: string; conversationId?: number }) {
const t = useTranslations("approvalFocus");
const [state, setState] = React.useState<{
id: number;
source?: InterceptExecution;
error?: string;
loading: boolean;
} | null>(null);
const [revision, setRevision] = React.useState(0);
// biome-ignore lint/correctness/useExhaustiveDependencies: revision is the explicit retry trigger.
React.useEffect(() => {
const q = new URLSearchParams(window.location.search);
const id = Number(q.get("approval"));
const matches = conversationId ? q.get("c") === String(conversationId) : q.get("id") === taskId;
if (!matches || !Number.isSafeInteger(id) || id <= 0) {
setState(null);
return;
}
let cancelled = false;
setState({ id, loading: true });
api
.interceptExecution(id, conversationId)
.then((source) => {
if (cancelled) return;
if (
conversationId
? source.conversation_id !== conversationId
: source.task_id !== taskId || source.conversation_id != null
) {
throw new Error(t("mismatch"));
}
setState({ id, source, loading: false });
})
.catch((e) => {
if (!cancelled) setState({ id, error: (e as Error).message || t("noSource"), loading: false });
});
return () => {
cancelled = true;
};
}, [taskId, conversationId, revision]);
const close = React.useCallback(() => {
const url = new URL(window.location.href);
url.searchParams.delete("approval");
window.history.replaceState(null, "", url);
setState(null);
}, []);
return { state, close, retry: () => setRevision((v) => v + 1) };
}
type HistoryPage = { items: Activity[]; hasMore: boolean };
// Fill the existing transcript continuously back to the call, preserving all
// intervening messages. Never splice an isolated call into a paginated history.
export function useApprovalHistory(
source: InterceptExecution | undefined,
loaded: boolean,
items: Activity[],
loadPage: (before: number) => Promise<HistoryPage>,
mergePage: (page: HistoryPage) => void,
) {
const t = useTranslations("approvalFocus");
const itemsRef = React.useRef(items);
itemsRef.current = items;
const [result, setResult] = React.useState<{ source: InterceptExecution; error?: string }>();
React.useEffect(() => {
if (!source || !loaded) return;
let cancelled = false;
void (async () => {
let current = itemsRef.current;
let before = current[0]?.seq ?? 0;
while (!current.some((a) => a.seq === source.seq && a.kind === "tool_use")) {
const page = await loadPage(before);
if (cancelled) return;
if (!page.items.length || (before > 0 && page.items[0].seq >= before)) {
throw new Error(t("notFoundDeleted"));
}
mergePage(page);
current = page.items;
before = current[0].seq;
if (!page.hasMore && !current.some((a) => a.seq === source.seq)) {
throw new Error(t("notFound"));
}
}
if (!cancelled) setResult({ source });
})().catch((error) => {
if (!cancelled) setResult({ source, error: (error as Error).message });
});
return () => {
cancelled = true;
};
}, [source, loaded, loadPage, mergePage, t]);
return {
ready: !!source && result?.source === source && !result.error,
error: result?.source === source ? result?.error : undefined,
};
}
export function ApprovalExecutionFocus({
focus,
history,
}: {
focus: ReturnType<typeof useApprovalFocus>;
history: ReturnType<typeof useApprovalHistory>;
}) {
const t = useTranslations("approvalFocus");
const { state } = focus;
if (!state) return null;
const error = state.error || history.error;
return (
<div
role="status"
className="flex shrink-0 flex-wrap items-center justify-between gap-2 border-b px-4 py-2 text-xs"
>
<span className={error ? "text-destructive" : "text-muted-foreground"}>
{error
? t("locateFail", { error })
: history.ready
? t("expanded", { id: state.id })
: t("loading", { id: state.id })}
</span>
<div className="flex gap-2">
{error ? (
<Button size="sm" variant="outline" onClick={focus.retry}>
{t("retry")}
</Button>
) : null}
<Button size="sm" variant="ghost" onClick={focus.close}>
{t("cancel")}
</Button>
</div>
</div>
);
}
+944
View File
@@ -0,0 +1,944 @@
"use client";
import * as React from "react";
import {
BotIcon,
CheckIcon,
ChevronDownIcon,
ChevronRightIcon,
ClipboardListIcon,
CopyIcon,
RefreshCwIcon,
ShieldAlertIcon,
XIcon,
} from "lucide-react";
import { useTranslations } from "next-intl";
import { toast } from "sonner";
import { TablePagination } from "@/components/table-pagination";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
import { Empty, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle } from "@/components/ui/empty";
import { Field, FieldGroup, FieldLabel } from "@/components/ui/field";
import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Skeleton } from "@/components/ui/skeleton";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { api } from "@/lib/api";
import type {
InterceptApprovalFilter,
InterceptApprovalRow,
InterceptDetail,
InterceptPending,
InterceptReviewInput,
} from "@/lib/types";
import { cn } from "@/lib/utils";
type Translator = ReturnType<typeof useTranslations>;
function fmtTime(value?: string) {
if (!value) return "—";
return new Date(value).toLocaleString("ko-KR", {
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
});
}
function source(row: InterceptApprovalRow) {
if (row.decision_source) return row.decision_source;
if (row.rule_id) return "rule";
return row.reason?.startsWith("[模型]") ? "model" : "unknown";
}
function originLabel(row: InterceptApprovalRow, t: Translator) {
if (row.task_id) return row.task_id;
if (row.conversation_id) return t("conversationRef", { id: row.conversation_id });
return "—";
}
function ApprovalOrigin({ row, detail = false }: { row: InterceptApprovalRow; detail?: boolean }) {
const t = useTranslations("approval");
const [locating, setLocating] = React.useState(false);
const label = detail && row.task_id ? t("taskRef", { id: row.task_id }) : originLabel(row, t);
const query = new URLSearchParams({ approval: String(row.id) });
let href: string | undefined;
if (row.conversation_id) {
query.set("c", String(row.conversation_id));
href = `/chat?${query}`;
} else if (row.task_id) {
query.set("id", row.task_id);
href = `/function/tasks/detail?${query}`;
}
return href ? (
<a
href={href}
className="text-primary underline-offset-4 hover:underline"
aria-label={t("locateSourceAria", { id: row.id, label })}
aria-busy={locating}
onClick={async (e) => {
e.stopPropagation();
if (e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return;
e.preventDefault();
if (locating) return;
setLocating(true);
try {
await api.interceptExecution(row.id, row.conversation_id ?? undefined);
window.location.assign(href);
} catch (error) {
toast.error((error as Error).message || t("locateFailed"));
setLocating(false);
}
}}
>
{label}
</a>
) : (
<span>{label}</span>
);
}
function StatusBadge({ status }: { status: string }) {
const t = useTranslations("approval");
let variant: "default" | "destructive" | "secondary" | "outline" = "outline";
if (status === "allowed") variant = "default";
if (status === "denied") variant = "destructive";
if (status === "pending") variant = "secondary";
return <Badge variant={variant}>{t.has(`status.${status}`) ? t(`status.${status}`) : status}</Badge>;
}
function MatchCell({ row, showReason = true }: { row: InterceptApprovalRow; showReason?: boolean }) {
const t = useTranslations("approval");
const reason = row.reason?.replace(/^\[模型\]\s*/, "");
return (
<div className="flex min-w-0 flex-col gap-1">
{source(row) === "model" ? (
<Badge variant="outline">
<BotIcon />
{t("modelDecision")}
</Badge>
) : (
<span className="truncate">{row.rule_name || t("ruleMissing")}</span>
)}
{showReason ? (
<p className="truncate text-muted-foreground text-xs" title={reason}>
{reason || t("noReason")}
</p>
) : null}
</div>
);
}
function CodeBlock({ label, text, truncated = false }: { label: string; text: string; truncated?: boolean }) {
const t = useTranslations("approval");
async function copy() {
try {
await navigator.clipboard.writeText(text);
toast.success(t("copied"));
} catch {
toast.error(t("copyFailed"));
}
}
return (
<section className="flex min-w-0 flex-col gap-2" aria-label={label}>
<div className="flex items-center justify-between gap-2">
<h3 className="font-medium text-muted-foreground text-xs">{label}</h3>
{text ? (
<Button variant="ghost" size="icon-xs" aria-label={t("copyAria", { label })} onClick={() => void copy()}>
<CopyIcon />
</Button>
) : null}
</div>
<pre className="max-h-80 min-w-0 overflow-auto whitespace-pre-wrap break-words rounded-lg bg-muted/60 p-3 font-mono text-xs leading-6 [overflow-wrap:anywhere]">
{text || t("notRecorded")}
</pre>
{truncated ? <p className="text-muted-foreground text-xs">{t("truncatedNote")}</p> : null}
</section>
);
}
function ModelReviewContext({ input }: { input: InterceptReviewInput }) {
const t = useTranslations("approval");
return (
<section className="flex min-w-0 flex-col gap-4" aria-label={t("review.context")}>
<div className="flex flex-col gap-2">
<h3 className="font-medium text-sm">{t("review.context")}</h3>
<p className="text-muted-foreground text-xs">
{t("review.snapshotNote")}
{input.version < 4 ? t("review.legacyInput") : null}
</p>
</div>
<CodeBlock
label={t("review.currentCall")}
text={JSON.stringify({ tool_name: input.tool_name, arguments: input.arguments }, null, 2)}
/>
{input.version >= 2 ? (
input.background ? (
<div className="flex min-w-0 flex-col gap-2">
<CodeBlock
label={input.background.source === "user_message" ? t("review.bgUser") : t("review.bgWorkerLegacy")}
text={input.background.text}
truncated={input.background.truncated}
/>
<p className="text-muted-foreground text-xs">
{input.background.source === "user_message" ? t("review.bgUserNote") : t("review.bgWorkerNote")}
</p>
</div>
) : (
<p className="text-muted-foreground text-xs">{t("review.noBg")}</p>
)
) : (
<>
{input.turn_input ? (
<CodeBlock
label={t("review.turnInputLegacy")}
text={input.turn_input}
truncated={input.background_truncated}
/>
) : null}
{input.task ? (
<>
<CodeBlock
label={t("review.taskDescLegacy")}
text={input.task.description}
truncated={input.task.truncated}
/>
<CodeBlock label={t("review.taskGoalLegacy")} text={input.task.goal} truncated={input.task.truncated} />
<CodeBlock
label={t("review.taskConstraintsLegacy")}
text={JSON.stringify(input.task.constraints, null, 2)}
/>
</>
) : null}
{input.worker_intent ? (
<CodeBlock
label={t("review.workerIntentLegacy")}
text={input.worker_intent}
truncated={input.background_truncated}
/>
) : null}
</>
)}
{input.working_directory ? <CodeBlock label={t("review.workingDir")} text={input.working_directory} /> : null}
{input.version >= 3 ? (
<p className="text-muted-foreground text-xs">{t("review.noHistory")}</p>
) : (
<div className="flex min-w-0 flex-col gap-3">
<h4 className="font-medium text-muted-foreground text-xs">{t("review.historyTitleLegacy")}</h4>
{input.history?.length ? (
input.history.map((entry) => (
<div key={entry.tool_use_id} className="flex min-w-0 flex-col gap-2 rounded-lg border p-3">
<p className="break-words font-medium text-xs">
{entry.tool} · {entry.status === "succeeded" ? t("review.histSucceeded") : t("review.histFailed")}
</p>
<CodeBlock label={t("review.histArgs")} text={entry.arguments_preview} truncated={entry.truncated} />
<CodeBlock label={t("review.histResult")} text={entry.result} truncated={entry.truncated} />
</div>
))
) : (
<p className="text-muted-foreground text-xs">{t("review.noPairable")}</p>
)}
{input.history_truncated ? (
<p className="text-muted-foreground text-xs">{t("review.histTruncated")}</p>
) : null}
</div>
)}
<Collapsible>
<CollapsibleTrigger asChild>
<Button variant="outline" size="sm" className="self-start">
<ChevronDownIcon data-icon="inline-start" />
{t("review.viewFullJson")}
</Button>
</CollapsibleTrigger>
<CollapsibleContent className="pt-3">
<CodeBlock label={t("review.modelInput")} text={JSON.stringify(input, null, 2)} />
</CollapsibleContent>
</Collapsible>
</section>
);
}
type Decide = (id: number, decision: "allowed" | "denied") => Promise<void>;
function DecisionActions({ row, busy, decide }: { row: InterceptApprovalRow; busy: boolean; decide: Decide }) {
const t = useTranslations("approval");
if (row.status !== "pending") return null;
return (
<div className="flex flex-wrap gap-2">
<Button size="sm" disabled={busy} onClick={() => void decide(row.id, "allowed")}>
<CheckIcon data-icon="inline-start" />
{t("allow")}
</Button>
<Button size="sm" variant="destructive" disabled={busy} onClick={() => void decide(row.id, "denied")}>
<XIcon data-icon="inline-start" />
{t("deny")}
</Button>
</div>
);
}
export function ApprovalDetail({
row,
busy,
decide,
revision,
readOnly = false,
defaultExpanded = false,
onResolved,
}: {
row: InterceptApprovalRow;
busy: boolean;
decide: Decide;
revision: number;
readOnly?: boolean;
defaultExpanded?: boolean;
onResolved?: (status: "allowed" | "denied" | "timeout") => void;
}) {
const t = useTranslations("approval");
const [detail, setDetail] = React.useState<InterceptDetail | null>(null);
const [error, setError] = React.useState("");
const [retry, setRetry] = React.useState(0);
const [more, setMore] = React.useState(defaultExpanded);
// biome-ignore lint/correctness/useExhaustiveDependencies: Status, refresh and retry invalidate details without closing the panel.
React.useEffect(() => {
let cancelled = false;
let timer: ReturnType<typeof setTimeout> | undefined;
async function load() {
try {
const next = await api.interceptDetail(row.id);
if (cancelled) return;
setDetail(next);
setError("");
if (next.status !== "pending") onResolved?.(next.status);
if (next.status === "pending" || next.audit?.execution_status === "awaiting_result")
timer = setTimeout(() => void load(), 5000);
} catch (e) {
if (!cancelled) setError((e as Error).message || t("detailLoadFailed"));
}
}
void load();
return () => {
cancelled = true;
clearTimeout(timer);
};
}, [row.id, row.status, revision, retry, onResolved, t]);
// Row updates are authoritative until the lazy detail has caught up.
const current = detail?.status === row.status ? detail : row;
const audit = detail?.audit;
let execution = audit ? t(`execution.${audit.execution_status}`) : t("notRecorded");
if (row.status === "pending") execution = t("execution.not_started");
if (audit && audit.correlation !== "exact" && audit.effective_action === "allow")
execution = t("execution.notCorrelated");
const command = typeof row.tool_input?.command === "string" ? row.tool_input.command : undefined;
let initialLabel = source(row) === "model" ? t("modelInitial") : t("ruleInitial");
if (audit?.model_fallback) initialLabel = t("modelFallback");
const agentName = current.agent_name || current.conv_agent_key || t("agentFallback");
return (
<div className="flex min-w-0 flex-col gap-4 p-3 sm:p-5">
<div className="grid min-w-0 gap-5 rounded-xl border bg-muted/20 p-4 lg:grid-cols-2">
<div className="flex min-w-0 flex-col gap-3">
<CodeBlock
label={t("toolRequest", { agent: agentName })}
text={JSON.stringify(row.tool_input ?? {}, null, 2)}
/>
{command ? (
<Collapsible>
<CollapsibleTrigger asChild>
<Button variant="ghost" size="sm">
<ChevronDownIcon data-icon="inline-start" />
{t("viewCommand")}
</Button>
</CollapsibleTrigger>
<CollapsibleContent className="pt-2">
<CodeBlock label={t("commandContent")} text={command} />
</CollapsibleContent>
</Collapsible>
) : null}
<p className="text-muted-foreground text-xs">
{t("executionResult")}: <span className="text-foreground">{detail ? execution : t("loading")}</span>
</p>
</div>
<div className="flex min-w-0 flex-col gap-4 lg:border-l lg:pl-5">
<h3 className="font-medium text-muted-foreground text-xs">
{current.status === "pending" ? t("reviewStatus") : t("approvalVerdict")}
</h3>
<div className="flex flex-wrap items-center gap-2">
<StatusBadge status={current.status} />
<MatchCell row={current} showReason={false} />
</div>
<p className="whitespace-pre-wrap break-words text-sm leading-7 [overflow-wrap:anywhere]">
{current.reason?.replace(/^\[模型\]\s*/, "") || t("noApprovalReason")}
</p>
{audit?.decision_reason ? <p className="text-sm">{audit.decision_reason}</p> : null}
{audit?.effective_action ? (
<p className="text-sm">
{t("finalAction")}: {t(`action.${audit.effective_action}`)}
</p>
) : null}
<dl className="grid grid-cols-[auto_minmax(0,1fr)] gap-x-4 gap-y-2 text-xs">
<dt className="text-muted-foreground">{t("sourceLabel")}</dt>
<dd className="break-words">
<ApprovalOrigin row={current} detail />
</dd>
<dt className="text-muted-foreground">{t("requestedAt")}</dt>
<dd>{fmtTime(row.created_at)}</dd>
<dt className="text-muted-foreground">{t("decidedAt")}</dt>
<dd>{fmtTime(current.decided_at)}</dd>
{audit?.rule_name ? (
<>
<dt className="text-muted-foreground">{t("ruleSnapshot")}</dt>
<dd>{audit.rule_name}</dd>
</>
) : null}
{audit?.profile_id ? (
<>
<dt className="text-muted-foreground">{t("approvalModelProfile")}</dt>
<dd>#{audit.profile_id}</dd>
</>
) : null}
</dl>
{!readOnly ? <DecisionActions row={current} busy={busy} decide={decide} /> : null}
</div>
</div>
{error ? (
<Alert variant="destructive">
<AlertDescription>
<div className="flex flex-wrap items-center gap-2">
<span>{t("detailLoadFailedWith", { error })}</span>
<Button variant="outline" size="sm" onClick={() => setRetry((v) => v + 1)}>
{t("retryDetail")}
</Button>
</div>
</AlertDescription>
</Alert>
) : null}
{!detail && !error ? <Skeleton className="h-8 w-60" /> : null}
{detail && !audit ? (
<Alert>
<AlertDescription>{t("noDetailSnapshot")}</AlertDescription>
</Alert>
) : null}
{audit ? (
<Collapsible open={more} onOpenChange={setMore}>
<CollapsibleTrigger asChild>
<Button variant="ghost" size="sm">
<ChevronDownIcon data-icon="inline-start" className={cn(more && "rotate-180")} />
{more ? t("collapseContext") : t("viewContextAndResult")}
</Button>
</CollapsibleTrigger>
<CollapsibleContent className="pt-4">
<div className="flex min-w-0 flex-col gap-5">
{audit.model_input ? (
<ModelReviewContext input={audit.model_input} />
) : (
<Alert>
<AlertDescription>
{audit.model_input_digest ? t("digestOnlyNote") : t("noModelInputNote")}
</AlertDescription>
</Alert>
)}
{audit.user_message || audit.context?.length ? (
<Collapsible>
<CollapsibleTrigger asChild>
<Button variant="ghost" size="sm">
<ChevronDownIcon data-icon="inline-start" />
{audit.model_input && audit.model_input.version >= 3
? t("viewAuditFragmentNotSent")
: t("viewAuditFragment")}
</Button>
</CollapsibleTrigger>
<CollapsibleContent className="pt-3">
<div className="flex min-w-0 flex-col gap-3">
{audit.user_message ? (
<CodeBlock
label={t("auditCurrentTurn")}
text={audit.user_message}
truncated={audit.user_truncated}
/>
) : null}
<section className="flex min-w-0 flex-col gap-3">
<h3 className="font-medium text-muted-foreground text-xs">{t("visibleContext")}</h3>
<p className="text-muted-foreground text-xs">
{t("savedContextNote", { time: fmtTime(audit.captured_at) })}
</p>
{audit.context_truncated ? (
<p className="text-muted-foreground text-xs">{t("contextTruncated")}</p>
) : null}
{audit.context?.length ? (
audit.context.map((entry, index) => (
<CodeBlock
key={`${entry.kind}-${entry.tool_use_id || index}`}
label={`${t.has(`context.${entry.kind}`) ? t(`context.${entry.kind}`) : entry.kind}${entry.tool ? ` · ${entry.tool}` : ""}${entry.is_error ? ` · ${t("errorTag")}` : ""}`}
text={entry.text}
truncated={entry.truncated}
/>
))
) : (
<p className="text-muted-foreground text-sm">{t("noRelatedContext")}</p>
)}
</section>
</div>
</CollapsibleContent>
</Collapsible>
) : null}
<CodeBlock
label={`${initialLabel}: ${t.has(`action.${audit.initial_action}`) ? t(`action.${audit.initial_action}`) : audit.initial_action}`}
text={audit.initial_reason.replace(/^\[模型\]\s*/, "")}
/>
<CodeBlock
label={t("executionOutput")}
text={
audit.output ?? (audit.execution_status === "not_executed" ? t("toolNotExecuted") : t("noOutputYet"))
}
truncated={audit.output_truncated}
/>
{audit.correlation !== "exact" ? (
<Alert>
<AlertDescription>
{audit.correlation === "ambiguous" ? t("ambiguousCorrelation") : t("noUniqueToolId")}
</AlertDescription>
</Alert>
) : null}
<dl className="grid gap-2 text-muted-foreground text-xs [overflow-wrap:anywhere]">
<div>
{t("toolCallId")}: {audit.tool_use_id || t("notRecorded")}
</div>
<div>
{t("inputDigest")}: {audit.input_digest}
</div>
<div>
{t("configDigest")}: {audit.config_digest || t("notRecorded")}
</div>
{audit.model_input_digest ? (
<div>
{t("modelInputDigest")}: {audit.model_input_digest}
</div>
) : null}
{audit.execution_ended_at ? (
<div>
{t("resultRecordedAt")}: {fmtTime(audit.execution_ended_at)}
</div>
) : null}
</dl>
</div>
</CollapsibleContent>
</Collapsible>
) : null}
</div>
);
}
// Hidden cells do not occupy table columns. Keep the detail span in sync so
// expansion cannot create empty columns and leave a gap in the selected row.
const approvalBreakpoints = ["(min-width: 40rem)", "(min-width: 48rem)", "(min-width: 64rem)", "(min-width: 80rem)"];
function subscribeColumns(onChange: () => void) {
const queries = approvalBreakpoints.map((query) => window.matchMedia(query));
for (const query of queries) query.addEventListener("change", onChange);
return () => {
for (const query of queries) query.removeEventListener("change", onChange);
};
}
function visibleColumnCount() {
const matches = approvalBreakpoints.map((query) => window.matchMedia(query).matches);
return 3 + Number(matches[0]) + Number(matches[1]) + 2 * Number(matches[2]) + 2 * Number(matches[3]);
}
function serverColumnCount() {
return 9;
}
function ApprovalTable({
rows,
busy,
decide,
revision,
label,
}: {
rows: InterceptApprovalRow[];
busy: boolean;
decide: Decide;
revision: number;
label: string;
}) {
const t = useTranslations("approval");
const [expanded, setExpanded] = React.useState<Set<number>>(() => new Set());
const columns = React.useSyncExternalStore(subscribeColumns, visibleColumnCount, serverColumnCount);
const prefix = React.useId();
function toggle(id: number) {
setExpanded((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
}
return (
<Table className="table-fixed" aria-label={label}>
<TableHeader>
<TableRow>
<TableHead className="w-10">
<span className="sr-only">{t("table.expandDetail")}</span>
</TableHead>
<TableHead className="hidden w-14 sm:table-cell">#</TableHead>
<TableHead className="w-28">{t("table.tool")}</TableHead>
<TableHead className="hidden md:table-cell">{t("table.source")}</TableHead>
<TableHead className="hidden lg:table-cell">{t("table.matchRule")}</TableHead>
<TableHead className="hidden xl:table-cell">{t("table.params")}</TableHead>
<TableHead className="w-24">{t("table.status")}</TableHead>
<TableHead className="hidden w-36 lg:table-cell">{t("requestedAt")}</TableHead>
<TableHead className="hidden w-36 xl:table-cell">{t("decidedAt")}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{rows.map((row) => {
const open = expanded.has(row.id);
const panelID = `${prefix}-${row.id}`;
return (
<React.Fragment key={row.id}>
<TableRow
data-state={open ? "selected" : undefined}
className="cursor-pointer"
onClick={() => toggle(row.id)}
>
<TableCell>
<Button
variant="ghost"
size="icon-sm"
aria-label={t("row.toggleAria", { action: open ? t("collapse") : t("expand"), id: row.id })}
aria-expanded={open}
aria-controls={open ? panelID : undefined}
onClick={(e) => {
e.stopPropagation();
toggle(row.id);
}}
>
{open ? <ChevronDownIcon /> : <ChevronRightIcon />}
</Button>
</TableCell>
<TableCell className="hidden text-muted-foreground sm:table-cell">{row.id}</TableCell>
<TableCell>
<code className="block truncate text-xs">{row.tool_name}</code>
</TableCell>
<TableCell className="hidden md:table-cell">
<div className="flex flex-col gap-1">
<span className="truncate" title={originLabel(row, t)}>
<ApprovalOrigin row={row} />
</span>
<span className="truncate text-muted-foreground text-xs">
{row.agent_name || row.conv_agent_key}
</span>
</div>
</TableCell>
<TableCell className="hidden lg:table-cell">
<MatchCell row={row} />
</TableCell>
<TableCell className="hidden xl:table-cell">
<code className="block truncate text-muted-foreground text-xs">{JSON.stringify(row.tool_input)}</code>
</TableCell>
<TableCell>
<StatusBadge status={row.status} />
</TableCell>
<TableCell className="hidden text-muted-foreground text-xs lg:table-cell">
{fmtTime(row.created_at)}
</TableCell>
<TableCell className="hidden text-muted-foreground text-xs xl:table-cell">
{fmtTime(row.decided_at)}
</TableCell>
</TableRow>
{open ? (
<TableRow className="hover:bg-transparent has-aria-expanded:bg-transparent">
<TableCell colSpan={columns} className="whitespace-normal p-0">
<section id={panelID} aria-label={t("detailAria", { id: row.id })}>
<ApprovalDetail row={row} busy={busy} decide={decide} revision={revision} />
</section>
</TableCell>
</TableRow>
) : null}
</React.Fragment>
);
})}
</TableBody>
</Table>
);
}
export function ApprovalRecords({ taskId }: { taskId?: string }) {
const t = useTranslations("approval");
const [rows, setRows] = React.useState<InterceptApprovalRow[]>([]);
const [pendingRows, setPendingRows] = React.useState<InterceptPending[]>([]);
const [page, setPage] = React.useState(1);
const [pageSize, setPageSize] = React.useState(20);
const [total, setTotal] = React.useState(0);
const [filter, setFilter] = React.useState<InterceptApprovalFilter>({});
const [loading, setLoading] = React.useState(true);
const [refreshing, setRefreshing] = React.useState(false);
const [error, setError] = React.useState("");
const [pendingError, setPendingError] = React.useState("");
const [deciding, setDeciding] = React.useState(false);
const [revision, setRevision] = React.useState(0);
const request = React.useRef(0);
const decisionLock = React.useRef(false);
const filterID = React.useId();
const filtered = Boolean(filter.status || filter.decision_source);
// A task can stay mounted while the user switches between task details.
// Reset the cursor so the new scope always starts at its newest records.
// biome-ignore lint/correctness/useExhaustiveDependencies: taskId intentionally resets pagination when scope changes.
React.useEffect(() => {
setPage(1);
}, [taskId]);
const load = React.useCallback(
async (manual = false) => {
const id = ++request.current;
if (manual) setRefreshing(true);
try {
// The approval queue is independent of the current history page and its
// filter. Older requests must remain actionable even when newer decisions
// fill the page or a filter would hide them.
const [history, pending] = await Promise.allSettled([
taskId
? api.interceptTaskPage(taskId, page, pageSize, filter)
: api.interceptHistoryPage(page, pageSize, filter),
api.interceptPending(),
]);
if (id !== request.current) return;
if (history.status === "fulfilled") {
setRows(history.value.items);
setTotal(history.value.total);
setError("");
} else {
setError((history.reason as Error).message || t("loadError"));
}
if (pending.status === "fulfilled") {
setPendingRows(pending.value);
setPendingError("");
} else {
setPendingError((pending.reason as Error).message || t("loadError"));
}
if (manual) setRevision((v) => v + 1);
} catch (e) {
if (id === request.current) setError((e as Error).message || t("loadError"));
} finally {
if (id === request.current) {
setLoading(false);
setRefreshing(false);
}
}
},
[taskId, page, pageSize, filter, t],
);
const latestLoad = React.useRef(load);
latestLoad.current = load;
React.useEffect(() => {
void load();
const timer = setInterval(() => void load(), 5000);
return () => {
request.current++;
clearInterval(timer);
};
}, [load]);
const changePageSize = (next: number) => {
setPageSize(next);
setPage(1);
};
const changeFilter = (next: InterceptApprovalFilter) => {
// An in-flight response for the old filter must not repopulate the table.
request.current++;
setFilter(next);
setPage(1);
setRows([]);
setTotal(0);
setError("");
setLoading(true);
};
const decide: Decide = async (id, decision) => {
if (decisionLock.current) return;
decisionLock.current = true;
setDeciding(true);
try {
await api.interceptDecide(id, decision);
// Invalidate a list request started before this decision.
request.current++;
// Optimistically drop the decided item from the independent pending queue
// for instant feedback; the re-fetch below reconciles with server truth.
setRows((prev) =>
prev.map((row) => (row.id === id ? { ...row, status: decision, decided_at: new Date().toISOString() } : row)),
);
setPendingRows((prev) => prev.filter((row) => row.id !== id));
setRevision((v) => v + 1);
toast.success(decision === "allowed" ? t("allowedExecution") : t("deniedExecution"));
// Re-fetch counts and rows: a decided item may no longer match the filter.
await latestLoad.current(true);
} catch (e) {
toast.error((e as Error).message);
await latestLoad.current(true);
} finally {
decisionLock.current = false;
setDeciding(false);
}
};
const historyById = new Map(rows.map((row) => [row.id, row]));
const pending: InterceptApprovalRow[] = pendingRows
.filter((row) => !taskId || row.task_id === taskId)
.map((row) => ({
conv_title: "",
conv_agent_key: "",
rule_name: row.rule_id ? t("ruleRef", { id: row.rule_id }) : "",
...historyById.get(row.id),
...row,
}));
const title = taskId ? t("title.task") : t("title.history");
return (
<div className="flex min-w-0 flex-col gap-5">
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="flex items-center gap-2">
<ClipboardListIcon className="size-5 text-muted-foreground" />
<h1 className="font-semibold text-xl">{title}</h1>
{pending.length ? <Badge variant="secondary">{t("pendingBadge", { count: pending.length })}</Badge> : null}
</div>
<Button variant="outline" size="sm" onClick={() => void load(true)} disabled={loading || refreshing}>
<RefreshCwIcon data-icon="inline-start" className={cn(refreshing && "animate-spin")} />
{t("refresh")}
</Button>
</div>
<p className="text-muted-foreground text-sm">{t("intro")}</p>
<FieldGroup className="flex-row flex-wrap items-end gap-3" aria-label={t("filter.label")}>
<Field className="w-full sm:w-40">
<FieldLabel htmlFor={`${filterID}-status`}>{t("filter.status")}</FieldLabel>
<Select
value={filter.status ?? "all"}
onValueChange={(value) =>
changeFilter({
...filter,
status: value === "all" ? undefined : (value as InterceptApprovalFilter["status"]),
})
}
>
<SelectTrigger id={`${filterID}-status`}>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectItem value="all">{t("filter.statusAll")}</SelectItem>
<SelectItem value="denied">{t("status.denied")}</SelectItem>
<SelectItem value="pending">{t("status.pending")}</SelectItem>
<SelectItem value="allowed">{t("status.allowed")}</SelectItem>
<SelectItem value="timeout">{t("status.timeout")}</SelectItem>
</SelectGroup>
</SelectContent>
</Select>
</Field>
<Field className="w-full sm:w-40">
<FieldLabel htmlFor={`${filterID}-source`}>{t("filter.source")}</FieldLabel>
<Select
value={filter.decision_source ?? "all"}
onValueChange={(value) =>
changeFilter({
...filter,
decision_source: value === "all" ? undefined : (value as InterceptApprovalFilter["decision_source"]),
})
}
>
<SelectTrigger id={`${filterID}-source`}>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectItem value="all">{t("filter.sourceAll")}</SelectItem>
<SelectItem value="model">{t("sourceOption.model")}</SelectItem>
<SelectItem value="rule">{t("sourceOption.rule")}</SelectItem>
<SelectItem value="unknown">{t("sourceOption.unknown")}</SelectItem>
</SelectGroup>
</SelectContent>
</Select>
</Field>
{filtered ? (
<Button variant="ghost" size="sm" onClick={() => changeFilter({})}>
{t("filter.clear")}
</Button>
) : null}
</FieldGroup>
{error ? (
<Alert variant="destructive">
<AlertDescription>{t("recordLoadError", { error })}</AlertDescription>
</Alert>
) : null}
{pendingError ? (
<Alert variant="destructive">
<AlertDescription>{t("pendingLoadError", { error: pendingError })}</AlertDescription>
</Alert>
) : null}
{pending.length ? (
<section className="overflow-hidden rounded-xl border">
<div className="flex items-center gap-2 border-b bg-muted/40 px-4 py-3 font-medium text-sm">
<ShieldAlertIcon className="size-4" />
{t("pendingSection", { count: pending.length })}{" "}
<span className="text-muted-foreground text-xs">{t("pendingHint")}</span>
</div>
<ApprovalTable
rows={pending}
busy={deciding}
decide={decide}
revision={revision}
label={t("pendingTableLabel")}
/>
</section>
) : null}
<section className="overflow-hidden rounded-xl border">
<div className="border-b px-4 py-3 font-medium text-sm">
{filtered ? t("filteredResults") : t("allRecords")} ({total})
</div>
{loading ? (
<div className="flex flex-col gap-3 p-4">
<Skeleton className="h-10 w-full" />
<Skeleton className="h-10 w-full" />
<Skeleton className="h-10 w-full" />
</div>
) : null}
{!loading && !rows.length && !error ? (
<Empty>
<EmptyHeader>
<EmptyMedia variant="icon">
<ClipboardListIcon />
</EmptyMedia>
<EmptyTitle>{filtered ? t("emptyFiltered") : t("emptyTitle")}</EmptyTitle>
<EmptyDescription>{filtered ? t("emptyFilteredDesc") : t("emptyDesc")}</EmptyDescription>
</EmptyHeader>
</Empty>
) : null}
{rows.length ? (
<ApprovalTable
rows={rows}
busy={deciding}
decide={decide}
revision={revision}
label={t("historyTableLabel")}
/>
) : null}
{!loading && !error ? (
<TablePagination
page={page}
pageSize={pageSize}
total={total}
onPageChange={setPage}
onPageSizeChange={changePageSize}
pageSizeOptions={[10, 20, 50]}
/>
) : null}
</section>
</div>
);
}
+272
View File
@@ -0,0 +1,272 @@
"use client";
import * as React from "react";
import { SearchIcon } from "lucide-react";
import { useTranslations } from "next-intl";
import { Input } from "@/components/ui/input";
import { cn } from "@/lib/utils";
// ── DSL autocomplete ──────────────────────────────────────────────────────────
// Shared by the global asset view (/function/assets) and the per-task 测试资产
// search, so both search boxes behave and look identical. Field/operator/logic
// descriptions are translation keys (namespace "assetSearch") resolved at render.
const STR_OPS = [
{ op: "=", descKey: "op.fuzzy" },
{ op: "==", descKey: "op.exact" },
{ op: "!=", descKey: "op.exclude" },
];
const EXACT_OPS = [
{ op: "==", descKey: "op.exact" },
{ op: "!=", descKey: "op.exclude" },
];
const NUM_OPS = [
{ op: "==", descKey: "op.eq" },
{ op: "!=", descKey: "op.ne" },
{ op: ">", descKey: "op.gt" },
{ op: ">=", descKey: "op.gte" },
{ op: "<", descKey: "op.lt" },
{ op: "<=", descKey: "op.lte" },
];
const EQ_OPS = [{ op: "==", descKey: "op.eq" }];
const DSL_FIELDS: { name: string; descKey: string; ops: { op: string; descKey: string }[] }[] = [
{ name: "domain", descKey: "field.domain", ops: STR_OPS },
{ name: "ip", descKey: "field.ip", ops: STR_OPS },
{ name: "url", descKey: "field.url", ops: STR_OPS },
{ name: "root_domain", descKey: "field.root_domain", ops: STR_OPS },
{ name: "page_title", descKey: "field.page_title", ops: STR_OPS },
{ name: "icp", descKey: "field.icp", ops: STR_OPS },
{ name: "service_name", descKey: "field.service_name", ops: STR_OPS },
{ name: "app_name", descKey: "field.app_name", ops: STR_OPS },
{ name: "bundle_id", descKey: "field.bundle_id", ops: STR_OPS },
{ name: "category", descKey: "field.category", ops: STR_OPS },
{ name: "app_icp", descKey: "field.app_icp", ops: STR_OPS },
{ name: "method", descKey: "field.method", ops: EXACT_OPS },
{ name: "service_type", descKey: "field.service_type", ops: EXACT_OPS },
{ name: "record_type", descKey: "field.record_type", ops: EXACT_OPS },
{ name: "technology", descKey: "field.technology", ops: STR_OPS },
{ name: "port", descKey: "field.port", ops: NUM_OPS },
{ name: "status_code", descKey: "field.status_code", ops: NUM_OPS },
{ name: "company_id", descKey: "field.company_id", ops: EQ_OPS },
{ name: "task_id", descKey: "field.task_id", ops: EQ_OPS },
];
const LOGIC_OPS = [
{ label: "AND", descKey: "logic.and" },
{ label: "OR", descKey: "logic.or" },
];
interface DslSuggestion {
kind: "field" | "operator" | "logic";
label: string;
descKey: string;
replaceStart: number;
replaceEnd: number;
insertText: string;
}
function getDslSuggestions(text: string, cursor: number): DslSuggestion[] {
const before = text.slice(0, cursor);
// Current token: non-whitespace, non-paren run ending at cursor
const tokenMatch = before.match(/([^\s()]*$)/);
const currentToken = tokenMatch?.[1] ?? "";
const tokenStart = cursor - currentToken.length;
// Token already contains field+operator → typing a value, no suggestions
if (/^[a-z_]+(==|!=|>=|<=|=|>|<)/.test(currentToken)) return [];
// Complete known field name → suggest operators for that field
const exactField = DSL_FIELDS.find((f) => f.name === currentToken.toLowerCase());
if (exactField) {
return exactField.ops.map(({ op, descKey }) => ({
kind: "operator",
label: `${exactField.name}${op}`,
descKey,
replaceStart: tokenStart,
replaceEnd: cursor,
insertText: `${exactField.name}${op}`,
}));
}
// Everything before the current token (trimmed)
const beforeToken = before.slice(0, tokenStart).trimEnd();
const afterExpression = beforeToken.length > 0 && !/\b(AND|OR)\s*$/i.test(beforeToken) && !beforeToken.endsWith("(");
// Current token is a prefix of AND/OR and follows a complete expression
if (/^(a|an|and|o|or)$/i.test(currentToken) && afterExpression) {
return LOGIC_OPS.filter((l) => l.label.startsWith(currentToken.toUpperCase())).map(({ label, descKey }) => ({
kind: "logic",
label,
descKey,
replaceStart: tokenStart,
replaceEnd: cursor,
insertText: `${label} `,
}));
}
// No current token, after a complete expression → suggest AND/OR
if (!currentToken && afterExpression) {
return LOGIC_OPS.map(({ label, descKey }) => ({
kind: "logic",
label,
descKey,
replaceStart: cursor,
replaceEnd: cursor,
insertText: `${label} `,
}));
}
// Default: suggest fields filtered by prefix
const prefix = currentToken.toLowerCase();
return DSL_FIELDS.filter((f) => f.name.startsWith(prefix)).map((f) => ({
kind: "field",
label: f.name,
descKey: f.descKey,
replaceStart: tokenStart,
replaceEnd: cursor,
insertText: f.name,
}));
}
function applyDslSuggestion(text: string, s: DslSuggestion): { text: string; cursor: number } {
const newText = text.slice(0, s.replaceStart) + s.insertText + text.slice(s.replaceEnd);
return { text: newText, cursor: s.replaceStart + s.insertText.length };
}
const KIND_STYLE: Record<string, string> = {
field: "text-blue-500 dark:text-blue-400",
operator: "text-amber-500 dark:text-amber-400",
logic: "text-emerald-500 dark:text-emerald-400",
};
// AssetDslSearch is the shared DSL search box: a monospace input with a
// field/operator/logic autocomplete popover and a status line (result count /
// error / loading). Used by both the global asset view and the per-task view.
export function AssetDslSearch({
query,
onChange,
loading,
error,
count,
}: {
query: string;
onChange: (v: string) => void;
loading: boolean;
error: string;
count?: number;
}) {
const t = useTranslations("assetSearch");
const inputRef = React.useRef<HTMLInputElement>(null);
const [suggestions, setSuggestions] = React.useState<DslSuggestion[]>([]);
const [selIdx, setSelIdx] = React.useState(0);
const [open, setOpen] = React.useState(false);
const refresh = React.useCallback((val: string, pos: number) => {
const suggs = getDslSuggestions(val, pos);
setSuggestions(suggs);
setSelIdx(0);
setOpen(suggs.length > 0);
}, []);
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const val = e.target.value;
onChange(val);
refresh(val, e.target.selectionStart ?? val.length);
};
const apply = React.useCallback(
(s: DslSuggestion) => {
const cursor = inputRef.current?.selectionStart ?? query.length;
// use cursor for logic-kind (insert at cursor), replaceStart/End for others
const adjusted: DslSuggestion =
s.kind === "logic" && !query.slice(s.replaceStart, s.replaceEnd)
? { ...s, replaceStart: cursor, replaceEnd: cursor }
: s;
const { text: newText, cursor: newCursor } = applyDslSuggestion(query, adjusted);
onChange(newText);
requestAnimationFrame(() => {
if (!inputRef.current) return;
inputRef.current.setSelectionRange(newCursor, newCursor);
inputRef.current.focus();
refresh(newText, newCursor);
});
},
[query, onChange, refresh],
);
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (!open || suggestions.length === 0) return;
if (e.key === "ArrowDown") {
e.preventDefault();
setSelIdx((i) => Math.min(i + 1, suggestions.length - 1));
} else if (e.key === "ArrowUp") {
e.preventDefault();
setSelIdx((i) => Math.max(i - 1, 0));
} else if (e.key === "Tab" || e.key === "Enter") {
const s = suggestions[selIdx];
if (s) {
e.preventDefault();
apply(s);
}
} else if (e.key === "Escape") {
setOpen(false);
}
};
const cursorPos = () => inputRef.current?.selectionStart ?? query.length;
return (
<div className="flex flex-col gap-1">
<div className="relative max-w-lg">
<SearchIcon className="pointer-events-none absolute top-1/2 left-2.5 size-4 -translate-y-1/2 text-muted-foreground" />
<Input
ref={inputRef}
placeholder={t("placeholder")}
value={query}
onChange={handleChange}
onKeyDown={handleKeyDown}
onFocus={() => refresh(query, cursorPos())}
onClick={() => refresh(query, cursorPos())}
onBlur={() => setTimeout(() => setOpen(false), 120)}
className="h-8 pl-8 font-mono text-xs"
/>
{open && suggestions.length > 0 && (
<div className="absolute top-full left-0 z-50 mt-1 w-max min-w-full max-w-sm rounded-md border bg-popover py-1 shadow-md">
{suggestions.map((s, i) => (
<button
type="button"
key={i}
className={cn(
"flex w-full cursor-pointer items-center gap-3 px-3 py-1.5 text-left",
i === selIdx ? "bg-accent" : "hover:bg-accent/50",
)}
onMouseEnter={() => setSelIdx(i)}
onMouseDown={(e) => {
e.preventDefault();
apply(s);
}}
>
<span className={cn("shrink-0 font-mono text-xs font-semibold", KIND_STYLE[s.kind])}>{s.label}</span>
<span className="text-xs text-muted-foreground">{t(s.descKey)}</span>
</button>
))}
</div>
)}
</div>
{query.trim() && !open && (
<p className="pl-1 text-[11px] text-muted-foreground">
{loading ? (
t("loading")
) : error ? (
<span className="text-destructive">{error}</span>
) : (
t("found", { count: count ?? 0 })
)}
</p>
)}
</div>
);
}
@@ -0,0 +1,105 @@
"use client";
import * as React from "react";
import { PlusIcon, Trash2Icon } from "lucide-react";
import { useTranslations } from "next-intl";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { NativeSelect, NativeSelectOption } from "@/components/ui/native-select";
import type { AssetInterceptKind, AssetInterceptRuleInput } from "@/lib/types";
// 用 NativeSelect(原生 <select>)而非 shadcn Select:这个编辑器会用在 Sheet 抽屉内,
// shadcn Select 的下拉 portal 到 body、点击外部会触发抽屉的「点击外部关闭」误关;原生下拉无此问题。
// label 은 렌더 시점에 t(`kind.${value}`) 로 해석(ASSET_INTERCEPT_KIND_OPTIONS 외부 소비처 없음).
export const ASSET_INTERCEPT_KIND_OPTIONS: {
value: AssetInterceptKind;
placeholder: string;
}[] = [
{ value: "exact_domain", placeholder: "example.gov.cn" },
{ value: "exact_ip", placeholder: "203.0.113.10" },
{ value: "exact_url", placeholder: "https://example.com/login" },
{ value: "fuzzy_domain", placeholder: ".gov.cn" },
{ value: "fuzzy_ip", placeholder: "203.0.113." },
{ value: "fuzzy_url", placeholder: "/admin" },
{ value: "cidr", placeholder: "192.168.0.0/16" },
];
// AssetInterceptRulesEditor 是「拦截/允许规则」的受控多行编辑区(拦截block/允许allow +
// 类型 + 匹配内容 + 备注),不自带持久化——由父组件决定何时提交。
export function AssetInterceptRulesEditor({
value,
onChange,
}: {
value: AssetInterceptRuleInput[];
onChange: (v: AssetInterceptRuleInput[]) => void;
}) {
const t = useTranslations("assetInterceptRules");
function update(i: number, patch: Partial<AssetInterceptRuleInput>) {
onChange(value.map((r, idx) => (idx === i ? { ...r, ...patch } : r)));
}
function remove(i: number) {
onChange(value.filter((_, idx) => idx !== i));
}
function add() {
onChange([...value, { action: "block", kind: "fuzzy_domain", pattern: "", note: "", enabled: true }]);
}
return (
<div className="grid gap-2">
{value.map((r, i) => {
const ph = ASSET_INTERCEPT_KIND_OPTIONS.find((o) => o.value === r.kind)?.placeholder ?? "";
return (
// biome-ignore lint/suspicious/noArrayIndexKey: 行无稳定 id,按索引受控即可
<div key={i} className="flex items-center gap-2">
<NativeSelect
size="sm"
className="w-[84px] shrink-0"
value={r.action}
onChange={(e) => update(i, { action: e.target.value as "block" | "allow" })}
>
<NativeSelectOption value="block">{t("action.block")}</NativeSelectOption>
<NativeSelectOption value="allow">{t("action.allow")}</NativeSelectOption>
</NativeSelect>
<NativeSelect
size="sm"
className="w-[120px] shrink-0"
value={r.kind}
onChange={(e) => update(i, { kind: e.target.value as AssetInterceptKind })}
>
{ASSET_INTERCEPT_KIND_OPTIONS.map((o) => (
<NativeSelectOption key={o.value} value={o.value}>
{t(`kind.${o.value}`)}
</NativeSelectOption>
))}
</NativeSelect>
<Input
className="flex-1"
placeholder={ph}
value={r.pattern}
onChange={(e) => update(i, { pattern: e.target.value })}
/>
<Input
className="w-[120px] shrink-0"
placeholder={t("notePlaceholder")}
value={r.note}
onChange={(e) => update(i, { note: e.target.value })}
/>
<Button
type="button"
size="icon"
variant="ghost"
className="text-destructive hover:text-destructive size-8 shrink-0"
onClick={() => remove(i)}
>
<Trash2Icon className="size-4" />
</Button>
</div>
);
})}
<Button type="button" size="sm" variant="outline" className="w-fit" onClick={add}>
<PlusIcon className="size-4" /> {t("add")}
</Button>
</div>
);
}
@@ -0,0 +1,497 @@
import FullCalendar, { type CalendarOptions, type DayCellInfo } from '@fullcalendar/react'
import '@fullcalendar/react/skeleton.css'
import { cn } from '@/lib/utils'
const xxsTextClass = 'text-[0.6875rem]/[1.090909]'
const blockPointerResizerClass = 'absolute hidden group-hover:block'
const rowPointerResizerClass = `${blockPointerResizerClass} inset-y-0 w-2`
const columnPointerResizerClass = `${blockPointerResizerClass} inset-x-0 h-2`
const blockTouchResizerClass = 'absolute size-2 border border-(--fc-event-color) bg-background rounded-full'
const rowTouchResizerClass = `${blockTouchResizerClass} top-1/2 -mt-1`
const columnTouchResizerClass = `${blockTouchResizerClass} left-1/2 -ml-1`
const tallDayCellBottomClass = 'min-h-3'
const getShortDayCellBottomClass = (info: DayCellInfo) => cn(
!info.isNarrow && 'min-h-0.5'
)
const dayRowCommonClasses: CalendarOptions = {
/* Day Row > List-Item Event
----------------------------------------------------------------------------------------------- */
listItemEventClass: (info) => cn(
'mb-px p-px rounded-sm',
info.isNarrow ? 'mx-0.5' : 'mx-1',
info.isSelected
? 'bg-foreground/5'
: 'hover:bg-foreground/5',
),
listItemEventInnerClass: (info) => cn(
'flex flex-row items-center justify-between',
info.isNarrow
? `py-px ${xxsTextClass}`
: 'py-0.5 text-xs',
),
listItemEventTimeClass: (info) => cn(
info.isNarrow ? 'px-px' : 'px-0.5',
'order-1 whitespace-nowrap overflow-hidden shrink-1',
),
listItemEventTitleClass: (info) => cn(
info.isNarrow ? 'px-px' : 'px-0.5',
'font-medium whitespace-nowrap overflow-hidden shrink-100',
info.timeText && 'text-ellipsis',
),
/* Day Row > Row Event
----------------------------------------------------------------------------------------------- */
rowEventClass: (info) => cn(
info.isStart && (info.isNarrow ? 'ms-0.5' : 'ms-1'),
info.isEnd && (info.isNarrow ? 'me-0.5' : 'me-1'),
),
rowEventInnerClass: (info) => cn(info.isNarrow ? 'py-px' : 'py-0.5'),
/* Day Row > More-Link
----------------------------------------------------------------------------------------------- */
rowMoreLinkClass: (info) => cn(
'mb-px border rounded-sm',
info.isNarrow
? 'mx-0.5 border-primary hover:bg-foreground/5'
: 'self-start mx-1 border-transparent bg-foreground/5 hover:bg-foreground/10',
),
rowMoreLinkInnerClass: (info) => cn(
info.isNarrow
? `p-px ${xxsTextClass}`
: 'p-0.5 text-xs',
),
}
export type EventCalendarViewProps =
CalendarOptions &
Required<Pick<CalendarOptions, 'popoverCloseContent'>> // ensure callers define icons
export function EventCalendarViews({
height,
views: userViews,
...restOptions
}: EventCalendarViewProps) {
return (
<FullCalendar
height={height}
/* Abstract Event
----------------------------------------------------------------------------------------- */
eventShortHeight={50}
eventColor='var(--primary)'
eventContrastColor='var(--primary-foreground)'
eventClass={(info) => cn(
info.isSelected
? ['outline-3', info.isDragging && 'shadow-lg']
: 'focus-visible:outline-3',
'outline-ring/50',
)}
/* Background Event
----------------------------------------------------------------------------------------- */
backgroundEventColor='var(--chart-3)'
backgroundEventClass='not-print:bg-[color-mix(in_oklab,var(--fc-event-color)_10%,transparent)] print:border-1 print:border-(--fc-event-color)'
backgroundEventTitleClass={(info) => cn(
'opacity-50 italic',
(info.isNarrow || info.isShort)
? `p-1 ${xxsTextClass}`
: 'p-2 text-xs',
)}
/* List-Item Event
----------------------------------------------------------------------------------------- */
listItemEventTimeClass='text-muted-foreground'
/* Block Event
----------------------------------------------------------------------------------------- */
blockEventClass={(info) => cn(
'group relative border-transparent print:border-(--fc-event-color) bg-(--fc-event-color) print:bg-white hover:bg-[color-mix(in_oklab,var(--fc-event-color)_92%,var(--fc-event-contrast-color))]',
info.isInteractive && 'active:bg-[color-mix(in_oklab,var(--fc-event-color)_85%,var(--fc-event-contrast-color))]',
(info.isDragging && !info.isSelected) && 'opacity-75',
)}
blockEventInnerClass='text-(--fc-event-contrast-color) print:text-black'
blockEventTimeClass='whitespace-nowrap overflow-hidden shrink-1'
blockEventTitleClass='whitespace-nowrap overflow-hidden shrink-100'
/* Row Event
----------------------------------------------------------------------------------------- */
rowEventClass={(info) => cn(
'mb-px border-y',
info.isStart && 'rounded-s-sm border-s',
info.isEnd && 'rounded-e-sm border-e',
)}
rowEventBeforeClass={(info) => cn(
info.isStartResizable && [
info.isSelected ? rowTouchResizerClass : rowPointerResizerClass,
'-start-1',
]
)}
rowEventAfterClass={(info) => cn(
info.isEndResizable && [
info.isSelected ? rowTouchResizerClass : rowPointerResizerClass,
'-end-1',
]
)}
rowEventInnerClass={(info) => cn(
'flex flex-row items-center',
info.isNarrow ? xxsTextClass : 'text-xs',
)}
rowEventTimeClass={(info) => cn(
info.isNarrow ? 'ps-0.5' : 'ps-1'
)}
rowEventTitleClass={(info) => cn(
info.isNarrow ? 'px-0.5' : 'px-1',
'font-medium',
)}
/* Column Event
----------------------------------------------------------------------------------------- */
columnEventClass={(info) => cn(
'border-x ring ring-background',
info.isStart && ['border-t rounded-t-lg', info.isNarrow ? 'mt-px' : 'mt-0.5'],
info.isEnd && ['border-b rounded-b-lg', info.isNarrow ? 'mb-px' : 'mb-0.5'],
)}
columnEventBeforeClass={(info) => cn(
info.isStartResizable && [
info.isSelected ? columnTouchResizerClass : columnPointerResizerClass,
'-top-1',
]
)}
columnEventAfterClass={(info) => cn(
info.isEndResizable && [
info.isSelected ? columnTouchResizerClass : columnPointerResizerClass,
'-bottom-1',
]
)}
columnEventInnerClass={(info) => cn(
'flex',
info.isShort
? 'flex-row items-center gap-1 p-1'
: ['flex-col', info.isNarrow ? 'px-1 py-0.5' : 'px-2 py-1'],
(info.isNarrow || info.isShort) ? xxsTextClass : 'text-xs',
)}
columnEventTimeClass={(info) => cn(
!info.isShort && (info.isNarrow ? 'pt-0.5' : 'pt-1')
)}
columnEventTitleClass={(info) => cn(
!info.isShort && (info.isNarrow ? 'py-0.5' : 'py-1'),
'font-medium',
)}
/* More-Link
----------------------------------------------------------------------------------------- */
moreLinkClass="focus-visible:outline-3 outline-ring/50"
moreLinkInnerClass='whitespace-nowrap overflow-hidden'
columnMoreLinkClass="my-0.5 border border-transparent print:border-black rounded-md bg-[color-mix(in_oklab,var(--foreground)_10%,var(--background))] hover:bg-[color-mix(in_oklab,var(--foreground)_13%,var(--background))] print:bg-white ring ring-background"
columnMoreLinkInnerClass={(info) => cn(
info.isNarrow
? `p-0.5 ${xxsTextClass}`
: 'p-1 text-xs',
)}
/* Day Header
----------------------------------------------------------------------------------------- */
dayHeaderClass={(info) => cn(
'justify-center',
info.inPopover ? 'border-b bg-foreground/5' :
info.isMajor && 'border border-foreground/20',
)}
dayHeaderInnerClass={(info) => cn(
'flex flex-row items-center',
info.isNarrow ? 'text-xs' : 'text-sm',
info.inPopover ? [
'm-1.5 px-1 py-0.5 rounded-sm font-semibold',
info.hasNavLink && 'hover:bg-foreground/5',
] : !info.dayNumberText ? [
'mx-0.5 my-1.5 py-0.5 px-1.5 rounded-sm text-muted-foreground',
info.hasNavLink && 'hover:bg-foreground/5',
] : !info.isToday ? [
'mx-2 my-2.5 h-6 px-1.5 rounded-sm text-muted-foreground',
info.hasNavLink && 'hover:bg-foreground/5',
] : (
'group mx-2 my-2 h-7 outline-none'
)
)}
dayHeaderContent={(info) => (
(info.inPopover || !info.dayNumberText || !info.isToday) ? (
<>{info.text}</>
) : (
<>
{info.textParts.map((textPart, i) => (
<span
key={i}
className={cn(
'whitespace-pre',
(textPart.type === 'day' && info.isToday)
? [
'first:-ms-1 last:-me-1 size-7 rounded-full font-semibold flex flex-row items-center justify-center bg-chart-1/50',
info.hasNavLink && 'group-hover:bg-chart-1/60 group-focus-visible:outline-3 outline-ring/50',
]
: 'text-muted-foreground',
)}
>{textPart.value}</span>
))}
</>
)
)}
/* Day Cell
----------------------------------------------------------------------------------------- */
dayCellClass={(info) => cn(
'border',
info.isMajor && 'border-foreground/20',
)}
dayCellTopClass={(info) => cn(
info.isNarrow ? 'min-h-0.5' : 'min-h-1',
'flex flex-row justify-end',
)}
dayCellTopInnerClass={(info) => cn(
'flex flex-row items-center',
info.isNarrow
? `my-px h-5 ${xxsTextClass}`
: 'my-1 h-6 text-sm',
!info.isToday
? [
'rounded-s-sm whitespace-nowrap',
!info.isOther && 'font-semibold',
info.isNarrow ? 'px-1' : 'px-2',
!info.monthText && 'text-muted-foreground',
info.hasNavLink && 'hover:bg-foreground/5',
]
: [
'group outline-none',
info.isNarrow
? 'mx-px'
: 'mx-2',
]
)}
dayCellTopContent={(info) => (
!info.isToday ? (
<>{info.text}</>
) : (
<>
{info.textParts.map((textPart, i) => (
<span
key={i}
className={cn(
'whitespace-pre',
(textPart.type === 'day' && info.isToday)
? [
'rounded-full font-semibold flex flex-row items-center justify-center bg-chart-1/50',
info.hasNavLink && 'group-hover:bg-chart-1/60 group-focus-visible:outline-3 outline-ring/50',
info.isNarrow
? 'size-5'
: 'size-6 first:-ms-1 last:-me-1',
]
: (!info.monthText && 'text-muted-foreground'),
)}
>{textPart.value}</span>
))}
</>
)
)}
dayCellInnerClass={(info) => cn(info.inPopover && 'p-2')}
/* Popover
----------------------------------------------------------------------------------------- */
popoverClass='border rounded-md overflow-hidden shadow-lg m-1 bg-popover text-popover-foreground min-w-55'
popoverCloseClass="group absolute top-1.5 end-1.5 p-0.5 rounded-sm hover:bg-foreground/5 focus-visible:outline-3 outline-ring/50"
/* Lane
----------------------------------------------------------------------------------------- */
dayLaneClass={(info) => cn(
'border',
info.isMajor && 'border-foreground/20',
info.isDisabled && 'bg-foreground/3',
)}
dayLaneInnerClass={(info) => (
info.isStack
? 'm-1'
: info.isNarrow ? 'mx-px' : 'mx-0.5'
)}
slotLaneClass={(info) => cn(
'border',
info.isMinor && 'border-dotted',
)}
/* List Day
----------------------------------------------------------------------------------------- */
listDayClass={(info) => cn(
'flex flex-col',
!info.isLast && 'border-b',
)}
listDayHeaderClass="-mb-px border-b bg-[color-mix(in_oklab,var(--foreground)_3%,var(--background))] flex flex-row items-center justify-between"
listDayHeaderInnerClass={(info) => cn(
'm-1.5 px-1.5 py-0.5 rounded-sm text-sm',
!info.level && 'font-semibold',
(!info.level && info.isToday)
? [
'bg-chart-1/50',
info.hasNavLink && 'hover:bg-chart-1/60 focus-visible:outline-3 outline-ring/50',
]
: info.hasNavLink && 'hover:bg-foreground/5',
)}
listDayBodyClass='mt-px px-1.5 py-2 gap-2'
/* Single Month (in Multi-Month)
----------------------------------------------------------------------------------------- */
singleMonthClass={(info) => cn(
info.multiMonthColumns > 1 && 'm-3',
(info.multiMonthColumns === 1 && !info.isLast) && 'border-b',
)}
singleMonthHeaderClass={(info) => cn(
info.multiMonthColumns > 1 ? 'pb-2' : 'py-1 border-b bg-background',
'items-center',
)}
singleMonthHeaderInnerClass={(info) => cn(
'px-1.5 py-0.5 rounded-sm text-base font-semibold',
info.hasNavLink && 'hover:bg-foreground/5',
)}
/* Misc Table
----------------------------------------------------------------------------------------- */
tableBodyClass='bg-background'
fillerClass='border opacity-50'
dayNarrowWidth={100}
dayHeaderRowClass='border'
dayRowClass='border'
slotHeaderRowClass='border'
slotHeaderInnerClass='text-muted-foreground'
/* Misc Content
----------------------------------------------------------------------------------------- */
navLinkClass="focus-visible:outline-3 outline-ring/50"
inlineWeekNumberClass={(info) => cn(
'absolute start-0 whitespace-nowrap rounded-e-sm text-muted-foreground',
info.isNarrow
? `top-0.5 my-px p-0.5 ${xxsTextClass}`
: 'top-1 p-1 text-xs',
info.hasNavLink && 'hover:bg-foreground/5',
)}
highlightClass='bg-chart-1/20'
nonBusinessHoursClass='bg-foreground/3'
nowIndicatorLineClass='-m-px border-1 border-destructive'
nowIndicatorDotClass="-m-[6px] border-6 border-destructive size-0 rounded-full ring-2 ring-background"
/* View-Specific Options
----------------------------------------------------------------------------------------- */
views={{
...userViews,
dayGrid: {
...dayRowCommonClasses,
tableHeaderClass: 'bg-background',
dayHeaderAlign: (info) => info.inPopover ? 'start' : info.isNarrow ? 'center' : 'end',
dayHeaderDividerClass: 'border-b',
dayCellBottomClass: getShortDayCellBottomClass,
...userViews?.dayGrid,
},
multiMonth: {
...dayRowCommonClasses,
className: 'bg-foreground/3',
tableHeaderClass: (info) => cn(info.multiMonthColumns === 1 && 'bg-background'),
dayHeaderAlign: (info) => info.inPopover ? 'start' : info.isNarrow ? 'center' : 'end',
dayHeaderDividerClass: (info) => cn(info.multiMonthColumns === 1 && 'border-b'),
dayCellBottomClass: getShortDayCellBottomClass,
tableBodyClass: (info) => cn(info.multiMonthColumns > 1 && 'border rounded-sm overflow-hidden'),
...userViews?.multiMonth,
},
timeGrid: {
...dayRowCommonClasses,
tableHeaderClass: 'bg-background',
dayHeaderAlign: (info) => info.inPopover ? 'start' : 'center',
dayHeaderDividerClass: (info) => cn(
'border-b',
!info.options.allDaySlot && 'border-foreground/20 not-print:shadow-sm',
),
dayCellBottomClass: tallDayCellBottomClass,
/* TimeGrid > Week Number Header
------------------------------------------------------------------------------------- */
weekNumberHeaderClass: 'items-center justify-end',
weekNumberHeaderInnerClass: (info) => cn(
'mx-0.5 h-6 px-1.5 text-muted-foreground flex flex-row items-center rounded-sm',
info.isNarrow ? 'text-xs' : 'text-sm',
info.hasNavLink && 'hover:bg-foreground/5',
),
/* TimeGrid > All-Day Header
------------------------------------------------------------------------------------- */
allDayHeaderClass: 'items-center',
allDayHeaderInnerClass: (info) => cn(
'm-2 text-muted-foreground',
info.isNarrow ? xxsTextClass : 'text-xs',
),
allDayDividerClass: 'border-b border-foreground/20 not-print:shadow-sm',
/* TimeGrid > Slot Header
------------------------------------------------------------------------------------- */
slotHeaderClass: 'justify-end',
slotHeaderInnerClass: (info) => cn(
'relative m-2',
info.isNarrow
? `-top-3.5 ${xxsTextClass}`
: '-top-4 text-xs',
info.isFirst && 'hidden',
),
slotHeaderDividerClass: 'border-e',
...userViews?.timeGrid,
},
list: {
/* List-View > List-Item Event
------------------------------------------------------------------------------------- */
listItemEventClass: (info) => cn(
'group py-1 rounded-sm hover:bg-muted/50',
info.isInteractive && 'focus-visible:bg-muted/50 -outline-offset-3',
),
listItemEventBeforeClass: 'w-1.5 bg-(--fc-event-color) rounded-full',
listItemEventInnerClass: '[display:contents]',
listItemEventTimeClass: "-order-1 shrink-0 w-1/2 max-w-60 px-4 py-2 whitespace-nowrap overflow-hidden text-ellipsis text-sm",
listItemEventTitleClass: (info) => cn(
'grow min-w-0 px-4 py-2 whitespace-nowrap overflow-hidden text-sm',
info.event.url && 'group-hover:underline',
),
/* No-Events Screen
------------------------------------------------------------------------------------- */
noEventsClass: 'grow flex flex-col items-center justify-center',
noEventsInnerClass: 'py-15 text-muted-foreground',
...userViews?.list,
},
}}
{...restOptions}
/>
)
}
+62
View File
@@ -0,0 +1,62 @@
"use client";
import * as React from "react";
import { CheckIcon, CopyIcon } from "lucide-react";
import { useTranslations } from "next-intl";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { cn, copyText } from "@/lib/utils";
type CopyButtonProps = {
// 要复制的文本;为空则按钮禁用。
text: string | null | undefined;
// 复制成功后的 toast 文案,默认「已复制」。
successMessage?: string;
label?: React.ReactNode;
size?: React.ComponentProps<typeof Button>["size"];
variant?: React.ComponentProps<typeof Button>["variant"];
className?: string;
};
// CopyButton 统一的「复制到剪贴板」按钮:内置成功/失败反馈,并在 HTTP 非安全上下文
// 下自动降级(见 copyText)。
export function CopyButton({
text,
successMessage,
label,
size = "sm",
variant = "outline",
className,
}: CopyButtonProps) {
const t = useTranslations("copyButton");
const [copied, setCopied] = React.useState(false);
const timer = React.useRef<ReturnType<typeof setTimeout> | null>(null);
React.useEffect(() => {
return () => {
if (timer.current) clearTimeout(timer.current);
};
}, []);
async function handleCopy() {
if (!text) return;
const ok = await copyText(text);
if (ok) {
setCopied(true);
toast.success(successMessage ?? t("success"));
if (timer.current) clearTimeout(timer.current);
timer.current = setTimeout(() => setCopied(false), 1500);
} else {
toast.error(t("error"));
}
}
return (
<Button type="button" size={size} variant={variant} className={cn(className)} disabled={!text} onClick={handleCopy}>
{copied ? <CheckIcon /> : <CopyIcon />}
{label ?? t("label")}
</Button>
);
}
+60
View File
@@ -0,0 +1,60 @@
"use client";
import * as React from "react";
import { format, subDays } from "date-fns";
import type { DateRange } from "react-day-picker";
import { Button } from "@/components/ui/button";
import { Calendar } from "@/components/ui/calendar";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
interface DateRangePickerProps {
value?: DateRange;
onChange?: (value: DateRange | undefined) => void;
}
export function DateRangePicker({ value, onChange }: DateRangePickerProps) {
const [open, setOpen] = React.useState(false);
const [internalDateRange, setInternalDateRange] = React.useState<DateRange | undefined>(() => {
const to = new Date();
const from = subDays(to, 29);
return { from, to };
});
const dateRange = value ?? internalDateRange;
let dateRangeLabel = "Select date";
if (dateRange?.from) {
dateRangeLabel = format(dateRange.from, "d MMM yyyy");
}
if (dateRange?.from && dateRange.to) {
dateRangeLabel = `${format(dateRange.from, "d MMM yyyy")} - ${format(dateRange.to, "d MMM yyyy")}`;
}
const handleDateChange = (nextValue: DateRange | undefined) => {
if (!value) {
setInternalDateRange(nextValue);
}
onChange?.(nextValue);
};
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button variant="outline" id="date" className="font-normal">
{dateRangeLabel}
</Button>
</PopoverTrigger>
<PopoverContent className="w-auto overflow-hidden p-0" align="end">
<Calendar
mode="range"
defaultMonth={dateRange?.from}
selected={dateRange}
onSelect={handleDateChange}
numberOfMonths={2}
/>
</PopoverContent>
</Popover>
);
}
Binary file not shown.
@@ -0,0 +1,93 @@
"use client";
import * as React from "react";
import { RotateCcwIcon } from "lucide-react";
import { useTranslations } from "next-intl";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Field, FieldDescription, FieldGroup, FieldLabel } from "@/components/ui/field";
import { Spinner } from "@/components/ui/spinner";
import { Textarea } from "@/components/ui/textarea";
import { api } from "@/lib/api";
import type { FindingRetest } from "@/lib/types";
interface FindingRetestDialogProps {
findingId: string;
findingName?: string;
onClose: () => void;
onStarted?: (retest: FindingRetest) => void;
}
// 仅在打开时挂载,关闭后清空说明;列表与详情共用提交锁及错误处理,启动后留在当前页。
export function FindingRetestDialog({ findingId, findingName, onClose, onStarted }: FindingRetestDialogProps) {
const t = useTranslations("retest.dialog");
const notesId = React.useId();
const [notes, setNotes] = React.useState("");
const [submitting, setSubmitting] = React.useState(false);
const submitLock = React.useRef(false);
async function start() {
if (submitLock.current) return;
submitLock.current = true;
setSubmitting(true);
try {
const result = await api.startFindingRetest(findingId, notes.trim());
onStarted?.(result.retest);
onClose();
toast.success(result.created ? t("startedNew") : t("alreadyRunning"));
} catch (e) {
toast.error(t("startFailed", { message: (e as Error).message }));
} finally {
submitLock.current = false;
setSubmitting(false);
}
}
return (
<Dialog open onOpenChange={(open) => !open && !submitLock.current && onClose()}>
<DialogContent>
<DialogHeader>
<DialogTitle>{t("title", { id: findingId })}</DialogTitle>
<DialogDescription className="break-words">
{findingName ? <span className="mb-2 block">{findingName}</span> : null}
{t("desc")}
</DialogDescription>
</DialogHeader>
<FieldGroup>
<Field data-disabled={submitting}>
<FieldLabel htmlFor={notesId}>{t("notesLabel")}</FieldLabel>
<Textarea
id={notesId}
value={notes}
maxLength={4000}
rows={4}
disabled={submitting}
onChange={(e) => setNotes(e.target.value)}
placeholder={t("notesPlaceholder")}
/>
<FieldDescription>{t("notesDesc")}</FieldDescription>
</Field>
</FieldGroup>
<DialogFooter>
<Button variant="outline" disabled={submitting} onClick={onClose}>
{t("cancel")}
</Button>
<Button disabled={submitting} onClick={() => void start()}>
{submitting ? <Spinner data-icon="inline-start" /> : <RotateCcwIcon data-icon="inline-start" />}
{submitting ? t("submitting") : t("submit")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
+180
View File
@@ -0,0 +1,180 @@
"use client";
import * as React from "react";
import Link from "next/link";
import { RotateCcwIcon } from "lucide-react";
import { useTranslations } from "next-intl";
import { FindingRetestDialog } from "@/components/finding-retest-dialog";
import { Markdown } from "@/components/markdown";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Empty, EmptyDescription, EmptyHeader, EmptyTitle } from "@/components/ui/empty";
import { Skeleton } from "@/components/ui/skeleton";
import { Spinner } from "@/components/ui/spinner";
import { api } from "@/lib/api";
import type { FindingRetest } from "@/lib/types";
function active(r: FindingRetest) {
return r.status === "pending" || r.status === "running";
}
export function FindingRetestPanel({
findingId,
findingName,
readOnly = false,
onCompleted,
}: {
findingId: string;
findingName?: string;
readOnly?: boolean;
onCompleted?: () => void;
}) {
const t = useTranslations("retest.panel");
const [items, setItems] = React.useState<FindingRetest[] | null>(null);
const [error, setError] = React.useState("");
const [open, setOpen] = React.useState(false);
const requestSeq = React.useRef(0);
const previousItems = React.useRef<FindingRetest[]>([]);
const load = React.useCallback(async () => {
const seq = ++requestSeq.current;
try {
const rows = await api.findingRetests(findingId);
if (seq !== requestSeq.current) return;
const completed = previousItems.current.some(
(item) => active(item) && rows.some((row) => row.id === item.id && !active(row)),
);
previousItems.current = rows;
setItems(rows);
setError("");
if (completed) onCompleted?.();
} catch (e) {
if (seq === requestSeq.current) setError((e as Error).message);
}
}, [findingId, onCompleted]);
React.useEffect(() => {
void load();
return () => {
requestSeq.current++;
};
}, [load]);
React.useEffect(() => {
if (error || !items?.some(active)) return;
const timer = setTimeout(() => void load(), 3000);
return () => clearTimeout(timer);
}, [error, items, load]);
const running = items?.find(active);
return (
<Card>
<CardHeader className="flex flex-row flex-wrap items-start justify-between gap-3">
<div className="flex flex-col gap-1.5">
<CardTitle>{t("title")}</CardTitle>
<CardDescription>{t("desc")}</CardDescription>
</div>
{running?.conversation_id != null ? (
<Button asChild variant="outline" size="sm">
<Link href={`/chat?c=${running.conversation_id}`} title={t("viewRunning")}>
<Spinner data-icon="inline-start" aria-hidden="true" />
{t("status.running")}
</Link>
</Button>
) : null}
{!running && !readOnly ? (
<Button size="sm" onClick={() => setOpen(true)} disabled={items === null || !!error}>
<RotateCcwIcon data-icon="inline-start" />
{t("start")}
</Button>
) : null}
</CardHeader>
<CardContent className="flex flex-col gap-3">
{error ? (
<Alert variant="destructive">
<AlertDescription>
{t("loadError", { error })}
<Button variant="outline" size="sm" onClick={() => void load()}>
{t("retry")}
</Button>
</AlertDescription>
</Alert>
) : null}
{!error && items === null ? <Skeleton className="h-16 w-full" /> : null}
{!error && items?.length === 0 ? (
<Empty>
<EmptyHeader>
<EmptyTitle>{t("emptyTitle")}</EmptyTitle>
<EmptyDescription>{t("emptyDesc")}</EmptyDescription>
</EmptyHeader>
</Empty>
) : null}
{!error && items
? items.map((item) => (
<div key={item.id} className="flex min-w-0 flex-col gap-2 rounded-lg border p-3">
<div className="flex flex-wrap items-center gap-2">
<Badge
variant={item.status === "completed" && item.verdict === "reproduced" ? "destructive" : "secondary"}
>
{item.status === "completed" && item.verdict
? t(`verdict.${item.verdict}`)
: t(`status.${item.status}`)}
</Badge>
<span className="text-muted-foreground text-xs">
#{item.id} · {new Date(item.created_at).toLocaleString("ko-KR")}
</span>
{item.conversation_id != null ? (
<Button asChild variant="ghost" size="sm" className="ml-auto">
<Link href={`/chat?c=${item.conversation_id}`}>{t("viewSession")}</Link>
</Button>
) : (
<span className="text-muted-foreground text-xs">{t("sessionDeleted")}</span>
)}
</div>
{item.status === "completed" && item.summary ? (
<p className="whitespace-pre-wrap break-words text-sm">{item.summary}</p>
) : null}
{item.error ? (
<p className="whitespace-pre-wrap break-words text-destructive text-sm">{item.error}</p>
) : null}
{item.notes ? (
<p className="whitespace-pre-wrap break-words text-muted-foreground text-xs">
{t("notes", { notes: item.notes })}
</p>
) : null}
{item.status === "completed" && item.evidence ? (
<details className="min-w-0">
<summary className="cursor-pointer text-sm">{t("evidence")}</summary>
<div className="mt-3 overflow-x-auto">
<Markdown text={item.evidence} />
</div>
</details>
) : null}
</div>
))
: null}
</CardContent>
{open ? (
<FindingRetestDialog
findingId={findingId}
findingName={findingName}
onClose={() => setOpen(false)}
onStarted={(retest) => {
requestSeq.current++;
const rows = [retest, ...previousItems.current.filter((item) => item.id !== retest.id)];
previousItems.current = rows;
setItems(rows);
setError("");
void load();
}}
/>
) : null}
</Card>
);
}
@@ -0,0 +1,285 @@
"use client";
import * as React from "react";
import { ArrowDownIcon, ArrowUpIcon, PlusIcon } from "lucide-react";
import { useTranslations } from "next-intl";
import { toast } from "sonner";
import { TrafficEvidenceViewer } from "@/components/traffic-evidence-viewer";
import { TrafficPickerDialog } from "@/components/traffic-picker-dialog";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Empty, EmptyDescription, EmptyHeader, EmptyTitle } from "@/components/ui/empty";
import { Field, FieldGroup, FieldLabel } from "@/components/ui/field";
import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Skeleton } from "@/components/ui/skeleton";
import { Textarea } from "@/components/ui/textarea";
import { api } from "@/lib/api";
import type { FindingTraffic, FindingTrafficBinding, TrafficEvidenceRole } from "@/lib/types";
// Role values in display order; labels resolve via t(`traffic.role.${value}`) at render time.
const ROLE_VALUES: TrafficEvidenceRole[] = ["baseline", "proof", "verification", "supporting"];
export function FindingTrafficPanel({
findingId,
contextTask,
readOnly,
onChanged,
}: {
findingId: string;
contextTask?: string;
readOnly?: boolean;
onChanged: () => void;
}) {
const t = useTranslations("findings");
const [data, setData] = React.useState<FindingTraffic | null>(null);
const [error, setError] = React.useState("");
const [adding, setAdding] = React.useState(false);
const [preview, setPreview] = React.useState<string | null>(null);
const [editing, setEditing] = React.useState<FindingTrafficBinding | null>(null);
const [role, setRole] = React.useState<TrafficEvidenceRole>("supporting");
const [note, setNote] = React.useState("");
const [busy, setBusy] = React.useState(false);
const [reload, setReload] = React.useState(0);
// biome-ignore lint/correctness/useExhaustiveDependencies: retry token intentionally refreshes this request.
React.useEffect(() => {
let active = true;
setData(null);
setError("");
api
.findingTraffic(findingId, contextTask)
.then((value) => {
if (active) setData(value);
})
.catch((e: Error) => {
if (active) setError(e.message);
});
return () => {
active = false;
};
}, [findingId, contextTask, reload]);
async function mutate(action: () => Promise<FindingTraffic>) {
setBusy(true);
try {
setData(await action());
setEditing(null);
onChanged();
toast.success(t("traffic.updated"));
} catch (e) {
toast.error((e as Error).message);
setReload((n) => n + 1);
} finally {
setBusy(false);
}
}
function move(index: number, delta: number) {
if (!data) return;
const ids = data.bindings.map((b) => b.id);
[ids[index], ids[index + delta]] = [ids[index + delta], ids[index]];
void mutate(() => api.orderFindingTraffic(findingId, ids, data.version, contextTask));
}
return (
<>
<Card>
<CardHeader>
<div className="flex flex-wrap items-center justify-between gap-2">
<CardTitle>
{t("traffic.title")} {data ? `(${data.bindings.length})` : ""}
</CardTitle>
{!readOnly ? (
<Button variant="outline" size="sm" disabled={!data || busy} onClick={() => setAdding(true)}>
<PlusIcon data-icon="inline-start" />
{t("traffic.bind")}
</Button>
) : (
<Badge variant="outline">{t("traffic.inherited")}</Badge>
)}
</div>
<CardDescription>{t("traffic.description")}</CardDescription>
</CardHeader>
<CardContent className="flex flex-col gap-3">
{error ? (
<Alert variant="destructive">
<AlertDescription>
{error}
<Button variant="link" onClick={() => setReload((n) => n + 1)}>
{t("traffic.retry")}
</Button>
</AlertDescription>
</Alert>
) : null}
{!data ? (
<Skeleton className="h-28 w-full" />
) : data.bindings.length === 0 ? (
<Empty>
<EmptyHeader>
<EmptyTitle>{t("traffic.empty")}</EmptyTitle>
<EmptyDescription>{t("traffic.emptyHint")}</EmptyDescription>
</EmptyHeader>
</Empty>
) : (
data.bindings.map((b, index) => (
<div key={b.id} className="flex min-w-0 flex-col gap-2 rounded-md border p-3">
<div className="flex flex-wrap items-center gap-2">
<Badge variant="outline">
{index + 1}. {t(`traffic.role.${b.role}`)}
</Badge>
<Badge variant="secondary">
{b.snapshot.method} · {b.snapshot.status}
</Badge>
<span className="text-xs text-muted-foreground">{t("traffic.evidenceNo", { id: b.id })}</span>
</div>
<Button
variant="link"
className="h-auto justify-start p-0 text-left whitespace-normal"
onClick={() => setPreview(b.id)}
>
<span className="break-all font-mono text-xs">{b.snapshot.url}</span>
</Button>
{b.note ? <p className="text-sm whitespace-pre-wrap">{b.note}</p> : null}
<div className="flex flex-wrap items-center justify-between gap-2">
<span className="text-xs text-muted-foreground">
{new Date(b.snapshot.captured_at * 1000).toLocaleString("ko-KR")}
</span>
<div className="flex flex-wrap gap-1">
<Button variant="outline" size="sm" onClick={() => setPreview(b.id)}>
{t("traffic.viewRaw")}
</Button>
{!readOnly ? (
<>
<Button
variant="ghost"
size="sm"
disabled={busy}
onClick={() => {
setEditing(b);
setRole(b.role);
setNote(b.note);
}}
>
{t("traffic.editNote")}
</Button>
<Button
variant="ghost"
size="icon-sm"
aria-label={t("traffic.moveUp", { id: b.id })}
disabled={busy || index === 0}
onClick={() => move(index, -1)}
>
<ArrowUpIcon />
</Button>
<Button
variant="ghost"
size="icon-sm"
aria-label={t("traffic.moveDown", { id: b.id })}
disabled={busy || index === data.bindings.length - 1}
onClick={() => move(index, 1)}
>
<ArrowDownIcon />
</Button>
<Button
variant="ghost"
size="sm"
disabled={busy}
onClick={() =>
void mutate(() => api.removeFindingTraffic(findingId, b.id, data.version, contextTask))
}
>
{t("traffic.unbind")}
</Button>
</>
) : null}
</div>
</div>
</div>
))
)}
</CardContent>
</Card>
{adding && data ? (
<TrafficPickerDialog
findingId={findingId}
contextTask={contextTask}
bound={data.bindings}
onClose={() => setAdding(false)}
onBound={() => {
setReload((n) => n + 1);
onChanged();
}}
/>
) : null}
<TrafficEvidenceViewer
findingId={findingId}
bindingId={preview}
contextTask={contextTask}
onClose={() => setPreview(null)}
/>
<Dialog
open={editing !== null}
onOpenChange={(open) => {
if (!open && !busy) setEditing(null);
}}
>
<DialogContent>
<DialogHeader>
<DialogTitle>{t("traffic.editTitle")}</DialogTitle>
<DialogDescription>{t("traffic.editDescription")}</DialogDescription>
</DialogHeader>
<FieldGroup>
<Field>
<FieldLabel htmlFor="evidence-role">{t("traffic.roleLabel")}</FieldLabel>
<Select value={role} onValueChange={(v) => setRole(v as TrafficEvidenceRole)}>
<SelectTrigger id="evidence-role">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectGroup>
{ROLE_VALUES.map((value) => (
<SelectItem key={value} value={value}>
{t(`traffic.role.${value}`)}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
</Field>
<Field>
<FieldLabel htmlFor="evidence-note">{t("traffic.noteLabel")}</FieldLabel>
<Textarea id="evidence-note" value={note} onChange={(e) => setNote(e.target.value)} />
</Field>
</FieldGroup>
<DialogFooter>
<Button variant="outline" disabled={busy} onClick={() => setEditing(null)}>
{t("common.cancel")}
</Button>
<Button
disabled={busy || !editing || !data}
onClick={() => {
if (editing && data)
void mutate(() =>
api.editFindingTraffic(findingId, editing.id, data.version, { role, note }, contextTask),
);
}}
>
{t("traffic.saveNote")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
);
}
+218
View File
@@ -0,0 +1,218 @@
"use client";
import * as React from "react";
import { CheckIcon, CopyIcon, WrapTextIcon } from "lucide-react";
import { useTranslations } from "next-intl";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { cn, copyText } from "@/lib/utils";
function statusTone(status: number) {
if (status >= 500) return "text-red-500";
if (status >= 400) return "text-amber-500";
if (status >= 300) return "text-blue-500";
if (status >= 200) return "text-emerald-500";
return "text-muted-foreground";
}
function StartLine({ line }: { line: string }) {
const response = /^(HTTP\/\S+)(\s+)(\d{3})(.*)$/.exec(line);
if (response) {
return (
<>
<span className="text-muted-foreground">{response[1]}</span>
{response[2]}
<span className={statusTone(Number(response[3]))}>{response[3]}</span>
{response[4]}
</>
);
}
const request = /^([A-Z]+)(\s+)(\S+)(\s+)(HTTP\/\S+)$/.exec(line);
if (request) {
return (
<>
<span className="font-semibold text-primary">{request[1]}</span>
{request[2]}
<span className="text-chart-2">{request[3]}</span>
{request[4]}
<span className="text-muted-foreground">{request[5]}</span>
</>
);
}
return line;
}
function HeaderLine({ line }: { line: string }) {
const separator = line.indexOf(":");
if (separator <= 0) return line;
return (
<>
<span className="text-primary">{line.slice(0, separator)}</span>
<span className="text-muted-foreground">:</span>
<span className="text-chart-2">{line.slice(separator + 1)}</span>
</>
);
}
function JsonBody({ body }: { body: string }) {
const parts: React.ReactNode[] = [];
const tokens = /("(?:\\.|[^"\\])*")(\s*:)?|\b(true|false|null)\b|-?\d+(?:\.\d+)?(?:[eE][+-]?\d+)?/g;
let cursor = 0;
for (const match of body.matchAll(tokens)) {
const index = match.index ?? 0;
if (index > cursor) parts.push(body.slice(cursor, index));
let className = "text-chart-4";
if (match[1]) className = match[2] ? "text-primary" : "text-chart-2";
else if (match[3]) className = "text-chart-3";
parts.push(
<span key={`${index}-${match[0].length}`} className={className}>
{match[0]}
</span>,
);
cursor = index + match[0].length;
}
if (cursor < body.length) parts.push(body.slice(cursor));
return parts;
}
function MarkupBody({ body }: { body: string }) {
const parts: React.ReactNode[] = [];
const tags = /<\/?[A-Za-z][^>]*>|<!--[\s\S]*?-->/g;
let cursor = 0;
for (const match of body.matchAll(tags)) {
const index = match.index ?? 0;
if (index > cursor) parts.push(body.slice(cursor, index));
parts.push(
<span key={`${index}-${match[0].length}`} className="text-primary">
{match[0]}
</span>,
);
cursor = index + match[0].length;
}
if (cursor < body.length) parts.push(body.slice(cursor));
return parts;
}
type BodyFormat = "json" | "markup" | "plain";
function detectBodyFormat(body: string): BodyFormat {
const trimmed = body.trimStart();
if (trimmed.startsWith("{") || trimmed.startsWith("[")) return "json";
if (trimmed.startsWith("<")) return "markup";
return "plain";
}
function HighlightedBody({ body, format }: { body: string; format: BodyFormat }) {
if (format === "json") return <JsonBody body={body} />;
if (format === "markup") return <MarkupBody body={body} />;
return body;
}
export function HttpCodeBlock({ raw }: { raw: string }) {
const t = useTranslations("trafficEvidence.code");
const [wrapLines, setWrapLines] = React.useState(true);
const [copied, setCopied] = React.useState(false);
const value = raw || t("empty");
const lines = value.replaceAll("\r\n", "\n").split("\n");
const separator = lines.indexOf("");
const body = separator >= 0 ? lines.slice(separator + 1).join("\n") : "";
const bodyFormat = detectBodyFormat(body);
React.useEffect(() => {
if (!copied) return;
const timer = window.setTimeout(() => setCopied(false), 1500);
return () => window.clearTimeout(timer);
}, [copied]);
const copyPacket = async () => {
const ok = await copyText(value);
if (ok) {
setCopied(true);
return;
}
toast.error(t("copyFailed"));
};
const renderLine = (line: string, index: number) => {
if (index === 0) return <StartLine line={line} />;
if (separator < 0 || index < separator) return <HeaderLine line={line} />;
if (index === separator) return null;
return <HighlightedBody body={line} format={bodyFormat} />;
};
return (
<div className="relative mx-3 my-4 overflow-hidden rounded-md border bg-background shadow-xs">
<div className="absolute top-2 right-2 flex items-center gap-0.5 rounded-md border bg-background/95 p-0.5 shadow-xs">
<Tooltip>
<TooltipTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon-xs"
aria-label={wrapLines ? t("wrapOff") : t("wrapOn")}
aria-pressed={wrapLines}
onClick={() => setWrapLines((current) => !current)}
>
<WrapTextIcon />
</Button>
</TooltipTrigger>
<TooltipContent side="bottom">{wrapLines ? t("wrapOff") : t("wrapOn")}</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon-xs"
aria-label={copied ? t("copiedPacket") : t("copyPacket")}
onClick={() => void copyPacket()}
>
{copied ? <CheckIcon /> : <CopyIcon />}
</Button>
</TooltipTrigger>
<TooltipContent side="bottom">{copied ? t("copied") : t("copyPacket")}</TooltipContent>
</Tooltip>
</div>
{/* biome-ignore lint/a11y/useSemanticElements: textarea cannot preserve line numbers and syntax-highlighting markup. */}
<div
role="textbox"
aria-label={t("label")}
aria-multiline="true"
aria-readonly="true"
tabIndex={0}
className="max-h-[calc(100vh-15rem)] min-w-0 overflow-auto bg-background py-3 font-mono text-xs outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring"
onKeyDown={(event) => {
if (!(event.ctrlKey || event.metaKey) || event.key.toLowerCase() !== "a") return;
event.preventDefault();
const selection = window.getSelection();
if (!selection) return;
const range = document.createRange();
range.selectNodeContents(event.currentTarget);
selection.removeAllRanges();
selection.addRange(range);
}}
>
{lines.map((line, index) => (
<div
key={`${index}-${line}`}
data-line={index + 1}
className="grid min-w-0 grid-cols-[1.5rem_minmax(0,1fr)] leading-relaxed before:sticky before:left-0 before:self-stretch before:border-r before:bg-muted/20 before:px-1 before:text-right before:text-muted-foreground/60 before:content-[attr(data-line)]"
>
<code
className={cn(
"min-h-[1lh] min-w-0 pr-16 pl-1.5 [tab-size:4]",
wrapLines ? "break-words whitespace-pre-wrap" : "whitespace-pre",
)}
>
{renderLine(line, index)}
</code>
</div>
))}
</div>
</div>
);
}
+163
View File
@@ -0,0 +1,163 @@
"use client";
import * as React from "react";
import { useTranslations } from "next-intl";
import { toast } from "sonner";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Field, FieldGroup, FieldLabel } from "@/components/ui/field";
import { Input } from "@/components/ui/input";
import { api } from "@/lib/api";
import type { Finding, FindingsPage } from "@/lib/types";
export function LinkTrafficDialog({
trafficIds,
onClose,
onBound,
}: {
trafficIds: string[];
onClose: () => void;
onBound: () => void;
}) {
const t = useTranslations("linkTraffic");
const [query, setQuery] = React.useState("");
const [page, setPage] = React.useState(1);
const [data, setData] = React.useState<FindingsPage | null>(null);
const [selected, setSelected] = React.useState<Finding | null>(null);
const [error, setError] = React.useState("");
const [busy, setBusy] = React.useState(false);
const [loading, setLoading] = React.useState(true);
React.useEffect(() => {
let active = true;
setLoading(true);
setError("");
const timer = setTimeout(() => {
api
.findingsPage({ page, pageSize: 20, query })
.then((d) => {
if (active) setData(d);
})
.catch((e: Error) => {
if (active) setError(e.message);
})
.finally(() => {
if (active) setLoading(false);
});
}, 250);
return () => {
active = false;
clearTimeout(timer);
};
}, [query, page]);
async function save() {
if (!selected?.finding_id) return;
setBusy(true);
setError("");
try {
await api.bindFindingTraffic(
selected.finding_id,
trafficIds.map((traffic_id) => ({ traffic_id })),
);
toast.success(t("boundToast", { count: trafficIds.length, id: selected.finding_id }));
onBound();
onClose();
} catch (e) {
setError((e as Error).message);
} finally {
setBusy(false);
}
}
return (
<Dialog
open
onOpenChange={(open) => {
if (!open && !busy) onClose();
}}
>
<DialogContent className="max-h-[90vh] overflow-y-auto sm:max-w-2xl">
<DialogHeader>
<DialogTitle>{t("title")}</DialogTitle>
<DialogDescription>{t("description", { count: trafficIds.length })}</DialogDescription>
</DialogHeader>
<FieldGroup>
<Field>
<FieldLabel htmlFor="link-finding-query">{t("findLabel")}</FieldLabel>
<Input
id="link-finding-query"
placeholder={t("searchPlaceholder")}
value={query}
onChange={(e) => {
setQuery(e.target.value);
setPage(1);
}}
/>
</Field>
</FieldGroup>
{error ? (
<Alert variant="destructive">
<AlertDescription>{error}</AlertDescription>
</Alert>
) : null}
<div className="flex max-h-[40vh] flex-col gap-2 overflow-auto" aria-busy={loading}>
{(data?.items ?? []).map((f) => (
<Button
key={f.finding_id ?? f.id}
variant={selected?.finding_id === f.finding_id ? "secondary" : "outline"}
className="h-auto justify-start p-3 text-left whitespace-normal"
aria-pressed={selected?.finding_id === f.finding_id}
disabled={busy || !f.finding_id || f.inherited}
onClick={() => setSelected(f)}
>
<span className="flex min-w-0 flex-col gap-1">
<span>
#{f.finding_id ?? f.id} · {f.name || f.vulnclass}
</span>
<span className="line-clamp-2 text-xs text-muted-foreground">{f.summary}</span>
</span>
</Button>
))}
{!data?.items.length ? (
<p className="py-6 text-center text-sm text-muted-foreground">{loading ? t("loading") : t("empty")}</p>
) : null}
</div>
<div className="flex items-center justify-between gap-2">
<Button variant="outline" size="sm" disabled={loading || page <= 1} onClick={() => setPage((p) => p - 1)}>
{t("prevPage")}
</Button>
<span className="text-xs">{t("pageInfo", { page, total: data?.total ?? 0 })}</span>
<Button
variant="outline"
size="sm"
disabled={loading || page * 20 >= (data?.total ?? 0)}
onClick={() => setPage((p) => p + 1)}
>
{t("nextPage")}
</Button>
</div>
<p className="text-sm">
{selected
? t("selected", { id: selected.finding_id ?? "", name: selected.name || selected.vulnclass })
: t("selectPrompt")}
</p>
<DialogFooter>
<Button variant="outline" disabled={busy} onClick={onClose}>
{t("cancel")}
</Button>
<Button disabled={busy || !selected} onClick={() => void save()}>
{busy ? t("saving") : t("confirm")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
+58
View File
@@ -0,0 +1,58 @@
"use client";
import * as React from "react";
import ReactMarkdown, { type Components } from "react-markdown";
import remarkGfm from "remark-gfm";
// Tailwind-styled element overrides (no typography plugin in this project, so we
// style each element). `node` is stripped — it's not a valid DOM attribute.
const components: Components = {
h1: ({ node, ...p }) => <div className="mt-2.5 text-base font-semibold" {...p} />,
h2: ({ node, ...p }) => <div className="mt-2 text-sm font-semibold" {...p} />,
h3: ({ node, ...p }) => <div className="mt-1.5 text-sm font-medium text-foreground/90" {...p} />,
h4: ({ node, ...p }) => <div className="mt-1.5 text-sm font-medium text-foreground/90" {...p} />,
p: ({ node, ...p }) => <p className="leading-relaxed" {...p} />,
ul: ({ node, ...p }) => <ul className="my-1 list-disc space-y-0.5 pl-5" {...p} />,
ol: ({ node, ...p }) => <ol className="my-1 list-decimal space-y-0.5 pl-5" {...p} />,
strong: ({ node, ...p }) => <strong className="font-semibold" {...p} />,
a: ({ node, ...p }) => (
<a className="text-primary underline underline-offset-2" target="_blank" rel="noreferrer" {...p} />
),
hr: () => <hr className="my-2 border-border" />,
blockquote: ({ node, ...p }) => (
<blockquote className="my-1 border-l-2 border-border pl-2.5 text-muted-foreground" {...p} />
),
pre: ({ node, ...p }) => (
<pre className="my-1.5 overflow-auto rounded-md bg-muted/70 p-2.5 text-xs leading-relaxed" {...p} />
),
code: ({ node, className, children, ...rest }) => {
const block = /language-/.test(className || "");
return block ? (
<code className={"font-mono text-xs " + (className || "")} {...rest}>
{children}
</code>
) : (
<code className="break-all rounded bg-muted px-1 py-0.5 font-mono text-[0.85em]" {...rest}>
{children}
</code>
);
},
table: ({ node, ...p }) => (
<div className="my-1.5 overflow-x-auto">
<table className="w-full border-collapse text-xs" {...p} />
</div>
),
th: ({ node, ...p }) => <th className="border border-border bg-muted/60 px-2 py-1 text-left font-medium" {...p} />,
td: ({ node, ...p }) => <td className="border border-border px-2 py-1 align-top" {...p} />,
};
export function Markdown({ text }: { text: string }) {
return (
<div className="min-w-0 space-y-1.5 text-sm [&>*:first-child]:mt-0">
<ReactMarkdown remarkPlugins={[remarkGfm]} components={components}>
{text}
</ReactMarkdown>
</div>
);
}
+355
View File
@@ -0,0 +1,355 @@
"use client";
import * as React from "react";
import { AtSignIcon, ChevronRightIcon, Loader2Icon, XIcon } from "lucide-react";
import { useTranslations } from "next-intl";
import { Badge } from "@/components/ui/badge";
import { InputGroupTextarea } from "@/components/ui/input-group";
import { Popover, PopoverAnchor, PopoverContent } from "@/components/ui/popover";
import { Textarea } from "@/components/ui/textarea";
import { api } from "@/lib/api";
import { activeMention, type ChatMention, mentionSearch, mentionToken, selectedMentions } from "@/lib/chat-mentions";
import { cn } from "@/lib/utils";
type Props = Omit<React.ComponentProps<"textarea">, "value" | "onChange" | "ref"> & {
value: string;
onValueChange: (value: string) => void;
inputGroup?: boolean;
};
export function MentionTextarea({ value, onValueChange, onKeyDown, className, inputGroup, disabled, ...props }: Props) {
const t = useTranslations("mentionTextarea");
const textarea = React.useRef<HTMLTextAreaElement>(null);
const composing = React.useRef(false);
const pendingKeyboardIndex = React.useRef<number | null>(null);
const [cursor, setCursor] = React.useState<number | null>(null);
const [dismissed, setDismissed] = React.useState(false);
const [activeIndex, setActiveIndex] = React.useState(0);
const [result, setResult] = React.useState<{
key: string;
items: ChatMention[];
next_cursor?: string;
error?: string;
loadingMore?: boolean;
pageError?: string;
} | null>(null);
const request = React.useRef<{ key: string; controller: AbortController; loadingMore: boolean } | null>(null);
const listId = React.useId();
const active = cursor === null ? null : activeMention(value, cursor);
const search = mentionSearch(active?.query ?? "");
const open = !!active && !dismissed && !disabled;
const categories = search.categories;
const requestKey = open && !categories.length ? `${search.kind}:${search.query}` : "";
const loading = !!requestKey && result?.key !== requestKey;
const items = result?.key === requestKey ? result.items : [];
const error = result?.key === requestKey ? result.error : undefined;
const nextCursor = result?.key === requestKey ? result.next_cursor : undefined;
const loadingMore = result?.key === requestKey && result.loadingMore;
const pageError = result?.key === requestKey ? result.pageError : undefined;
const count = categories.length || items.length;
const selected = selectedMentions(value);
React.useEffect(() => {
if (!requestKey) return;
setResult(null);
const controller = new AbortController();
request.current = { key: requestKey, controller, loadingMore: false };
const timer = setTimeout(() => {
api.chatMentions(search.kind, search.query, controller.signal).then(
(page) => {
if (!controller.signal.aborted) setResult({ key: requestKey, ...page });
},
(error: Error) => {
if (!controller.signal.aborted) setResult({ key: requestKey, items: [], error: error.message });
},
);
}, 180);
return () => {
clearTimeout(timer);
controller.abort();
};
}, [requestKey, search.kind, search.query]);
function loadMore() {
const session = request.current;
if (
!nextCursor ||
!session ||
session.key !== requestKey ||
session.controller.signal.aborted ||
session.loadingMore
)
return;
session.loadingMore = true;
setResult((current) =>
current?.key === requestKey ? { ...current, loadingMore: true, pageError: undefined } : current,
);
api
.chatMentions(search.kind, search.query, session.controller.signal, nextCursor)
.then(
(page) => {
if (session.controller.signal.aborted) return;
setResult((current) => {
if (current?.key !== session.key) return current;
const seen = new Set(current.items.map((item) => `${item.kind}:${item.id}`));
return {
...current,
items: [...current.items, ...page.items.filter((item) => !seen.has(`${item.kind}:${item.id}`))],
next_cursor: page.next_cursor,
loadingMore: false,
};
});
},
(error: Error) => {
if (!session.controller.signal.aborted)
setResult((current) =>
current?.key === session.key ? { ...current, loadingMore: false, pageError: error.message } : current,
);
},
)
.finally(() => {
session.loadingMore = false;
});
}
React.useEffect(() => {
if (open) document.getElementById(`${listId}-${activeIndex}`)?.scrollIntoView({ block: "nearest" });
}, [activeIndex, listId, open]);
React.useEffect(() => {
if (open && pendingKeyboardIndex.current !== null && pendingKeyboardIndex.current < count) {
document.getElementById(`${listId}-${pendingKeyboardIndex.current}`)?.scrollIntoView({ block: "nearest" });
pendingKeyboardIndex.current = null;
}
}, [count, listId, open]);
function replaceActive(text: string, close: boolean) {
if (!active) return;
const next = value.slice(0, active.start) + text + value.slice(active.end);
const caret = active.start + text.length;
onValueChange(next);
setCursor(caret);
setDismissed(close);
setActiveIndex(0);
requestAnimationFrame(() => {
textarea.current?.focus();
textarea.current?.setSelectionRange(caret, caret);
});
}
function choose(index: number) {
if (categories[index]) replaceActive(`@${categories[index].label} `, false);
else if (items[index]) replaceActive(`${mentionToken(items[index])} `, true);
}
function keyDown(event: React.KeyboardEvent<HTMLTextAreaElement>) {
if (composing.current || event.nativeEvent.isComposing || event.keyCode === 229) return;
if (open) {
if (event.key === "Escape") {
event.preventDefault();
setDismissed(true);
return;
}
if (event.key === "ArrowDown" || event.key === "ArrowUp") {
event.preventDefault();
if (event.key === "ArrowDown" && count && activeIndex >= count - 1 && nextCursor) {
pendingKeyboardIndex.current = count;
setActiveIndex(count);
loadMore();
return;
}
if (count) setActiveIndex((index) => (index + (event.key === "ArrowDown" ? 1 : count - 1)) % count);
return;
}
if ((event.key === "Enter" || event.key === "Tab") && !event.shiftKey && !event.ctrlKey && !event.metaKey) {
if (event.key === "Tab" && (loading || !count)) {
setDismissed(true);
return;
}
event.preventDefault();
if (!loading && count) choose(Math.min(activeIndex, count - 1));
return;
}
}
onKeyDown?.(event);
}
const Control = inputGroup ? InputGroupTextarea : Textarea;
return (
<div className="min-w-0 flex-1 self-stretch">
<Popover
open={open}
onOpenChange={(next) => {
if (!next) setDismissed(true);
}}
>
<PopoverAnchor asChild>
<div>
<Control
{...props}
ref={textarea}
value={value}
disabled={disabled}
className={className}
aria-label={props["aria-label"] ?? t("inputAria")}
aria-autocomplete="list"
aria-controls={open ? listId : undefined}
aria-expanded={open}
aria-activedescendant={open && count ? `${listId}-${Math.min(activeIndex, count - 1)}` : undefined}
onChange={(event) => {
pendingKeyboardIndex.current = null;
onValueChange(event.target.value);
setCursor(event.target.selectionStart);
setDismissed(false);
setActiveIndex(0);
}}
onSelect={(event) => setCursor(event.currentTarget.selectionStart)}
onCompositionStart={() => {
composing.current = true;
}}
onCompositionEnd={() => {
composing.current = false;
}}
onKeyDown={keyDown}
/>
</div>
</PopoverAnchor>
<PopoverContent
side="top"
align="start"
className="w-[min(28rem,calc(100vw-2rem))] gap-1 p-1.5"
onOpenAutoFocus={(event) => event.preventDefault()}
onCloseAutoFocus={(event) => event.preventDefault()}
onInteractOutside={(event) => {
if (event.target === textarea.current) event.preventDefault();
}}
aria-label={t("listboxAria")}
>
<div className="flex items-center justify-between px-2 py-1 text-muted-foreground text-xs">
<span>
{categories.length
? t("pickKind")
: t("searchKind", {
kind: search.kind ? t(`kind.${search.kind}`) : t("allRecords"),
})}
</span>
<span>{t("hintKeys")}</span>
</div>
<div
id={listId}
role="listbox"
aria-label={t("candidatesAria")}
className="max-h-60 overflow-y-auto"
onScroll={(event) => {
const list = event.currentTarget;
if (list.scrollHeight - list.scrollTop - list.clientHeight < 64) loadMore();
}}
>
{categories.map((kind, index) => (
<button
key={kind.kind}
id={`${listId}-${index}`}
type="button"
role="option"
aria-selected={activeIndex === index}
className={cn(
"flex w-full items-center gap-2 rounded-md px-2 py-2 text-left text-sm hover:bg-accent",
activeIndex === index && "bg-accent",
)}
onMouseDown={(event) => event.preventDefault()}
onClick={() => choose(index)}
>
<AtSignIcon className="size-4 text-muted-foreground" />
<span>{t(`kind.${kind.kind}`)}</span>
<ChevronRightIcon className="ml-auto size-4 text-muted-foreground" />
</button>
))}
{!categories.length && loading && (
<div role="status" className="flex items-center gap-2 p-3 text-muted-foreground text-sm">
<Loader2Icon className="size-4 animate-spin" />
{t("searching")}
</div>
)}
{!categories.length && !loading && error && (
<div role="alert" className="p-3 text-destructive text-sm">
{t("searchError", { error })}
</div>
)}
{!categories.length && !loading && !error && !items.length && (
<div role="status" className="p-3 text-muted-foreground text-sm">
{t("noMatch")}
</div>
)}
{items.map((item, index) => (
<button
key={`${item.kind}:${item.id}`}
id={`${listId}-${index}`}
type="button"
role="option"
aria-selected={activeIndex === index}
className={cn(
"flex w-full flex-col gap-0.5 rounded-md px-2 py-2 text-left hover:bg-accent",
activeIndex === index && "bg-accent",
)}
onMouseDown={(event) => event.preventDefault()}
onClick={() => choose(index)}
>
<span className="flex w-full items-center gap-2">
<Badge variant="outline">{t(`kind.${item.kind}`)}</Badge>
<span className="truncate text-sm">{item.label}</span>
<span className="ml-auto shrink-0 text-muted-foreground text-xs">#{item.id}</span>
</span>
<span className="w-full truncate text-muted-foreground text-xs">{item.description}</span>
</button>
))}
</div>
{!categories.length && (
<p className="px-2 py-1 text-muted-foreground text-xs">
{loadingMore && <span role="status">{t("loadingMore")}</span>}
{!loadingMore && nextCursor && (
<button type="button" onMouseDown={(event) => event.preventDefault()} onClick={loadMore}>
{pageError ? t("pageError") : t("showingMore", { count: items.length })}
</button>
)}
{!loadingMore &&
!nextCursor &&
!loading &&
!error &&
items.length > 0 &&
t("showingAll", { count: items.length })}
{!loadingMore && !nextCursor && (loading || !!error || items.length === 0) && t("searchHint")}
</p>
)}
</PopoverContent>
</Popover>
{selected.length > 0 && (
<div className="flex flex-wrap items-center gap-1 px-1 pt-1">
{selected.map((item) => {
const label = `${t(`kind.${item.kind}`)} #${item.id}${item.text ? ` · ${item.text}` : ""}`;
return (
<Badge key={item.start} variant="secondary" className="max-w-full gap-1">
<span className="max-w-64 truncate" title={label}>
{label}
</span>
<button
type="button"
disabled={disabled}
aria-label={t("removeAria", { label })}
onClick={() => {
onValueChange(value.slice(0, item.start) + value.slice(item.start + item.token.length));
setCursor(null);
}}
>
<XIcon className="size-3" />
</button>
</Badge>
);
})}
<span className="text-muted-foreground text-xs">{t("selectedHint")}</span>
</div>
)}
</div>
);
}
+88
View File
@@ -0,0 +1,88 @@
"use client";
import * as React from "react";
import { useTranslations } from "next-intl";
import { Badge } from "@/components/ui/badge";
import { Field, FieldDescription, FieldError, FieldLabel } from "@/components/ui/field";
import { Textarea } from "@/components/ui/textarea";
import { MAX_COMPANY_SCOPE_VALUE_LENGTH, type ParsedCompanyScopeText } from "@/lib/company-scope";
import type { CompanyScopeKind } from "@/lib/types";
// 라벨은 렌더 시점에 t(`kind.${kind}`) 로 해석(아래 배지).
const SCOPE_KINDS: CompanyScopeKind[] = ["domain", "ip", "cidr", "icp", "keyword"];
export function ScopeTextEditor({
id,
value,
onValueChange,
parsed,
label,
description,
}: {
id: string;
value: string;
onValueChange: (value: string) => void;
parsed: ParsedCompanyScopeText;
label?: string;
description?: string;
}) {
const t = useTranslations("scopeEditor");
const counts = React.useMemo(() => {
const result = new Map<CompanyScopeKind, number>();
for (const rule of parsed.rules) result.set(rule.kind, (result.get(rule.kind) ?? 0) + 1);
return result;
}, [parsed.rules]);
// company-scope.ts 는 번역할 수 없는 순수 모듈이라 오류 "코드"만 돌려준다. 여기서
// 코드별 메시지로 해석한다(tooLong 은 최대 글자 수를 함께 보간).
const errorText = React.useCallback(
(code: string) => {
if (code === "tooLong") return t("error.tooLong", { max: MAX_COMPANY_SCOPE_VALUE_LENGTH });
const key = `error.${code}`;
return t.has(key) ? t(key) : code;
},
[t],
);
return (
<Field data-invalid={parsed.errors.length > 0}>
<FieldLabel htmlFor={id}>{label ?? t("defaultLabel")}</FieldLabel>
<FieldDescription>{description ?? t("defaultDescription")}</FieldDescription>
<Textarea
id={id}
rows={8}
value={value}
aria-invalid={parsed.errors.length > 0}
placeholder={t("placeholder")}
className="min-h-36 resize-y font-mono text-sm"
onChange={(event) => onValueChange(event.target.value)}
/>
{parsed.rules.length > 0 && (
<div className="flex flex-wrap items-center gap-1.5 text-muted-foreground text-xs">
<span>{t("recognized", { count: parsed.rules.length })}</span>
{SCOPE_KINDS.map((kind) => {
const count = counts.get(kind) ?? 0;
return count > 0 ? (
<Badge key={kind} variant="secondary" className="font-mono tabular-nums">
{t(`kind.${kind}`)} {count}
</Badge>
) : null;
})}
</div>
)}
{parsed.errors.length > 0 && (
<FieldError>
{parsed.errors.slice(0, 5).map((item) => (
<span key={`${item.line}-${item.error}`} className="block">
{t("lineError", { line: item.line, error: errorText(item.error) })}
</span>
))}
{parsed.errors.length > 5 && (
<span className="block">{t("moreErrors", { count: parsed.errors.length - 5 })}</span>
)}
</FieldError>
)}
</Field>
);
}
@@ -0,0 +1,258 @@
"use client";
import { type ReactNode, useEffect, useRef, useState } from "react";
import { ArrowUpIcon, MessageCircleQuestionIcon, SquareIcon, Trash2Icon, XIcon } from "lucide-react";
import { useTranslations } from "next-intl";
import { Markdown } from "@/components/markdown";
import { Alert, AlertDescription } from "@/components/ui/alert";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Drawer, DrawerContent, DrawerDescription, DrawerHeader, DrawerTitle } from "@/components/ui/drawer";
import { Empty, EmptyDescription, EmptyHeader, EmptyTitle } from "@/components/ui/empty";
import { InputGroup, InputGroupAddon, InputGroupButton, InputGroupTextarea } from "@/components/ui/input-group";
import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from "@/components/ui/resizable";
import { Skeleton } from "@/components/ui/skeleton";
import { useIsMobile } from "@/hooks/use-mobile";
import type { SideQuestions } from "@/hooks/use-side-questions";
import { cn } from "@/lib/utils";
type ComposerLayout = "inline" | "stacked";
export function SideQuestionButton({ side }: { side: SideQuestions }) {
const t = useTranslations("sideQuestion");
if (!side.enabled) return null;
return (
<Button variant="outline" size="sm" onClick={() => side.setOpen(true)} title={t("buttonTitle")}>
<MessageCircleQuestionIcon data-icon="inline-start" />
{t("button")}
</Button>
);
}
function SidePanel({
side,
label,
composerLayout,
}: {
side: SideQuestions;
label: string;
composerLayout: ComposerLayout;
}) {
const t = useTranslations("sideQuestion");
const inlineComposer = composerLayout === "inline";
const [confirm, setConfirm] = useState(false);
const viewport = useRef<HTMLDivElement>(null);
const pinned = useRef(true);
const tail = side.items.at(-1);
// biome-ignore lint/correctness/useExhaustiveDependencies: New cumulative text scrolls only readers who remain at the bottom.
useEffect(() => {
if (pinned.current && viewport.current) viewport.current.scrollTop = viewport.current.scrollHeight;
}, [tail?.answer, tail?.id]);
return (
<section className="flex h-full min-h-0 flex-col bg-background" aria-label={t("panelLabel")}>
<div className="flex items-center gap-2 border-b p-3">
<div className="min-w-0 flex-1">
<p className="font-medium">
{t("title")} <span className="text-muted-foreground">/btw</span>
</p>
<p className="truncate text-muted-foreground text-xs">{label}</p>
</div>
<Button
variant="ghost"
size="icon-sm"
onClick={() => setConfirm(true)}
disabled={!side.items.length || side.busy}
aria-label={t("clearHistory")}
>
<Trash2Icon />
</Button>
<Button variant="ghost" size="icon-sm" onClick={() => side.setOpen(false)} aria-label={t("closePanel")}>
<XIcon />
</Button>
</div>
<div className="border-b px-3 py-2 text-muted-foreground text-xs">
{side.snapshot ? (
<>
<p>{side.snapshot.model.model}</p>
<p>{t("contextUpdatedAt", { time: new Date(side.snapshot.captured_at).toLocaleString() })}</p>
</>
) : (
t("firstRunHint")
)}
</div>
<div
ref={viewport}
onScroll={(event) => {
const el = event.currentTarget;
pinned.current = el.scrollHeight - el.scrollTop - el.clientHeight < 80;
}}
className="min-h-0 flex-1 overflow-y-auto p-3"
>
{side.nextCursor > 0 && (
<Button variant="ghost" size="sm" onClick={() => void side.load(side.nextCursor)}>
{t("loadEarlier")}
</Button>
)}
{side.loading && <Skeleton className="h-16 w-full" />}
{!side.loading && side.items.length === 0 && (
<Empty>
<EmptyHeader>
<EmptyTitle>{t("emptyTitle")}</EmptyTitle>
<EmptyDescription>{t("emptyDesc")}</EmptyDescription>
</EmptyHeader>
</Empty>
)}
<div className="flex flex-col gap-5">
{side.items.map((item) => (
<article key={item.id} className="flex min-w-0 flex-col gap-2">
<div className="whitespace-pre-wrap break-words rounded-lg bg-muted p-3 text-sm">{item.question}</div>
<div className="flex flex-wrap items-center gap-2 text-muted-foreground text-xs">
<Badge variant="secondary">{t(`status.${item.status}`)}</Badge>
<span className="truncate">{item.model.model}</span>
<time dateTime={item.snapshot_at} title={new Date(item.snapshot_at).toLocaleString()}>
{t("contextTime", { time: new Date(item.snapshot_at).toLocaleTimeString() })}
</time>
</div>
{item.context?.estimated_input_tokens != null && (
<p className="text-muted-foreground text-xs">
{t("recentExchanges", { count: item.context.recent_exchanges })}
{item.context.history_summarized && ` · ${t("historySummarized")}`}
{item.context.snapshot_summarized && ` · ${t("snapshotSummarized")}`}
</p>
)}
{item.answer && <Markdown text={item.answer} />}
{!item.answer && item.status === "running" && (
<p role="status" className="text-muted-foreground text-sm">
{t(`phase.${item.context?.phase ?? "answering"}`)}
</p>
)}
{item.error && (
<Alert variant="destructive">
<AlertDescription>{item.error}</AlertDescription>
</Alert>
)}
</article>
))}
</div>
</div>
<div className="shrink-0 border-t p-3">
{(side.error || side.snapshot?.reason) && (
<Alert variant="destructive" className="mb-2">
<AlertDescription>{side.error || side.snapshot?.reason}</AlertDescription>
</Alert>
)}
<InputGroup className={inlineComposer ? "min-h-10" : "min-h-9"}>
<InputGroupTextarea
rows={1}
className={cn("overflow-y-auto", inlineComposer ? "max-h-40 min-h-0" : "max-h-36 min-h-9")}
aria-label={t("questionAria")}
placeholder={t("askPlaceholder")}
value={side.draft}
maxLength={4000}
disabled={side.busy}
onChange={(event) => side.setDraft(event.target.value)}
onKeyDown={(event) => {
if (event.key === "Enter" && !event.shiftKey && !event.nativeEvent.isComposing) {
event.preventDefault();
void side.ask(side.draft);
}
}}
/>
<InputGroupAddon align={inlineComposer ? "inline-end" : "block-end"}>
{!inlineComposer && <span className="text-muted-foreground text-xs">{t("independentNote")}</span>}
{side.running ? (
<InputGroupButton
className="ml-auto"
variant="destructive"
size="icon-xs"
onClick={() => void side.stop()}
aria-label={t("stopAnswer")}
>
<SquareIcon />
</InputGroupButton>
) : (
<InputGroupButton
className="ml-auto"
variant="default"
size="icon-xs"
onClick={() => void side.ask(side.draft)}
disabled={side.busy || !side.draft.trim() || !side.snapshot?.available}
aria-label={t("sendQuestion")}
>
<ArrowUpIcon />
</InputGroupButton>
)}
</InputGroupAddon>
</InputGroup>
</div>
{inlineComposer && (
<div className="shrink-0 truncate px-3 pt-0.5 pb-1 text-muted-foreground text-xs">{t("independentNote")}</div>
)}
<AlertDialog open={confirm} onOpenChange={setConfirm}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{t("clearConfirmTitle")}</AlertDialogTitle>
<AlertDialogDescription>{t("clearConfirmDesc")}</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>{t("cancel")}</AlertDialogCancel>
<AlertDialogAction onClick={() => void side.clear()}>{t("clearConfirm")}</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</section>
);
}
export function SideQuestionWorkspace({
side,
label,
children,
composerLayout = "stacked",
}: {
side: SideQuestions;
label: string;
children: ReactNode;
composerLayout?: ComposerLayout;
}) {
const t = useTranslations("sideQuestion");
const mobile = useIsMobile();
return (
<>
<ResizablePanelGroup orientation="horizontal" className="min-h-0 min-w-0 flex-1">
<ResizablePanel id="main-conversation" minSize="35%" className="flex min-h-0 min-w-0 flex-col">
{children}
</ResizablePanel>
{side.open && side.enabled && !mobile && (
<>
<ResizableHandle withHandle />
<ResizablePanel id="side-question" defaultSize="38%" minSize="280px" maxSize="65%">
<SidePanel side={side} label={label} composerLayout={composerLayout} />
</ResizablePanel>
</>
)}
</ResizablePanelGroup>
<Drawer open={mobile && side.open && side.enabled} onOpenChange={side.setOpen}>
<DrawerContent className="h-[85svh]">
<DrawerHeader className="sr-only">
<DrawerTitle>{t("title")}</DrawerTitle>
<DrawerDescription>{t("drawerDesc", { label })}</DrawerDescription>
</DrawerHeader>
<SidePanel side={side} label={label} composerLayout={composerLayout} />
</DrawerContent>
</Drawer>
</>
);
}
+30
View File
@@ -0,0 +1,30 @@
"use client";
import type * as React from "react";
import type { SimpleIcon as SimpleIconType } from "simple-icons";
import { cn } from "@/lib/utils";
type SimpleIconProps = {
icon: SimpleIconType;
className?: string;
} & React.SVGProps<SVGSVGElement>;
export function SimpleIcon({ icon, className, ...props }: SimpleIconProps) {
const { title, path } = icon;
return (
<svg
viewBox="0 0 24 24"
aria-label={title}
aria-hidden="false"
focusable="false"
className={cn("size-5 fill-foreground", className)}
{...props}
>
<title>{title}</title>
<path d={path} />
</svg>
);
}
+42
View File
@@ -0,0 +1,42 @@
"use client";
import { useTranslations } from "next-intl";
import { type StatusDomain, statusMeta, type Tone, toneClasses, toneDot } from "@/lib/status";
import { cn } from "@/lib/utils";
export function StatusBadge({
domain,
value,
dot = false,
className,
}: {
domain: StatusDomain;
value: string;
dot?: boolean;
className?: string;
}) {
const meta = statusMeta(domain, value);
// Shared status labels live in the "status" message namespace (ko/zh);
// lib/status.ts only carries the color tone. The raw value is the last-resort
// fallback for an unknown status (every real status.* key exists in ko/zh).
const t = useTranslations("status");
const key = `${domain}.${value}`;
const label = t.has(key) ? t(key) : value;
return (
<span
className={cn(
"inline-flex items-center gap-1.5 rounded-md border px-2 py-0.5 text-xs font-medium whitespace-nowrap",
toneClasses[meta.tone],
className,
)}
>
{dot && <span className={cn("size-1.5 rounded-full", toneDot[meta.tone])} />}
{label}
</span>
);
}
export function ToneDot({ tone }: { tone: Tone }) {
return <span className={cn("size-2 rounded-full", toneDot[tone])} />;
}
+125
View File
@@ -0,0 +1,125 @@
"use client";
import { useEffect, useRef } from "react";
import { ChevronLeftIcon, ChevronRightIcon } from "lucide-react";
import { useTranslations } from "next-intl";
import { Button } from "@/components/ui/button";
import { Pagination, PaginationContent, PaginationEllipsis, PaginationItem } from "@/components/ui/pagination";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
interface TablePaginationProps {
page: number; // 1-based
pageSize: number;
total: number; // total (after filtering)
onPageChange: (p: number) => void;
onPageSizeChange: (s: number) => void;
pageSizeOptions?: number[];
}
// pageWindows returns the sequence of page numbers / ellipsis to render.
function pageWindows(page: number, total: number): (number | "...")[] {
if (total <= 7) return Array.from({ length: total }, (_, i) => i + 1);
const out: (number | "...")[] = [1];
if (page > 3) out.push("...");
const lo = Math.max(2, page - 1);
const hi = Math.min(total - 1, page + 1);
for (let i = lo; i <= hi; i++) out.push(i);
if (page < total - 2) out.push("...");
out.push(total);
return out;
}
export function TablePagination({
page,
pageSize,
total,
onPageChange,
onPageSizeChange,
pageSizeOptions = [10, 20, 50],
}: TablePaginationProps) {
const t = useTranslations("pagination");
const totalPages = Math.max(1, Math.ceil(total / pageSize));
const safePage = Math.min(Math.max(1, page), totalPages);
const from = total === 0 ? 0 : (safePage - 1) * pageSize + 1;
const to = Math.min(safePage * pageSize, total);
const onPageChangeRef = useRef(onPageChange);
onPageChangeRef.current = onPageChange;
useEffect(() => {
if (page !== safePage) onPageChangeRef.current(safePage);
}, [page, safePage]);
return (
<div className="flex flex-wrap items-center justify-between gap-2 border-t px-4 py-2 lg:px-6">
<div className="text-muted-foreground flex items-center gap-2 text-xs">
<Select value={String(pageSize)} onValueChange={(v) => onPageSizeChange(Number(v))}>
<SelectTrigger size="sm" className="h-7 w-16">
<SelectValue />
</SelectTrigger>
<SelectContent>
{pageSizeOptions.map((s) => (
<SelectItem key={s} value={String(s)}>
{s}
</SelectItem>
))}
</SelectContent>
</Select>
<span>{t("perPage")}</span>
{total > 0 ? (
<span className="tabular-nums">{t("range", { from, to, total })}</span>
) : (
<span>{t("empty")}</span>
)}
</div>
{totalPages > 1 && (
<Pagination className="mx-0 w-auto justify-end">
<PaginationContent>
<PaginationItem>
<Button
variant="ghost"
size="icon-sm"
disabled={safePage === 1}
onClick={() => onPageChange(safePage - 1)}
aria-label={t("prev")}
>
<ChevronLeftIcon className="size-4" />
</Button>
</PaginationItem>
{pageWindows(safePage, totalPages).map((p, i) =>
p === "..." ? (
<PaginationItem key={`el-${i}`}>
<PaginationEllipsis />
</PaginationItem>
) : (
<PaginationItem key={p}>
<Button
variant={p === safePage ? "outline" : "ghost"}
size="icon-sm"
onClick={() => onPageChange(p)}
aria-current={p === safePage ? "page" : undefined}
>
{p}
</Button>
</PaginationItem>
),
)}
<PaginationItem>
<Button
variant="ghost"
size="icon-sm"
disabled={safePage === totalPages}
onClick={() => onPageChange(safePage + 1)}
aria-label={t("next")}
>
<ChevronRightIcon className="size-4" />
</Button>
</PaginationItem>
</PaginationContent>
</Pagination>
)}
</div>
);
}
@@ -0,0 +1,226 @@
"use client";
import * as React from "react";
import { ArrowDownIcon, ArrowUpIcon, XIcon } from "lucide-react";
import { useTranslations } from "next-intl";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Combobox,
ComboboxChip,
ComboboxChips,
ComboboxChipsInput,
ComboboxContent,
ComboboxEmpty,
ComboboxItem,
ComboboxList,
ComboboxValue,
} from "@/components/ui/combobox";
import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import type { LLMProfile } from "@/lib/types";
interface TaskLLMProfileChainProps {
profiles: LLMProfile[];
value: string[];
onValueChange: (value: string[]) => void;
activeProfileId?: string;
onActiveProfileChange?: (value: string) => void;
disabled?: boolean;
inputId?: string;
portalContainer?: React.RefObject<HTMLElement | null>;
}
function ProfileRoleBadge({ index, currentIndex }: { index: number; currentIndex: number }) {
const t = useTranslations("llmChain");
if (index === currentIndex) return <Badge variant="default">{t("current")}</Badge>;
if (index < currentIndex) {
return (
<Badge variant="outline" title={t("skippedTitle")}>
{t("skipped")}
</Badge>
);
}
return <Badge variant="secondary">{t("backup", { n: index - currentIndex })}</Badge>;
}
export function TaskLLMProfileChain({
profiles,
value,
onValueChange,
activeProfileId,
onActiveProfileChange,
disabled = false,
inputId,
portalContainer,
}: TaskLLMProfileChainProps) {
const t = useTranslations("llmChain");
const profilesByID = React.useMemo(() => new Map(profiles.map((profile) => [profile.id, profile])), [profiles]);
const itemIDs = React.useMemo(() => profiles.map((profile) => profile.id), [profiles]);
const profilesUnavailable = profiles.length === 0;
const currentProfileID = activeProfileId && value.includes(activeProfileId) ? activeProfileId : value[0];
const currentIndex = value.indexOf(currentProfileID);
// api_key_hint is display-only and is empty for valid keys shorter than four
// characters. The task API remains authoritative for profile validation.
const profileLabel = React.useCallback(
(id: string) => {
const profile = profilesByID.get(id);
return profile ? `${profile.name} ${profile.model}` : t("profileRef", { id });
},
[profilesByID, t],
);
const move = (index: number, offset: -1 | 1) => {
const target = index + offset;
if (target < 0 || target >= value.length) return;
const next = [...value];
[next[index], next[target]] = [next[target], next[index]];
onValueChange(next);
};
const remove = (id: string) => {
const next = value.filter((profileID) => profileID !== id);
onValueChange(next);
if (activeProfileId === id) onActiveProfileChange?.(next[0] ?? "");
};
const handleValueChange = (next: string[]) => {
onValueChange(next);
if (activeProfileId && !next.includes(activeProfileId)) {
onActiveProfileChange?.(next[0] ?? "");
}
};
return (
<div className="flex flex-col gap-3">
<Combobox
items={itemIDs}
itemToStringValue={profileLabel}
multiple
value={value}
onValueChange={handleValueChange}
disabled={disabled}
>
<ComboboxChips>
<ComboboxValue>
{value.map((id) => (
<ComboboxChip key={id}>{profilesByID.get(id)?.name ?? `#${id}`}</ComboboxChip>
))}
</ComboboxValue>
<ComboboxChipsInput
id={inputId}
placeholder={profiles.length > 0 ? t("searchPlaceholder") : t("noProfiles")}
disabled={disabled ? true : profilesUnavailable}
/>
</ComboboxChips>
<ComboboxContent portalContainer={portalContainer}>
<ComboboxEmpty>{t("noMatch")}</ComboboxEmpty>
<ComboboxList>
{(id) => {
const profile = profilesByID.get(id);
return (
<ComboboxItem key={id} value={id} disabled={!profile}>
<div className="flex min-w-0 flex-1 flex-col">
<span className="flex min-w-0 items-center gap-2">
<span className="truncate">
{profile?.name ?? t("profileRef", { id })}
{profile?.is_default ? t("activeSuffix") : ""}
</span>
</span>
{profile && <span className="truncate text-muted-foreground text-xs">{profile.model}</span>}
</div>
</ComboboxItem>
);
}}
</ComboboxList>
</ComboboxContent>
</Combobox>
{value.length === 0 ? (
<p className="text-muted-foreground text-xs">{t("emptyHint")}</p>
) : (
<div className="flex flex-col divide-y rounded-lg border">
{value.map((id, index) => {
const profile = profilesByID.get(id);
return (
<div
key={id}
className="grid min-w-0 grid-cols-[auto_minmax(0,1fr)_auto] items-center gap-2 px-2.5 py-2 sm:flex"
>
<span className="w-5 shrink-0 text-center text-muted-foreground text-xs tabular-nums">{index + 1}</span>
<div className="min-w-0 sm:flex-1">
<p className="truncate font-medium text-sm">{profile?.name ?? t("profileRef", { id })}</p>
<div className="flex min-w-0 items-center gap-2">
<p className="truncate text-muted-foreground text-xs">{profile?.model ?? t("unavailable")}</p>
</div>
</div>
<ProfileRoleBadge index={index} currentIndex={currentIndex} />
<div className="col-span-2 col-start-2 flex shrink-0 items-center gap-1 justify-self-end sm:col-auto sm:justify-self-auto">
<Button
type="button"
variant="ghost"
size="icon-sm"
aria-label={t("moveUp")}
onClick={() => move(index, -1)}
disabled={disabled ? true : index === 0}
>
<ArrowUpIcon data-icon="inline-start" />
</Button>
<Button
type="button"
variant="ghost"
size="icon-sm"
aria-label={t("moveDown")}
onClick={() => move(index, 1)}
disabled={disabled ? true : index === value.length - 1}
>
<ArrowDownIcon data-icon="inline-start" />
</Button>
<Button
type="button"
variant="ghost"
size="icon-sm"
aria-label={t("remove")}
onClick={() => remove(id)}
disabled={disabled}
>
<XIcon data-icon="inline-start" />
</Button>
</div>
</div>
);
})}
</div>
)}
{onActiveProfileChange && value.length > 0 && (
<div className="flex flex-col items-stretch gap-2 sm:flex-row sm:items-center sm:justify-between sm:gap-3">
<span className="font-medium text-sm">{t("currentProfile")}</span>
<Select
value={activeProfileId && value.includes(activeProfileId) ? activeProfileId : value[0]}
onValueChange={onActiveProfileChange}
disabled={disabled}
>
<SelectTrigger size="sm" className="w-full sm:min-w-48 sm:max-w-64">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectGroup>
{value.map((id) => {
const profile = profilesByID.get(id);
return (
<SelectItem key={id} value={id} disabled={!profile}>
{profile?.name ?? t("profileRef", { id })}
</SelectItem>
);
})}
</SelectGroup>
</SelectContent>
</Select>
</div>
)}
</div>
);
}
@@ -0,0 +1,540 @@
"use client";
import * as React from "react";
import { LibraryIcon, PlusIcon, SaveIcon, Settings2Icon, Trash2Icon } from "lucide-react";
import { useTranslations } from "next-intl";
import { toast } from "sonner";
import { AssetInterceptRulesEditor } from "@/components/asset-intercept-rules-editor";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { Button } from "@/components/ui/button";
import {
Combobox,
ComboboxContent,
ComboboxEmpty,
ComboboxInput,
ComboboxItem,
ComboboxList,
} from "@/components/ui/combobox";
import { Field, FieldDescription, FieldGroup, FieldLabel } from "@/components/ui/field";
import { Input } from "@/components/ui/input";
import { NativeSelect, NativeSelectOption } from "@/components/ui/native-select";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Sheet, SheetContent, SheetDescription, SheetFooter, SheetHeader, SheetTitle } from "@/components/ui/sheet";
import { Spinner } from "@/components/ui/spinner";
import { Textarea } from "@/components/ui/textarea";
import { api } from "@/lib/api";
import type { AssetInterceptRuleInput, TaskCategory, TaskTemplate } from "@/lib/types";
import { cn } from "@/lib/utils";
interface TemplateDraft {
name: string;
description: string;
goal: string;
categoryID: number | null;
interceptRules: AssetInterceptRuleInput[];
}
// TemplateSeed 是「另存为模板」时从创建表单带入的初值。
type TemplateSeed = Pick<TemplateDraft, "description" | "goal" | "categoryID" | "interceptRules">;
interface TaskTemplateManagerProps {
open: boolean;
onOpenChange: (open: boolean) => void;
templates: TaskTemplate[];
seed: TemplateSeed | null;
onCreated: (template: TaskTemplate) => void;
onUpdated: (template: TaskTemplate) => void;
onDeleted: (id: number) => void;
}
const emptyDraft = (): TemplateDraft => ({
name: "",
description: "",
goal: "",
categoryID: null,
interceptRules: [],
});
function templateDraft(template: TaskTemplate): TemplateDraft {
return {
name: template.name,
description: template.description,
goal: template.goal,
categoryID: template.category_id ?? null,
interceptRules: template.intercept_rules ?? [],
};
}
function TaskTemplateManager({
open,
onOpenChange,
templates,
seed,
onCreated,
onUpdated,
onDeleted,
}: TaskTemplateManagerProps) {
const t = useTranslations("taskTemplate");
const [selectedID, setSelectedID] = React.useState<number | null>(null);
const [draft, setDraft] = React.useState<TemplateDraft>(emptyDraft);
const [saving, setSaving] = React.useState(false);
const [deleteOpen, setDeleteOpen] = React.useState(false);
const [deleting, setDeleting] = React.useState(false);
const [categories, setCategories] = React.useState<TaskCategory[]>([]);
const wasOpen = React.useRef(false);
React.useEffect(() => {
if (!open) return;
api
.taskCategories()
.then(setCategories)
.catch(() => setCategories([]));
}, [open]);
React.useEffect(() => {
if (open && !wasOpen.current) {
if (seed) {
setSelectedID(null);
setDraft({
name: "",
description: seed.description,
goal: seed.goal,
categoryID: seed.categoryID,
interceptRules: seed.interceptRules,
});
} else if (templates[0]) {
setSelectedID(templates[0].id);
setDraft(templateDraft(templates[0]));
} else {
setSelectedID(null);
setDraft(emptyDraft());
}
}
wasOpen.current = open;
}, [open, seed, templates]);
const selectTemplate = (template: TaskTemplate) => {
setSelectedID(template.id);
setDraft(templateDraft(template));
};
const startNew = () => {
setSelectedID(null);
setDraft(emptyDraft());
};
const updateDraft = (field: "name" | "description" | "goal", value: string) => {
setDraft((current) => ({ ...current, [field]: value }));
};
const patchDraft = (patch: Partial<TemplateDraft>) => {
setDraft((current) => ({ ...current, ...patch }));
};
async function save() {
const input = {
name: draft.name.trim(),
description: draft.description.trim(),
goal: draft.goal.trim(),
category_id: draft.categoryID,
intercept_rules: draft.interceptRules
.map((r) => ({ ...r, pattern: r.pattern.trim() }))
.filter((r) => r.pattern !== ""),
};
if (!input.name || !input.description || !input.goal) {
toast.error(t("manager.validation"));
return;
}
setSaving(true);
try {
if (selectedID == null) {
const created = await api.createTaskTemplate(input);
onCreated(created);
setSelectedID(created.id);
setDraft(templateDraft(created));
toast.success(t("manager.created"));
} else {
const updated = await api.updateTaskTemplate(selectedID, input);
onUpdated(updated);
setDraft(templateDraft(updated));
toast.success(t("manager.updated"));
}
} catch (error) {
toast.error(t("manager.saveError", { error: (error as Error).message }));
} finally {
setSaving(false);
}
}
async function remove() {
if (selectedID == null) return;
const deletedID = selectedID;
setDeleting(true);
try {
await api.deleteTaskTemplate(deletedID);
onDeleted(deletedID);
const next = templates.find((template) => template.id !== deletedID);
if (next) {
setSelectedID(next.id);
setDraft(templateDraft(next));
} else {
startNew();
}
setDeleteOpen(false);
toast.success(t("manager.deleted"));
} catch (error) {
toast.error(t("manager.deleteError", { error: (error as Error).message }));
} finally {
setDeleting(false);
}
}
let saveLabel = saving ? t("manager.saving") : t("manager.saveChanges");
if (!saving && selectedID == null) saveLabel = t("manager.create");
return (
<>
<Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent className="grid h-full w-full! max-w-none! grid-rows-[auto_minmax(0,1fr)_auto] gap-0 overflow-hidden p-0 sm:w-[48rem]! sm:max-w-[48rem]!">
<SheetHeader className="border-b px-6 py-5">
<SheetTitle>{t("manager.title")}</SheetTitle>
<SheetDescription>{t("manager.description")}</SheetDescription>
</SheetHeader>
<div className="grid min-h-0 overflow-y-auto lg:grid-cols-[15rem_minmax(0,1fr)] lg:overflow-hidden">
<div className="flex min-h-0 flex-col border-b p-3 lg:border-r lg:border-b-0">
<Button type="button" variant="outline" className="w-full" onClick={startNew}>
<PlusIcon data-icon="inline-start" />
{t("manager.new")}
</Button>
<ScrollArea className="mt-2 max-h-44 lg:max-h-none lg:flex-1">
<div className="flex flex-col gap-1 pr-2">
{templates.length === 0 && (
<p className="px-2 py-6 text-center text-muted-foreground text-sm">{t("manager.noTemplates")}</p>
)}
{templates.map((template) => (
<button
key={template.id}
type="button"
className={cn(
"min-w-0 rounded-md px-2.5 py-2 text-left transition-colors",
selectedID === template.id ? "bg-accent text-accent-foreground" : "hover:bg-accent/50",
)}
onClick={() => selectTemplate(template)}
>
<span className="block truncate font-medium text-sm">{template.name}</span>
<span className="block truncate text-muted-foreground text-xs">{template.description}</span>
</button>
))}
</div>
</ScrollArea>
</div>
<ScrollArea className="min-h-0">
<FieldGroup className="p-6">
<Field>
<FieldLabel htmlFor="task-template-name">{t("manager.nameLabel")}</FieldLabel>
<Input
id="task-template-name"
value={draft.name}
maxLength={120}
placeholder={t("manager.namePlaceholder")}
onChange={(event) => updateDraft("name", event.target.value)}
/>
</Field>
<Field>
<FieldLabel htmlFor="task-template-description">{t("manager.descriptionLabel")}</FieldLabel>
<Textarea
id="task-template-description"
className="min-h-28"
value={draft.description}
placeholder={t("manager.descriptionPlaceholder")}
onChange={(event) => updateDraft("description", event.target.value)}
/>
</Field>
<Field>
<FieldLabel htmlFor="task-template-goal">{t("manager.goalLabel")}</FieldLabel>
<Textarea
id="task-template-goal"
className="min-h-28"
value={draft.goal}
placeholder={t("manager.goalPlaceholder")}
onChange={(event) => updateDraft("goal", event.target.value)}
/>
</Field>
<Field>
<FieldLabel htmlFor="task-template-category">{t("manager.categoryLabel")}</FieldLabel>
<NativeSelect
id="task-template-category"
className="w-full"
value={draft.categoryID == null ? "" : String(draft.categoryID)}
onChange={(event) =>
patchDraft({ categoryID: event.target.value === "" ? null : Number(event.target.value) })
}
>
<NativeSelectOption value="">{t("manager.categoryUnclassified")}</NativeSelectOption>
{categories.map((c) => (
<NativeSelectOption key={c.id} value={String(c.id)}>
{c.name}
</NativeSelectOption>
))}
</NativeSelect>
<FieldDescription>{t("manager.categoryHint")}</FieldDescription>
</Field>
<Field>
<FieldLabel>{t("manager.rulesLabel")}</FieldLabel>
<AssetInterceptRulesEditor
value={draft.interceptRules}
onChange={(rules) => patchDraft({ interceptRules: rules })}
/>
<FieldDescription>{t("manager.rulesHint")}</FieldDescription>
</Field>
</FieldGroup>
</ScrollArea>
</div>
<SheetFooter className="border-t px-6 py-4 sm:flex-row sm:items-center">
{selectedID != null && (
<Button type="button" variant="destructive" className="sm:mr-auto" onClick={() => setDeleteOpen(true)}>
<Trash2Icon data-icon="inline-start" />
{t("manager.delete")}
</Button>
)}
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
{t("manager.close")}
</Button>
<Button type="button" disabled={saving} onClick={() => void save()}>
{saving ? <Spinner data-icon="inline-start" /> : <SaveIcon data-icon="inline-start" />}
{saveLabel}
</Button>
</SheetFooter>
</SheetContent>
</Sheet>
<AlertDialog open={deleteOpen} onOpenChange={setDeleteOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
{t("deleteDialog.title", { name: draft.name || t("deleteDialog.untitled") })}
</AlertDialogTitle>
<AlertDialogDescription>{t("deleteDialog.description")}</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={deleting}>{t("deleteDialog.cancel")}</AlertDialogCancel>
<AlertDialogAction
variant="destructive"
disabled={deleting}
onClick={(event) => {
event.preventDefault();
void remove();
}}
>
{deleting && <Spinner data-icon="inline-start" />}
{deleting ? t("deleteDialog.deleting") : t("deleteDialog.delete")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
);
}
interface TaskTemplateControlsProps {
description: string;
goal: string;
categoryID?: number;
interceptRules?: AssetInterceptRuleInput[];
selectedTemplateID: number | null;
onSelectedTemplateIDChange: (id: number | null) => void;
onApply: (template: TaskTemplate) => void;
portalContainer?: React.RefObject<HTMLElement | null>;
}
export function TaskTemplateControls({
description,
goal,
categoryID,
interceptRules,
selectedTemplateID,
onSelectedTemplateIDChange,
onApply,
portalContainer,
}: TaskTemplateControlsProps) {
const t = useTranslations("taskTemplate");
const [templates, setTemplates] = React.useState<TaskTemplate[]>([]);
const [loading, setLoading] = React.useState(true);
const [templateInputValue, setTemplateInputValue] = React.useState("");
const [pendingTemplate, setPendingTemplate] = React.useState<TaskTemplate | null>(null);
const [managerOpen, setManagerOpen] = React.useState(false);
const [managerSeed, setManagerSeed] = React.useState<TemplateSeed | null>(null);
const loadTemplates = React.useCallback(async () => {
setLoading(true);
try {
setTemplates(await api.taskTemplates());
} catch (error) {
setTemplates([]);
toast.error(t("picker.loadError", { error: (error as Error).message }));
} finally {
setLoading(false);
}
}, [t]);
React.useEffect(() => {
void loadTemplates();
}, [loadTemplates]);
const selectedTemplate = React.useMemo(
() => templates.find((template) => template.id === selectedTemplateID) ?? null,
[selectedTemplateID, templates],
);
React.useEffect(() => {
setTemplateInputValue(selectedTemplate?.name ?? "");
}, [selectedTemplate]);
const applyTemplate = (template: TaskTemplate) => {
onApply(template);
onSelectedTemplateIDChange(template.id);
setPendingTemplate(null);
};
const chooseTemplate = (template: TaskTemplate | null) => {
if (!template) {
setTemplateInputValue("");
onSelectedTemplateIDChange(null);
return;
}
setTemplateInputValue(template.name);
const hasContent = description.trim() !== "" || goal.trim() !== "";
const changesContent = description !== template.description || goal !== template.goal;
if (hasContent && changesContent) {
setPendingTemplate(template);
return;
}
applyTemplate(template);
};
const openManager = (seed: TemplateSeed | null) => {
setManagerSeed(seed);
setManagerOpen(true);
};
const upsertTemplate = (template: TaskTemplate) => {
setTemplates((current) => [template, ...current.filter((item) => item.id !== template.id)]);
};
const deleteTemplate = (id: number) => {
setTemplates((current) => current.filter((template) => template.id !== id));
if (selectedTemplateID === id) onSelectedTemplateIDChange(null);
};
let pickerPlaceholder = loading ? t("picker.loading") : t("picker.empty");
if (!loading && templates.length > 0) pickerPlaceholder = t("picker.search");
return (
<>
<Field>
<div className="flex flex-wrap items-center justify-between gap-2">
<FieldLabel htmlFor="task-template-picker">{t("picker.label")}</FieldLabel>
<div className="flex flex-wrap gap-2">
<Button type="button" variant="ghost" size="sm" onClick={() => openManager(null)}>
<Settings2Icon data-icon="inline-start" />
{t("picker.manage")}
</Button>
<Button
type="button"
variant="ghost"
size="sm"
disabled={!description.trim() || !goal.trim()}
onClick={() =>
openManager({
description,
goal,
categoryID: categoryID ?? null,
interceptRules: interceptRules ?? [],
})
}
>
<SaveIcon data-icon="inline-start" />
{t("picker.saveAs")}
</Button>
</div>
</div>
<Combobox
items={templates}
itemToStringLabel={(template) => template.name}
itemToStringValue={(template) => String(template.id)}
inputValue={templateInputValue}
onInputValueChange={(value) => setTemplateInputValue(value)}
value={selectedTemplate}
onValueChange={chooseTemplate}
>
<ComboboxInput
id="task-template-picker"
className="w-full"
placeholder={pickerPlaceholder}
disabled={loading || templates.length === 0}
showClear
/>
<ComboboxContent portalContainer={portalContainer}>
<ComboboxEmpty>{t("picker.noMatch")}</ComboboxEmpty>
<ComboboxList>
{(template) => (
<ComboboxItem key={template.id} value={template}>
<LibraryIcon />
<div className="min-w-0 flex-1">
<p className="truncate font-medium text-sm">{template.name}</p>
{template.description && (
<p className="truncate text-muted-foreground text-xs">{template.description}</p>
)}
</div>
</ComboboxItem>
)}
</ComboboxList>
</ComboboxContent>
</Combobox>
<FieldDescription>{t("picker.hint")}</FieldDescription>
</Field>
<AlertDialog
open={pendingTemplate != null}
onOpenChange={(open) => {
if (open) return;
setPendingTemplate(null);
setTemplateInputValue(selectedTemplate?.name ?? "");
}}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{t("apply.title", { name: pendingTemplate?.name ?? "" })}</AlertDialogTitle>
<AlertDialogDescription>{t("apply.description")}</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>{t("apply.cancel")}</AlertDialogCancel>
<AlertDialogAction onClick={() => pendingTemplate && applyTemplate(pendingTemplate)}>
{t("apply.confirm")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
<TaskTemplateManager
open={managerOpen}
onOpenChange={setManagerOpen}
templates={templates}
seed={managerSeed}
onCreated={upsertTemplate}
onUpdated={upsertTemplate}
onDeleted={deleteTemplate}
/>
</>
);
}
+90
View File
@@ -0,0 +1,90 @@
"use client";
import * as React from "react";
import { ListTodo } from "lucide-react";
import { useTranslations } from "next-intl";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { cn } from "@/lib/utils";
// TodoPopover shows the latest TodoWrite state of a session (chat conversation or
// task worker/planner replay). The full todo list isn't in the activity list
// (detail is lazy), so on open it fetches the detail of the most-recent TodoWrite
// call — by seq — and parses its {todos:[…]} JSON. Purely client-side; the caller
// supplies the seq + a detail fetcher (conversation or exploration endpoint).
export function TodoPopover({
seq,
fetchDetail,
}: {
seq: number | null;
fetchDetail: (seq: number) => Promise<string>;
}) {
const tr = useTranslations("todoPopover");
const [open, setOpen] = React.useState(false);
const [todos, setTodos] = React.useState<{ content: string; status: string }[] | null>(null);
const [loading, setLoading] = React.useState(false);
const [err, setErr] = React.useState("");
const load = React.useCallback(async () => {
if (seq == null) return;
setLoading(true);
setErr("");
try {
const detail = await fetchDetail(seq);
const start = detail.indexOf("{"); // detail 可能带 "TodoWrite " 前缀
const parsed = JSON.parse(start >= 0 ? detail.slice(start) : detail);
setTodos(Array.isArray(parsed?.todos) ? parsed.todos : []);
} catch {
setErr(tr("parseError"));
setTodos(null);
} finally {
setLoading(false);
}
}, [seq, fetchDetail, tr]);
// refetch on each open — todos change as the run progresses.
React.useEffect(() => {
if (open) void load();
}, [open, load]);
const disabled = seq == null;
const MARK: Record<string, string> = { pending: "☐", in_progress: "▶", completed: "✔" };
return (
<Popover open={open} onOpenChange={disabled ? undefined : setOpen}>
<PopoverTrigger asChild>
<button
type="button"
disabled={disabled}
title={disabled ? tr("none") : tr("view")}
className="text-muted-foreground/70 hover:text-primary flex items-center gap-0.5 text-xs disabled:pointer-events-none disabled:opacity-40"
>
<ListTodo className="size-3" />
Todo
</button>
</PopoverTrigger>
<PopoverContent align="end" className="max-h-80 w-80 overflow-auto p-2">
<p className="text-muted-foreground px-1 pb-1 text-[11px] font-medium">
{tr("recent")}
{loading ? tr("loadingSuffix") : ""}
</p>
{err && <p className="text-destructive px-1 text-xs">{err}</p>}
{todos && todos.length === 0 && !loading && <p className="text-muted-foreground px-1 text-xs">{tr("empty")}</p>}
<ul className="space-y-0.5">
{(todos ?? []).map((t, i) => (
<li
key={`${i}:${t.content}`}
className={cn(
"flex gap-1.5 px-1 text-xs",
t.status === "completed" && "text-muted-foreground line-through",
)}
>
<span className="shrink-0">{MARK[t.status] ?? "☐"}</span>
<span className="break-words">{t.content}</span>
</li>
))}
</ul>
</PopoverContent>
</Popover>
);
}
@@ -0,0 +1,185 @@
"use client";
import * as React from "react";
import { useTranslations } from "next-intl";
import { toast } from "sonner";
import { HttpCodeBlock } from "@/components/http-code-block";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { Button } from "@/components/ui/button";
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { Skeleton } from "@/components/ui/skeleton";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { api } from "@/lib/api";
import type { FindingTrafficDetail, TrafficDetail } from "@/lib/types";
export function TrafficEvidenceViewer({
findingId,
bindingId,
contextTask,
onClose,
}: {
findingId: string;
bindingId: string | null;
contextTask?: string;
onClose: () => void;
}) {
const t = useTranslations("trafficEvidence");
const [detail, setDetail] = React.useState<FindingTrafficDetail | null>(null);
const [error, setError] = React.useState("");
const [busy, setBusy] = React.useState(false);
React.useEffect(() => {
if (!bindingId) return;
let active = true;
setDetail(null);
setError("");
api
.findingTrafficDetail(findingId, bindingId, contextTask)
.then((d) => {
if (active) setDetail(d);
})
.catch((e: Error) => {
if (active) setError(e.message);
});
return () => {
active = false;
};
}, [findingId, bindingId, contextTask]);
async function more(side: "request" | "response") {
if (!detail || !bindingId) return;
setBusy(true);
try {
const next = await api.findingTrafficBody(findingId, bindingId, side, detail[side].next_offset, contextTask);
setDetail((current) =>
current?.binding.id === bindingId
? { ...current, [side]: { ...next, content: current[side].content + next.content } }
: current,
);
} catch (e) {
toast.error((e as Error).message);
} finally {
setBusy(false);
}
}
return (
<Dialog
open={bindingId !== null}
onOpenChange={(open) => {
if (!open) onClose();
}}
>
<DialogContent className="max-h-[90vh] overflow-y-auto sm:max-w-4xl">
<DialogHeader>
<DialogTitle>{t("viewer.title", { id: bindingId ?? "" })}</DialogTitle>
<DialogDescription className="break-all">
{detail?.binding.snapshot.url ?? t("viewer.fallbackDesc")}
</DialogDescription>
</DialogHeader>
{error ? (
<Alert variant="destructive">
<AlertDescription>{error}</AlertDescription>
</Alert>
) : detail ? (
<Tabs defaultValue="request">
<TabsList>
<TabsTrigger value="request">{t("viewer.request")}</TabsTrigger>
<TabsTrigger value="response">{t("viewer.response")}</TabsTrigger>
</TabsList>
{(["request", "response"] as const).map((side) => (
<TabsContent key={side} value={side}>
<div className="flex flex-wrap items-center justify-between gap-2">
<span className="text-xs text-muted-foreground">
{t("viewer.bodyBytes", { bytes: detail[side].total.toLocaleString() })}
{detail[side].truncated ? t("viewer.previewSuffix") : ""}
</span>
<Button
variant="outline"
size="sm"
onClick={() =>
void api
.downloadFindingTrafficBody(findingId, detail.binding.id, side, contextTask)
.catch((e: Error) => toast.error(e.message))
}
>
{side === "request" ? t("viewer.downloadRequest") : t("viewer.downloadResponse")}
</Button>
</div>
<HttpCodeBlock
raw={`${(side === "request" ? detail.binding.snapshot.req_head : detail.binding.snapshot.resp_head)?.trimEnd() ?? ""}\n\n${detail[side].content}`}
/>
{detail[side].truncated && !detail[side].binary ? (
<Button variant="outline" size="sm" disabled={busy} onClick={() => void more(side)}>
{t("viewer.loadMore")}
</Button>
) : null}
</TabsContent>
))}
</Tabs>
) : (
<Skeleton className="h-56 w-full" />
)}
</DialogContent>
</Dialog>
);
}
export function CapturedTrafficViewer({ id, onClose }: { id: string | null; onClose: () => void }) {
const t = useTranslations("trafficEvidence");
const [detail, setDetail] = React.useState<TrafficDetail | null>(null);
const [error, setError] = React.useState("");
React.useEffect(() => {
if (!id) return;
let active = true;
setDetail(null);
setError("");
api
.trafficExchange(id)
.then((d) => {
if (active) setDetail(d);
})
.catch((e: Error) => {
if (active) setError(e.message);
});
return () => {
active = false;
};
}, [id]);
return (
<Dialog
open={id !== null}
onOpenChange={(open) => {
if (!open) onClose();
}}
>
<DialogContent className="max-h-[90vh] overflow-y-auto sm:max-w-4xl">
<DialogHeader>
<DialogTitle>{t("captured.title")}</DialogTitle>
<DialogDescription>{t("captured.description", { id: id ?? "" })}</DialogDescription>
</DialogHeader>
{error ? (
<Alert variant="destructive">
<AlertDescription>{error}</AlertDescription>
</Alert>
) : detail ? (
<Tabs defaultValue="request">
<TabsList>
<TabsTrigger value="request">{t("viewer.request")}</TabsTrigger>
<TabsTrigger value="response">{t("viewer.response")}</TabsTrigger>
</TabsList>
<TabsContent value="request">
<HttpCodeBlock raw={detail.req} />
</TabsContent>
<TabsContent value="response">
<HttpCodeBlock raw={detail.resp} />
</TabsContent>
</Tabs>
) : (
<Skeleton className="h-56 w-full" />
)}
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,277 @@
"use client";
import * as React from "react";
import { useTranslations } from "next-intl";
import { toast } from "sonner";
import { CapturedTrafficViewer } from "@/components/traffic-evidence-viewer";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Field, FieldGroup, FieldLabel } from "@/components/ui/field";
import { Input } from "@/components/ui/input";
import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { api } from "@/lib/api";
import type { FindingTrafficBinding, TrafficResp } from "@/lib/types";
const METHODS = ["GET", "POST", "PUT", "DELETE", "PATCH", "HEAD", "OPTIONS"];
export function TrafficPickerDialog({
findingId,
contextTask,
bound,
onClose,
onBound,
}: {
findingId: string;
contextTask?: string;
bound: FindingTrafficBinding[];
onClose: () => void;
onBound: () => void;
}) {
const t = useTranslations("trafficEvidence");
const [host, setHost] = React.useState("");
const [method, setMethod] = React.useState("all");
const [query, setQuery] = React.useState("");
const [page, setPage] = React.useState(0);
const [data, setData] = React.useState<TrafficResp | null>(null);
const [selected, setSelected] = React.useState<Set<string>>(() => new Set());
const [preview, setPreview] = React.useState<string | null>(null);
const [loading, setLoading] = React.useState(true);
const [busy, setBusy] = React.useState(false);
const [error, setError] = React.useState("");
const alreadyBound = new Set(bound.map((b) => b.snapshot.source_traffic_id));
React.useEffect(() => {
let active = true;
setLoading(true);
setError("");
const timer = setTimeout(() => {
api
.traffic(page, 25, host, method === "all" ? "" : method, query)
.then((d) => {
if (active) setData(d);
})
.catch((e: Error) => {
if (active) setError(e.message);
})
.finally(() => {
if (active) setLoading(false);
});
}, 250);
return () => {
active = false;
clearTimeout(timer);
};
}, [page, host, method, query]);
const rows = data?.exchanges ?? [];
const selectable = rows.filter((e) => !alreadyBound.has(e.id));
function toggle(id: string, checked: boolean) {
setSelected((previous) => {
const next = new Set(previous);
if (checked) next.add(id);
else next.delete(id);
return next;
});
}
async function save() {
setBusy(true);
setError("");
try {
await api.bindFindingTraffic(
findingId,
[...selected].map((traffic_id) => ({ traffic_id })),
contextTask,
);
toast.success(t("picker.boundToast", { count: selected.size }));
onBound();
onClose();
} catch (e) {
setError((e as Error).message);
} finally {
setBusy(false);
}
}
return (
<>
<Dialog
open
onOpenChange={(open) => {
if (!open && !busy) onClose();
}}
>
<DialogContent className="max-h-[90vh] overflow-y-auto sm:max-w-5xl">
<DialogHeader>
<DialogTitle>{t("picker.title")}</DialogTitle>
<DialogDescription>{t("picker.description")}</DialogDescription>
</DialogHeader>
<FieldGroup className="flex flex-col gap-3 sm:flex-row">
<Field>
<FieldLabel htmlFor="evidence-host">{t("picker.host")}</FieldLabel>
<Input
id="evidence-host"
value={host}
placeholder={t("picker.hostPlaceholder")}
onChange={(e) => {
setHost(e.target.value);
setPage(0);
}}
/>
</Field>
<Field>
<FieldLabel htmlFor="evidence-method">{t("picker.method")}</FieldLabel>
<Select
value={method}
onValueChange={(v) => {
setMethod(v);
setPage(0);
}}
>
<SelectTrigger id="evidence-method">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectItem value="all">{t("picker.allMethods")}</SelectItem>
{METHODS.map((m) => (
<SelectItem key={m} value={m}>
{m}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
</Field>
<Field>
<FieldLabel htmlFor="evidence-query">{t("picker.keyword")}</FieldLabel>
<Input
id="evidence-query"
value={query}
placeholder={t("picker.keywordPlaceholder")}
onChange={(e) => {
setQuery(e.target.value);
setPage(0);
}}
/>
</Field>
</FieldGroup>
{error ? (
<Alert variant="destructive">
<AlertDescription>{error}</AlertDescription>
</Alert>
) : null}
<div className="max-h-[45vh] overflow-auto rounded-md border" aria-busy={loading}>
<Table>
<TableHeader>
<TableRow>
<TableHead>
<Checkbox
aria-label={t("picker.selectPage")}
disabled={loading || busy || selectable.length === 0}
checked={selectable.length > 0 && selectable.every((e) => selected.has(e.id))}
onCheckedChange={(checked) =>
setSelected((previous) => {
const next = new Set(previous);
for (const e of selectable) {
if (checked === true) next.add(e.id);
else next.delete(e.id);
}
return next;
})
}
/>
</TableHead>
<TableHead>{t("picker.colMethodUrl")}</TableHead>
<TableHead>{t("picker.colTime")}</TableHead>
<TableHead>{t("picker.colStatus")}</TableHead>
<TableHead>{t("picker.colAction")}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{rows.map((e) => (
<TableRow key={e.id}>
<TableCell>
<Checkbox
aria-label={t("picker.selectOne", { id: e.id })}
checked={selected.has(e.id) || alreadyBound.has(e.id)}
disabled={busy || loading || alreadyBound.has(e.id)}
onCheckedChange={(checked) => toggle(e.id, checked === true)}
/>
</TableCell>
<TableCell>
<span className="font-mono text-xs">
{e.method} {e.url}
</span>
{alreadyBound.has(e.id) ? <Badge variant="secondary">{t("picker.bound")}</Badge> : null}
</TableCell>
<TableCell className="whitespace-nowrap text-xs">
{new Date(e.ts).toLocaleString("ko-KR")}
</TableCell>
<TableCell>{e.status}</TableCell>
<TableCell>
<Button variant="ghost" size="sm" onClick={() => setPreview(e.id)}>
{t("picker.preview")}
</Button>
</TableCell>
</TableRow>
))}
{!rows.length ? (
<TableRow>
<TableCell colSpan={5} className="py-8 text-center">
{loading ? t("picker.loading") : t("picker.noMatch")}
</TableCell>
</TableRow>
) : null}
</TableBody>
</Table>
</div>
<div className="flex flex-wrap items-center justify-between gap-2">
<span className="text-sm">
{t("picker.selectedSummary", { selected: selected.size, total: data?.total ?? 0 })}
</span>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
disabled={loading || page === 0}
onClick={() => setPage((p) => p - 1)}
>
{t("picker.prevPage")}
</Button>
<span className="text-xs">{t("picker.pageNo", { page: page + 1 })}</span>
<Button
variant="outline"
size="sm"
disabled={loading || (page + 1) * 25 >= (data?.total ?? 0)}
onClick={() => setPage((p) => p + 1)}
>
{t("picker.nextPage")}
</Button>
</div>
</div>
<DialogFooter>
<Button variant="outline" disabled={busy} onClick={onClose}>
{t("picker.cancel")}
</Button>
<Button disabled={busy || selected.size === 0} onClick={() => void save()}>
{busy ? t("picker.saving") : t("picker.bindCount", { count: selected.size })}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<CapturedTrafficViewer id={preview} onClose={() => setPreview(null)} />
</>
);
}
+802
View File
@@ -0,0 +1,802 @@
"use client";
import * as React from "react";
import {
CheckIcon,
ChevronDown,
ChevronRight,
CrosshairIcon,
Flag,
MessageSquare,
PaperclipIcon,
ShieldAlertIcon,
Terminal,
UserIcon,
Wrench,
XIcon,
} from "lucide-react";
import { useTranslations } from "next-intl";
import { toast } from "sonner";
import { ApprovalDetail } from "@/components/approval-records";
import { Markdown } from "@/components/markdown";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
import { api } from "@/lib/api";
import type { Activity, InterceptPending } from "@/lib/types";
// ---- per-agent lane color (planner + work#1/#2/#3 …) ---------------------------
const workerColors = ["bg-sky-600", "bg-violet-600", "bg-teal-600", "bg-pink-600", "bg-orange-600"];
function workerColor(name: string): string {
if (name === "planner") return "bg-amber-600"; // the intent generator, distinct
if (name === "mainagent") return "bg-primary";
const m = /#(\d+)/.exec(name);
const i = m ? (parseInt(m[1], 10) - 1) % workerColors.length : 0;
return workerColors[Math.max(0, i)];
}
const chip = (worker: string) =>
"mt-0.5 shrink-0 rounded px-1 text-[9px] font-medium text-white " + workerColor(worker);
// useInView latches true when the ref'd element first comes within `rootMargin` of
// the enclosing scroll viewport. Blocks that always show their full body (user
// bubbles, answers) use it to defer fetching that body until they're about to be
// seen — so opening a long thread doesn't fire a detail request for every off-
// screen step. Observes the ScrollArea viewport (falls back to eager load when
// IntersectionObserver is unavailable, e.g. SSR).
function useInView(rootMargin = "400px"): [React.RefObject<HTMLDivElement | null>, boolean] {
const ref = React.useRef<HTMLDivElement | null>(null);
const [inView, setInView] = React.useState(false);
React.useEffect(() => {
if (inView) return; // latch: once seen, stop observing
const el = ref.current;
if (!el) return;
if (typeof IntersectionObserver === "undefined") {
setInView(true);
return;
}
const root = el.closest('[data-slot="scroll-area-viewport"]') as HTMLElement | null;
const ob = new IntersectionObserver(
(entries) => {
if (entries.some((e) => e.isIntersecting)) setInView(true);
},
{ root, rootMargin },
);
ob.observe(el);
return () => ob.disconnect();
}, [inView, rootMargin]);
return [ref, inView];
}
// A tool group pairs a tool_use with its matching tool_result (by tool_use_id);
// a run of consecutive conversational steps (text/thinking/result) from the same
// agent is one "message".
type Group =
| { type: "user"; key: number; step: Activity; intent?: boolean }
| { type: "answer"; key: number; step: Activity }
| { type: "round"; key: number; label: string }
| { type: "tool"; key: number; worker: string; use?: Activity; result?: Activity }
| { type: "msg"; key: number; worker: string; steps: Activity[] }
| { type: "intercept"; key: number; step: Activity };
function groupSteps(steps: Activity[], chat: boolean): Group[] {
const out: Group[] = [];
const byToolId = new Map<string, Extract<Group, { type: "tool" }>>();
for (const s of steps) {
if (s.kind === "usage") continue; // live token-usage marker — not a rendered step
if (s.kind === "round") {
out.push({ type: "round", key: s.seq, label: s.summary || "" }); // planner round boundary (empty → localized fallback at render)
continue;
}
if (s.kind === "intercept_request") {
out.push({ type: "intercept", key: s.seq, step: s });
continue;
}
if (s.kind === "user" || s.kind === "intent") {
// human turn OR the LLM-generated intent leading a worker session — both are
// right-aligned bubbles; `intent` swaps the avatar to a non-human icon.
out.push({ type: "user", key: s.seq, step: s, intent: s.kind === "intent" });
continue;
}
// In a chat (main agent) the assistant's text/result IS the answer — render it
// full (markdown), never collapsed. thinking still folds into a compact block.
if (s.kind === "result" || (chat && s.kind === "text")) {
out.push({ type: "answer", key: s.seq, step: s });
continue;
}
if (s.kind === "tool_use") {
const g: Extract<Group, { type: "tool" }> = { type: "tool", key: s.seq, worker: s.worker, use: s };
if (s.tool_use_id) byToolId.set(s.tool_use_id, g);
out.push(g);
continue;
}
if (s.kind === "tool_result") {
// bind to its tool_use by id (NOT adjacency — tools can run in parallel)
const g = s.tool_use_id ? byToolId.get(s.tool_use_id) : undefined;
if (g && !g.result) g.result = s;
else out.push({ type: "tool", key: s.seq, worker: s.worker, result: s }); // orphan result
continue;
}
const last = out[out.length - 1];
if (last && last.type === "msg" && last.worker === s.worker) last.steps.push(s);
else out.push({ type: "msg", key: s.seq, worker: s.worker, steps: [s] });
}
return out;
}
// maps a step kind to its translation sub-key under transcript.kind.* (label resolved at render)
const kindKey = (k: string) => (k === "thinking" ? "thinking" : k === "result" ? "result" : "note");
function ActivityTime({ ts }: { ts: string }) {
const date = new Date(ts);
if (!ts || Number.isNaN(date.getTime())) return null;
return (
<time
dateTime={date.toISOString()}
title={date.toLocaleString("ko-KR")}
className="text-[10px] text-muted-foreground tabular-nums"
suppressHydrationWarning
>
{date.toLocaleString("ko-KR", {
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
hour12: false,
})}
</time>
);
}
// toolInputText renders a tool_use input for display. For Bash it pulls the shell
// command out of the raw input JSON ({"command":…,"description":…}) so the UI shows
// the command itself instead of JSON; other tools fall back to the raw text.
function toolInputText(tool: string, raw: string): string {
if (tool !== "Bash") return raw;
// full input (expanded detail): parse and pull the command out.
try {
const o = JSON.parse(raw);
if (o && typeof o.command === "string") return o.command;
} catch {
// collapsed-row summaries are truncated to ~200 chars by the backend, so the
// JSON tail is cut off and JSON.parse fails — fall through and extract the
// "command" field by hand, tolerating the missing closing quote.
}
const m = raw.match(/"command"\s*:\s*"((?:\\.|[^"\\])*)/);
if (m) {
try {
// re-wrap the captured body and parse to unescape \n, \", \\, etc.
return JSON.parse('"' + m[1] + '"');
} catch {
// truncated mid-escape — unescape the common sequences best-effort.
return m[1].replace(/\\(["\\/nrt])/g, (_s, c) => (c === "n" ? "\n" : c === "r" ? "\r" : c === "t" ? "\t" : c));
}
}
return raw;
}
// InterceptCard renders an inline intercept_request approval card. The pending_id
// is extracted from the summary (format: "도구 X 승인 요청 (#N)") so buttons are
// available immediately without waiting for the detail load.
function InterceptCard({ step, getDetail }: { step: Activity; getDetail: (seq: number) => Promise<string> }) {
const t = useTranslations("transcript");
// extract pending_id from summary: "도구 Bash 승인 요청 (#42)"
const pendingId = React.useMemo(() => {
const m = /\(#(\d+)\)/.exec(step.summary);
return m ? parseInt(m[1], 10) : null;
}, [step.summary]);
const toolName = React.useMemo(() => {
const m = /도구\s+(\S+)\s+승인/.exec(step.summary);
return m ? m[1] : step.summary;
}, [step.summary]);
const [detail, setDetail] = React.useState<Record<string, unknown> | null>(null);
const [decided, setDecided] = React.useState<"allowed" | "denied" | "timeout" | null>(null);
const [deciding, setDeciding] = React.useState(false);
const [expanded, setExpanded] = React.useState(false);
const [pending, setPending] = React.useState<InterceptPending | null>(null);
const [statusError, setStatusError] = React.useState("");
const [retry, setRetry] = React.useState(0);
// Load persisted detail JSON + check the real current status from the backend
// so that a page refresh shows the already-decided state instead of re-offering buttons.
React.useEffect(() => {
let live = true;
getDetail(step.seq)
.then((raw) => {
if (!live || !raw) return;
try {
setDetail(JSON.parse(raw));
} catch {
/* ignore */
}
})
.catch(() => {
/* ignore */
});
return () => {
live = false;
};
}, [step.seq, getDetail]);
// biome-ignore lint/correctness/useExhaustiveDependencies: Retry explicitly reloads the same approval after a failed request.
React.useEffect(() => {
if (!pendingId) return;
let live = true;
api
.interceptGetOne(pendingId)
.then((p) => {
if (!live) return;
setPending(p);
setStatusError("");
if (p.status !== "pending") setDecided(p.status as "allowed" | "denied" | "timeout");
})
.catch((error) => {
if (live) setStatusError((error as Error).message || t("approval.loadError"));
});
return () => {
live = false;
};
}, [pendingId, retry]);
async function decide(decision: "allowed" | "denied") {
if (step.inherited || !pendingId || deciding) return;
setDeciding(true);
try {
await api.interceptDecide(pendingId, decision);
setDecided(decision);
toast.success(decision === "allowed" ? t("approval.allowedToast") : t("approval.deniedToast"));
} catch (e) {
toast.error((e as Error).message);
} finally {
setDeciding(false);
}
}
const inputStr = detail?.input ? JSON.stringify(detail.input).slice(0, 200) : null;
const row = pending
? { ...pending, status: decided || pending.status, conv_title: "", conv_agent_key: "", rule_name: "" }
: null;
return (
<div className="my-2 rounded-lg border border-amber-400/50 bg-amber-50/40 dark:bg-amber-950/15 p-3 text-xs">
<div className="flex flex-wrap items-start justify-between gap-3">
<div className="flex items-start gap-2 min-w-0">
<ShieldAlertIcon className="mt-0.5 h-3.5 w-3.5 shrink-0 text-amber-500" />
<div className="min-w-0 space-y-0.5">
<div className="flex items-center gap-1.5 font-medium">
<span className="text-amber-700 dark:text-amber-400">{t("approval.request")}</span>
<code className="rounded bg-amber-100 dark:bg-amber-900/50 px-1 font-mono text-amber-800 dark:text-amber-300">
{toolName}
</code>
{pendingId && <span className="text-muted-foreground">#{pendingId}</span>}
</div>
{inputStr && <p className="font-mono text-muted-foreground truncate">{inputStr}</p>}
</div>
</div>
{step.inherited ? (
<Badge variant="outline">{t("approval.historyReadonly")}</Badge>
) : decided ? (
<span
className={
"shrink-0 rounded px-2 py-0.5 text-[11px] font-medium " +
(decided === "allowed"
? "bg-emerald-100 text-emerald-700 dark:bg-emerald-900/40 dark:text-emerald-400"
: decided === "timeout"
? "bg-zinc-100 text-zinc-600 dark:bg-zinc-800 dark:text-zinc-400"
: "bg-red-100 text-red-700 dark:bg-red-900/40 dark:text-red-400")
}
>
{decided === "allowed"
? t("approval.allowed")
: decided === "timeout"
? t("approval.timeout")
: t("approval.denied")}
</span>
) : (
<div className="flex shrink-0 gap-1.5">
<Button
size="sm"
className="h-6 px-2 text-xs"
disabled={deciding || !pendingId}
onClick={() => decide("allowed")}
>
<CheckIcon className="h-3 w-3" />
{t("approval.allow")}
</Button>
<Button
size="sm"
variant="destructive"
className="h-6 px-2 text-xs"
disabled={deciding || !pendingId}
onClick={() => decide("denied")}
>
<XIcon className="h-3 w-3" />
{t("approval.deny")}
</Button>
</div>
)}
</div>
{pendingId ? (
<Collapsible open={expanded} onOpenChange={setExpanded} className="mt-2 min-w-0">
<CollapsibleTrigger asChild>
<Button variant="ghost" size="sm" aria-label={expanded ? t("approval.collapse") : t("approval.expand")}>
{expanded ? <ChevronDown data-icon="inline-start" /> : <ChevronRight data-icon="inline-start" />}
{expanded ? t("approval.collapse") : t("approval.expand")}
</Button>
</CollapsibleTrigger>
<CollapsibleContent>
{expanded && row ? (
<ApprovalDetail
row={row}
busy={deciding}
decide={(_id, decision) => decide(decision)}
revision={retry}
readOnly={Boolean(step.inherited)}
defaultExpanded
onResolved={setDecided}
/>
) : statusError ? (
<div className="flex flex-wrap items-center gap-2 p-3" role="alert">
<span>{statusError}</span>
<Button variant="outline" size="sm" onClick={() => setRetry((value) => value + 1)}>
{t("approval.retry")}
</Button>
</div>
) : (
<p className="p-3 text-muted-foreground">{t("approval.loading")}</p>
)}
</CollapsibleContent>
</Collapsible>
) : null}
</div>
);
}
// ToolBlock renders ONE tool call: the command and its result bound into a single
// row (collapsed shows the command + a status/result preview; expand shows the
// full input AND output together). Lazy-loads both details on expand.
function ToolBlock({
group,
getDetail,
showWorker,
focused = false,
}: {
group: Extract<Group, { type: "tool" }>;
getDetail: (seq: number) => Promise<string>;
showWorker?: boolean;
focused?: boolean;
}) {
const t = useTranslations("transcript");
const [open, setOpen] = React.useState(focused);
const targetRef = React.useRef<HTMLElement>(null);
const [detail, setDetail] = React.useState<string | null>(null);
// what we last loaded, keyed by the underlying step seqs. When the tool result
// arrives after we expanded mid-run (command only), this key changes and the
// effect below re-fetches — so the output shows up instead of being cached out.
const loadedKey = React.useRef<string | null>(null);
const { use, result } = group;
const toolName = use?.tool || result?.tool || t("tool.name");
const ToolIcon = toolName === "Bash" ? Terminal : Wrench;
const running = !result;
const ok = !!result && !result.is_error;
const statusTone = running
? "text-muted-foreground"
: ok
? "text-emerald-600 dark:text-emerald-400"
: "text-red-600 dark:text-red-400";
const rawCmd =
use && use.summary.startsWith(toolName) ? use.summary.slice(toolName.length).trimStart() : (use?.summary ?? "");
const cmd = toolInputText(toolName, rawCmd);
// status only — the full result lives behind the expand (output section), not previewed inline
const statusText = running ? t("tool.running") : ok ? "✓" : t("tool.failed");
// key over the seqs we'd load; changes when the result (or command) arrives.
const detailKey = `${use?.seq ?? ""}:${result?.seq ?? ""}`;
React.useEffect(() => {
if (!open || loadedKey.current === detailKey) return;
let live = true;
const segs: { label: string; seq: number; cmd?: boolean }[] = [];
if (use) segs.push({ label: t("tool.command"), seq: use.seq, cmd: true });
if (result) segs.push({ label: t("tool.output") + (result.is_error ? " ✕" : " ✓"), seq: result.seq });
void Promise.all(
segs.map((x) =>
getDetail(x.seq)
.then((d) => d || t("tool.empty"))
.catch(() => t("tool.loadFailed")),
),
).then((parts) => {
if (!live) return;
setDetail(
segs.map((x, i) => `【${x.label}】\n${x.cmd ? toolInputText(toolName, parts[i]) : parts[i]}`).join("\n\n"),
);
loadedKey.current = detailKey;
});
return () => {
live = false;
};
}, [open, detailKey, use, result, getDetail, toolName, t]);
const scrolledRef = React.useRef(false);
React.useEffect(() => {
scrolledRef.current = false;
if (focused) setOpen(true);
}, [focused]);
React.useEffect(() => {
if (!focused || !open || detail === null || scrolledRef.current) return;
const el = targetRef.current;
const viewport = el?.closest('[data-slot="scroll-area-viewport"]');
if (!el || !viewport) return;
scrolledRef.current = true;
const center = () => {
const rect = el.getBoundingClientRect();
const bounds = viewport.getBoundingClientRect();
viewport.scrollTop += rect.top + rect.height / 2 - bounds.top - bounds.height / 2;
};
// User bubbles load their full text lazily. Allow their initial layout to
// settle, but stop anchoring immediately if the user interacts or after 2s.
const observer = new ResizeObserver(center);
observer.observe(el.parentElement ?? el);
observer.observe(viewport);
const stop = () => observer.disconnect();
viewport.addEventListener("wheel", stop, { passive: true, once: true });
viewport.addEventListener("touchstart", stop, { passive: true, once: true });
viewport.addEventListener("pointerdown", stop, { once: true });
const frame = requestAnimationFrame(center);
const timer = setTimeout(stop, 2000);
return () => {
cancelAnimationFrame(frame);
clearTimeout(timer);
stop();
viewport.removeEventListener("wheel", stop);
viewport.removeEventListener("touchstart", stop);
viewport.removeEventListener("pointerdown", stop);
};
}, [focused, open, detail]);
function toggle() {
setOpen((o) => !o);
}
return (
<section
ref={targetRef}
aria-label={focused ? t("tool.pinned", { seq: use?.seq ?? "" }) : undefined}
className={focused ? "rounded-lg border-2 border-primary bg-primary/5 p-3 text-xs" : "text-xs"}
>
<button type="button" onClick={toggle} className="flex w-full items-start gap-2 py-1 text-left hover:bg-muted/40">
<span className="mt-0.5 text-muted-foreground">
{open ? <ChevronDown className="size-3" /> : <ChevronRight className="size-3" />}
</span>
<ToolIcon className={"mt-0.5 size-3.5 shrink-0 " + (running ? "text-sky-600 dark:text-sky-400" : statusTone)} />
{showWorker && <span className={chip(group.worker)}>{group.worker}</span>}
<span className="shrink-0 font-medium text-sky-600 dark:text-sky-400">{toolName}</span>
{cmd && <span className="min-w-0 flex-1 truncate font-mono text-muted-foreground">{cmd}</span>}
<span className={"ml-auto shrink-0 font-medium " + statusTone}>{statusText}</span>
</button>
{open && (
<pre className="ml-7 mb-1 max-h-72 overflow-auto whitespace-pre-wrap break-all rounded bg-muted/50 p-2 font-mono text-[11px] leading-relaxed">
{detail ?? t("loading")}
</pre>
)}
</section>
);
}
// MessageBlock renders a coalesced agent message (merged streaming text/thinking/
// result fragments into one block) so the conversation reads as messages, not rows.
function MessageBlock({
group,
getDetail,
showWorker,
}: {
group: Extract<Group, { type: "msg" }>;
getDetail: (seq: number) => Promise<string>;
showWorker?: boolean;
}) {
const t = useTranslations("transcript");
const [open, setOpen] = React.useState(false);
const [detail, setDetail] = React.useState<string | null>(null);
// like ToolBlock: keyed by the group's step seqs so streamed steps arriving
// after an early expand re-fetch instead of being cached out.
const loadedKey = React.useRef<string | null>(null);
const speak = group.steps.filter((s) => s.kind !== "thinking");
const hasThinking = group.steps.some((s) => s.kind === "thinking");
const isError = group.steps.some((s) => s.is_error);
const body = (speak.length ? speak : group.steps).map((s) => s.summary).join(" ") || "…";
const Icon = isError ? Flag : MessageSquare;
const tone = isError ? "text-red-600 dark:text-red-400" : "text-foreground";
const detailKey = group.steps.map((s) => s.seq).join(",");
React.useEffect(() => {
if (!open || loadedKey.current === detailKey) return;
let live = true;
void Promise.all(
group.steps.map((s) =>
getDetail(s.seq)
.then((d) => d || s.summary)
.catch(() => s.summary),
),
).then((parts) => {
if (!live) return;
setDetail(group.steps.map((s, i) => `【${t(`kind.${kindKey(s.kind)}`)}】\n${parts[i]}`).join("\n\n"));
loadedKey.current = detailKey;
});
return () => {
live = false;
};
}, [open, detailKey, group.steps, getDetail, t]);
function toggle() {
setOpen((o) => !o);
}
return (
<div className="text-xs">
<button type="button" onClick={toggle} className="flex w-full items-start gap-2 py-1 text-left hover:bg-muted/40">
<span className="mt-0.5 text-muted-foreground">
{open ? <ChevronDown className="size-3" /> : <ChevronRight className="size-3" />}
</span>
<Icon className={"mt-0.5 size-3.5 shrink-0 " + tone} />
{showWorker && <span className={chip(group.worker)}>{group.worker}</span>}
<span className={"min-w-0 flex-1 truncate " + tone}>
{body}
{hasThinking && <span className="ml-1 text-[10px] text-muted-foreground">· {t("withThinking")}</span>}
</span>
</button>
{open && (
<pre className="ml-7 mb-1 max-h-72 overflow-auto whitespace-pre-wrap break-all rounded bg-muted/50 p-2 font-mono text-[11px] leading-relaxed">
{detail ?? t("loading")}
</pre>
)}
</div>
);
}
// UserRow renders a right-aligned chat bubble: either a human turn (the message you
// sent the main agent) or, in a worker session, the LLM-generated intent that leads
// it (intent=true) — same bubble, but a target icon instead of the human avatar.
// summary is a truncated first line, so the full message is pulled from the detail
// and shown in full (bubble is whitespace-pre-wrap, so long/multi-line text wraps).
// fmtBytes renders a human file size for attachment chips.
function fmtBytes(n: number): string {
if (n >= 1 << 20) return `${(n / (1 << 20)).toFixed(1)} MB`;
if (n >= 1 << 10) return `${(n / (1 << 10)).toFixed(1)} KB`;
return `${n} B`;
}
type MsgAttachment = { name: string; path: string; size: number };
// parseUserBody splits a user turn's body into text + attachments. The backend stores
// Detail as JSON {text, attachments} when files were uploaded, else plain text — so we
// parse defensively and fall back to treating the whole body as text.
function parseUserBody(body: string): { text: string; attachments: MsgAttachment[] } {
if (body.startsWith("{")) {
try {
const p = JSON.parse(body);
if (p && Array.isArray(p.attachments)) {
return { text: typeof p.text === "string" ? p.text : "", attachments: p.attachments };
}
} catch {
/* plain text that merely starts with "{" */
}
}
return { text: body, attachments: [] };
}
function UserRow({
step,
intent,
getDetail,
}: {
step: Activity;
intent?: boolean;
getDetail: (seq: number) => Promise<string>;
}) {
const Icon = intent ? CrosshairIcon : UserIcon;
const [ref, inView] = useInView();
// Optimistic echoes carry their detail inline; persisted rows lazy-load it on scroll.
const inline = step.detail && step.detail.length > 0 ? step.detail : null;
const [full, setFull] = React.useState<string | null>(inline);
React.useEffect(() => {
if (inline) return; // already have the body (optimistic echo)
if (!inView) return; // fetch the full message only when the bubble nears view
let live = true;
getDetail(step.seq)
.then((d) => {
if (live) setFull(d || step.summary);
})
.catch(() => {
if (live) setFull(step.summary);
});
return () => {
live = false;
};
}, [inView, step.seq, getDetail, step.summary, inline]);
const { text, attachments } = parseUserBody(full ?? step.summary);
return (
<div ref={ref} className="mt-3 mb-2 flex min-w-0 justify-end gap-2">
<div className="flex min-w-0 max-w-[85%] flex-col items-end gap-1.5">
{attachments.length > 0 && (
<div className="flex min-w-0 max-w-full flex-wrap justify-end gap-1.5">
{attachments.map((a) => (
<div
key={a.path}
className="flex min-w-0 max-w-full items-center gap-1.5 rounded-md border bg-card px-2 py-1 text-xs shadow-sm"
title={a.path}
>
<PaperclipIcon className="size-3 shrink-0 text-primary" />
<span className="max-w-[180px] truncate font-medium">{a.name}</span>
<span className="shrink-0 text-muted-foreground">{fmtBytes(a.size)}</span>
</div>
))}
</div>
)}
{text && (
<div className="min-w-0 max-w-full whitespace-pre-wrap rounded-lg rounded-tr-sm bg-primary px-3 py-1.5 text-sm text-primary-foreground [overflow-wrap:anywhere]">
{text}
</div>
)}
<ActivityTime ts={step.ts} />
</div>
<div className="mt-0.5 flex size-6 shrink-0 items-center justify-center rounded-full bg-primary/10">
<Icon className="size-3.5 text-primary" />
</div>
</div>
);
}
// AnswerBlock renders the agent's FINAL answer (kind="result") in full — never
// collapsed. The summary is a truncated first line, so the full text is pulled
// from the detail and shown inline.
function AnswerBlock({ step, getDetail }: { step: Activity; getDetail: (seq: number) => Promise<string> }) {
const [ref, inView] = useInView();
const [full, setFull] = React.useState<string | null>(null);
React.useEffect(() => {
if (!inView) return; // fetch the full answer only when it nears view
let live = true;
getDetail(step.seq)
.then((d) => {
if (live) setFull(d || step.summary);
})
.catch(() => {
if (live) setFull(step.summary);
});
return () => {
live = false;
};
}, [inView, step.seq, getDetail, step.summary]);
return (
<div ref={ref} className="mb-2 mt-1 flex min-w-0 flex-col gap-1">
<div
className={
"min-w-0 flex-1 break-words rounded-lg bg-muted px-3 py-2 " +
(step.is_error ? "text-sm text-red-600 dark:text-red-400" : "")
}
>
{step.is_error ? (
<span className="whitespace-pre-wrap">{full ?? step.summary}</span>
) : (
<Markdown text={full ?? step.summary} />
)}
</div>
<ActivityTime ts={step.ts} />
</div>
);
}
// ExecView renders an agent execution replay (planner / worker / main agent) in
// the compact, grouped, expand-to-detail format — thinking, tool calls/results,
// and (for the main agent) the human turns. Worker lane chips show only when the
// view actually mixes agents.
function ExecView({
activity,
taskId,
chat,
fetchDetail,
focusedSeq,
}: {
activity: Activity[];
taskId?: string;
chat?: boolean;
fetchDetail?: (seq: number) => Promise<string>;
focusedSeq?: number;
}) {
const t = useTranslations("transcript");
const showWorker = new Set(activity.map((a) => a.worker)).size > 1;
// default detail fetcher: the task-scoped activity endpoint. The chat page passes
// its own (conversation-scoped) fetcher instead.
const getDetail = React.useCallback(
(seq: number) => (fetchDetail ? fetchDetail(seq) : api.activityDetail(seq, taskId).then((r) => r.detail ?? "")),
[fetchDetail, taskId],
);
return (
<div className="flex flex-col">
{groupSteps(activity, !!chat).map((g) =>
g.type === "round" ? (
<div key={"r" + g.key} className="my-2 flex items-center gap-2 text-[10px] font-medium text-muted-foreground">
<span className="h-px flex-1 bg-border" />
{g.label || t("newRound")}
<span className="h-px flex-1 bg-border" />
</div>
) : g.type === "user" ? (
<UserRow key={"u" + g.key} step={g.step} intent={g.intent} getDetail={getDetail} />
) : g.type === "answer" ? (
<AnswerBlock key={"a" + g.key} step={g.step} getDetail={getDetail} />
) : g.type === "tool" ? (
<ToolBlock
key={"t" + g.key}
group={g}
getDetail={getDetail}
showWorker={showWorker}
focused={focusedSeq != null && g.use?.seq === focusedSeq}
/>
) : g.type === "intercept" ? (
<InterceptCard key={"ic" + g.key} step={g.step} getDetail={getDetail} />
) : (
<MessageBlock key={"m" + g.key} group={g} getDetail={getDetail} showWorker={showWorker} />
),
)}
</div>
);
}
// Transcript renders a session's activity as a compact grouped execution replay:
// human turns, thinking, tool calls (command+result paired), and messages — for
// the main agent (interactive) and worker/planner (read-only) alike.
export function Transcript({
activity,
live,
taskId,
chat,
fetchDetail,
focusedSeq,
}: {
activity: Activity[];
live?: boolean;
taskId?: string;
chat?: boolean;
fetchDetail?: (seq: number) => Promise<string>;
focusedSeq?: number;
}) {
const t = useTranslations("transcript");
const transcriptRef = React.useRef<HTMLDivElement>(null);
const [focusPadding, setFocusPadding] = React.useState(0);
React.useLayoutEffect(() => {
if (focusedSeq == null) {
setFocusPadding(0);
return;
}
const viewport = transcriptRef.current?.closest('[data-slot="scroll-area-viewport"]');
if (!viewport) return;
const measure = () => setFocusPadding(viewport.clientHeight / 2);
measure();
const observer = new ResizeObserver(measure);
observer.observe(viewport);
return () => observer.disconnect();
}, [focusedSeq]);
return (
<div
ref={transcriptRef}
className="flex flex-col gap-1"
style={focusPadding ? { paddingBlock: focusPadding } : undefined}
>
<ExecView activity={activity} taskId={taskId} chat={chat} fetchDetail={fetchDetail} focusedSeq={focusedSeq} />
{live && (
<div className="flex items-center gap-2 pl-2 pt-1 text-xs text-muted-foreground">
<span className="flex gap-1">
<span className="size-1.5 animate-bounce rounded-full bg-blue-500 [animation-delay:-0.3s]" />
<span className="size-1.5 animate-bounce rounded-full bg-blue-500 [animation-delay:-0.15s]" />
<span className="size-1.5 animate-bounce rounded-full bg-blue-500" />
</span>
{t("streaming")}
</div>
)}
</div>
);
}
+81
View File
@@ -0,0 +1,81 @@
"use client"
import * as React from "react"
import { Accordion as AccordionPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
import { ChevronDownIcon, ChevronUpIcon } from "lucide-react"
function Accordion({
className,
...props
}: React.ComponentProps<typeof AccordionPrimitive.Root>) {
return (
<AccordionPrimitive.Root
data-slot="accordion"
className={cn("flex w-full flex-col", className)}
{...props}
/>
)
}
function AccordionItem({
className,
...props
}: React.ComponentProps<typeof AccordionPrimitive.Item>) {
return (
<AccordionPrimitive.Item
data-slot="accordion-item"
className={cn("not-last:border-b", className)}
{...props}
/>
)
}
function AccordionTrigger({
className,
children,
...props
}: React.ComponentProps<typeof AccordionPrimitive.Trigger>) {
return (
<AccordionPrimitive.Header className="flex">
<AccordionPrimitive.Trigger
data-slot="accordion-trigger"
className={cn(
"group/accordion-trigger relative flex flex-1 items-start justify-between rounded-lg border border-transparent py-2.5 text-left text-sm font-medium transition-all outline-none hover:underline focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 focus-visible:after:border-ring disabled:pointer-events-none disabled:opacity-50 **:data-[slot=accordion-trigger-icon]:ml-auto **:data-[slot=accordion-trigger-icon]:size-4 **:data-[slot=accordion-trigger-icon]:text-muted-foreground",
className
)}
{...props}
>
{children}
<ChevronDownIcon data-slot="accordion-trigger-icon" className="pointer-events-none shrink-0 group-aria-expanded/accordion-trigger:hidden" />
<ChevronUpIcon data-slot="accordion-trigger-icon" className="pointer-events-none hidden shrink-0 group-aria-expanded/accordion-trigger:inline" />
</AccordionPrimitive.Trigger>
</AccordionPrimitive.Header>
)
}
function AccordionContent({
className,
children,
...props
}: React.ComponentProps<typeof AccordionPrimitive.Content>) {
return (
<AccordionPrimitive.Content
data-slot="accordion-content"
className="overflow-hidden text-sm data-open:animate-accordion-down data-closed:animate-accordion-up"
{...props}
>
<div
className={cn(
"h-(--radix-accordion-content-height) pt-0 pb-2.5 [&_a]:underline [&_a]:underline-offset-3 [&_a]:hover:text-foreground [&_p:not(:last-child)]:mb-4",
className
)}
>
{children}
</div>
</AccordionPrimitive.Content>
)
}
export { Accordion, AccordionItem, AccordionTrigger, AccordionContent }
+199
View File
@@ -0,0 +1,199 @@
"use client"
import * as React from "react"
import { AlertDialog as AlertDialogPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
function AlertDialog({
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Root>) {
return <AlertDialogPrimitive.Root data-slot="alert-dialog" {...props} />
}
function AlertDialogTrigger({
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Trigger>) {
return (
<AlertDialogPrimitive.Trigger data-slot="alert-dialog-trigger" {...props} />
)
}
function AlertDialogPortal({
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Portal>) {
return (
<AlertDialogPrimitive.Portal data-slot="alert-dialog-portal" {...props} />
)
}
function AlertDialogOverlay({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Overlay>) {
return (
<AlertDialogPrimitive.Overlay
data-slot="alert-dialog-overlay"
className={cn(
"fixed inset-0 z-50 bg-black/10 duration-100 supports-backdrop-filter:backdrop-blur-xs data-open:animate-in data-open:fade-in-0 data-closed:animate-out data-closed:fade-out-0",
className
)}
{...props}
/>
)
}
function AlertDialogContent({
className,
size = "default",
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Content> & {
size?: "default" | "sm"
}) {
return (
<AlertDialogPortal>
<AlertDialogOverlay />
<AlertDialogPrimitive.Content
data-slot="alert-dialog-content"
data-size={size}
className={cn(
"group/alert-dialog-content fixed top-1/2 left-1/2 z-50 grid w-full -translate-x-1/2 -translate-y-1/2 gap-4 rounded-xl bg-popover p-4 text-popover-foreground ring-1 ring-foreground/10 duration-100 outline-none data-[size=default]:max-w-xs data-[size=sm]:max-w-xs data-[size=default]:sm:max-w-sm data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
className
)}
{...props}
/>
</AlertDialogPortal>
)
}
function AlertDialogHeader({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-dialog-header"
className={cn(
"grid grid-rows-[auto_1fr] place-items-center gap-1.5 text-center has-data-[slot=alert-dialog-media]:grid-rows-[auto_auto_1fr] has-data-[slot=alert-dialog-media]:gap-x-4 sm:group-data-[size=default]/alert-dialog-content:place-items-start sm:group-data-[size=default]/alert-dialog-content:text-left sm:group-data-[size=default]/alert-dialog-content:has-data-[slot=alert-dialog-media]:grid-rows-[auto_1fr]",
className
)}
{...props}
/>
)
}
function AlertDialogFooter({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-dialog-footer"
className={cn(
"-mx-4 -mb-4 flex flex-col-reverse gap-2 rounded-b-xl border-t bg-muted/50 p-4 group-data-[size=sm]/alert-dialog-content:grid group-data-[size=sm]/alert-dialog-content:grid-cols-2 sm:flex-row sm:justify-end",
className
)}
{...props}
/>
)
}
function AlertDialogMedia({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-dialog-media"
className={cn(
"mb-2 inline-flex size-10 items-center justify-center rounded-md bg-muted sm:group-data-[size=default]/alert-dialog-content:row-span-2 *:[svg:not([class*='size-'])]:size-6",
className
)}
{...props}
/>
)
}
function AlertDialogTitle({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Title>) {
return (
<AlertDialogPrimitive.Title
data-slot="alert-dialog-title"
className={cn(
"font-heading text-base font-medium sm:group-data-[size=default]/alert-dialog-content:group-has-data-[slot=alert-dialog-media]/alert-dialog-content:col-start-2",
className
)}
{...props}
/>
)
}
function AlertDialogDescription({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Description>) {
return (
<AlertDialogPrimitive.Description
data-slot="alert-dialog-description"
className={cn(
"text-sm text-balance text-muted-foreground md:text-pretty *:[a]:underline *:[a]:underline-offset-3 *:[a]:hover:text-foreground",
className
)}
{...props}
/>
)
}
function AlertDialogAction({
className,
variant = "default",
size = "default",
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Action> &
Pick<React.ComponentProps<typeof Button>, "variant" | "size">) {
return (
<Button variant={variant} size={size} asChild>
<AlertDialogPrimitive.Action
data-slot="alert-dialog-action"
className={cn(className)}
{...props}
/>
</Button>
)
}
function AlertDialogCancel({
className,
variant = "outline",
size = "default",
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Cancel> &
Pick<React.ComponentProps<typeof Button>, "variant" | "size">) {
return (
<Button variant={variant} size={size} asChild>
<AlertDialogPrimitive.Cancel
data-slot="alert-dialog-cancel"
className={cn(className)}
{...props}
/>
</Button>
)
}
export {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogMedia,
AlertDialogOverlay,
AlertDialogPortal,
AlertDialogTitle,
AlertDialogTrigger,
}
+76
View File
@@ -0,0 +1,76 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const alertVariants = cva(
"group/alert relative grid w-full gap-0.5 rounded-lg border px-2.5 py-2 text-left text-sm has-data-[slot=alert-action]:relative has-data-[slot=alert-action]:pr-18 has-[>svg]:grid-cols-[auto_minmax(0,1fr)] has-[>svg]:gap-x-2 *:[svg]:row-span-2 *:[svg]:translate-y-0.5 *:[svg]:text-current *:[svg:not([class*='size-'])]:size-4",
{
variants: {
variant: {
default: "bg-card text-card-foreground",
destructive:
"bg-card text-destructive *:data-[slot=alert-description]:text-destructive/90 *:[svg]:text-current",
},
},
defaultVariants: {
variant: "default",
},
}
)
function Alert({
className,
variant,
...props
}: React.ComponentProps<"div"> & VariantProps<typeof alertVariants>) {
return (
<div
data-slot="alert"
role="alert"
className={cn(alertVariants({ variant }), className)}
{...props}
/>
)
}
function AlertTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-title"
className={cn(
"min-w-0 font-medium group-has-[>svg]/alert:col-start-2 [&_a]:underline [&_a]:underline-offset-3 [&_a]:hover:text-foreground",
className
)}
{...props}
/>
)
}
function AlertDescription({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-description"
className={cn(
"min-w-0 text-sm text-balance text-muted-foreground [overflow-wrap:anywhere] md:text-pretty [&_a]:underline [&_a]:underline-offset-3 [&_a]:hover:text-foreground [&_p:not(:last-child)]:mb-4",
className
)}
{...props}
/>
)
}
function AlertAction({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-action"
className={cn("absolute top-2 right-2", className)}
{...props}
/>
)
}
export { Alert, AlertTitle, AlertDescription, AlertAction }
+11
View File
@@ -0,0 +1,11 @@
"use client"
import { AspectRatio as AspectRatioPrimitive } from "radix-ui"
function AspectRatio({
...props
}: React.ComponentProps<typeof AspectRatioPrimitive.Root>) {
return <AspectRatioPrimitive.Root data-slot="aspect-ratio" {...props} />
}
export { AspectRatio }
+204
View File
@@ -0,0 +1,204 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { Slot } from "radix-ui"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
const attachmentVariants = cva(
"group/attachment relative flex w-fit max-w-full min-w-0 shrink-0 flex-wrap rounded-xl border bg-card text-card-foreground transition-colors focus-within:ring-1 focus-within:ring-ring/50 has-[>a,>button]:hover:bg-muted/50 data-[state=error]:border-destructive/30 data-[state=idle]:border-dashed",
{
variants: {
size: {
default:
"gap-2 text-sm has-data-[slot=attachment-content]:px-2.5 has-data-[slot=attachment-content]:py-2 has-data-[slot=attachment-media]:p-2",
sm: "gap-2.5 text-xs has-data-[slot=attachment-content]:px-2 has-data-[slot=attachment-content]:py-1.5 has-data-[slot=attachment-media]:p-1.5",
xs: "gap-1.5 rounded-lg text-xs has-data-[slot=attachment-content]:px-1.5 has-data-[slot=attachment-content]:py-1 has-data-[slot=attachment-media]:p-1",
},
orientation: {
horizontal: "min-w-40 items-center",
vertical: "w-24 flex-col has-data-[slot=attachment-content]:w-30",
},
},
}
)
function Attachment({
className,
state = "done",
size = "default",
orientation = "horizontal",
...props
}: React.ComponentProps<"div"> &
VariantProps<typeof attachmentVariants> & {
state?: "idle" | "uploading" | "processing" | "error" | "done"
}) {
return (
<div
data-slot="attachment"
data-state={state}
data-size={size}
data-orientation={orientation}
className={cn(attachmentVariants({ size, orientation }), className)}
{...props}
/>
)
}
const attachmentMediaVariants = cva(
"relative flex aspect-square w-10 shrink-0 items-center justify-center overflow-hidden rounded-lg bg-muted text-foreground group-data-[orientation=vertical]/attachment:w-full group-data-[size=sm]/attachment:w-8 group-data-[size=xs]/attachment:w-7 group-data-[size=xs]/attachment:rounded-md group-data-[state=error]/attachment:bg-destructive/10 group-data-[state=error]/attachment:text-destructive group-data-[orientation=vertical]/attachment:*:data-[slot=spinner]:size-6! [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 group-data-[orientation=vertical]/attachment:[&_svg:not([class*='size-'])]:size-6 group-data-[size=xs]/attachment:[&_svg:not([class*='size-'])]:size-3.5",
{
variants: {
variant: {
icon: "",
image:
"opacity-60 group-data-[state=done]/attachment:opacity-100 group-data-[state=idle]/attachment:opacity-100 *:[img]:aspect-square *:[img]:w-full *:[img]:object-cover",
},
},
defaultVariants: {
variant: "icon",
},
}
)
function AttachmentMedia({
className,
variant = "icon",
...props
}: React.ComponentProps<"div"> & VariantProps<typeof attachmentMediaVariants>) {
return (
<div
data-slot="attachment-media"
data-variant={variant}
className={cn(attachmentMediaVariants({ variant }), className)}
{...props}
/>
)
}
function AttachmentContent({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="attachment-content"
className={cn(
"max-w-full min-w-0 flex-1 leading-tight group-data-[orientation=vertical]/attachment:px-1",
className
)}
{...props}
/>
)
}
function AttachmentTitle({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
data-slot="attachment-title"
className={cn(
"block max-w-full min-w-0 truncate font-medium group-data-[state=processing]/attachment:shimmer group-data-[state=uploading]/attachment:shimmer",
className
)}
{...props}
/>
)
}
function AttachmentDescription({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
data-slot="attachment-description"
className={cn(
"mt-0.5 block min-w-0 truncate text-xs text-muted-foreground group-data-[state=error]/attachment:text-destructive/80",
"max-w-full",
className
)}
{...props}
/>
)
}
function AttachmentActions({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="attachment-actions"
className={cn(
"relative z-20 flex shrink-0 items-center group-data-[orientation=vertical]/attachment:absolute group-data-[orientation=vertical]/attachment:top-3 group-data-[orientation=vertical]/attachment:right-3 group-data-[orientation=vertical]/attachment:gap-1",
className
)}
{...props}
/>
)
}
function AttachmentAction({
className,
variant,
size = "icon-xs",
...props
}: React.ComponentProps<typeof Button>) {
return (
<Button
data-slot="attachment-action"
variant={variant ?? "ghost"}
size={size}
className={cn(className)}
{...props}
/>
)
}
function AttachmentTrigger({
className,
asChild = false,
type,
...props
}: React.ComponentProps<"button"> & {
asChild?: boolean
}) {
const Comp = asChild ? Slot.Root : "button"
return (
<Comp
data-slot="attachment-trigger"
type={asChild ? undefined : (type ?? "button")}
className={cn("absolute inset-0 z-10 outline-none", className)}
{...props}
/>
)
}
function AttachmentGroup({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="attachment-group"
className={cn(
"flex min-w-0 scroll-fade-x snap-x snap-mandatory scroll-px-1 scrollbar-none gap-3 overflow-x-auto overscroll-x-contain py-1 *:data-[slot=attachment]:flex-none *:data-[slot=attachment]:snap-start",
className
)}
{...props}
/>
)
}
export {
Attachment,
AttachmentGroup,
AttachmentMedia,
AttachmentContent,
AttachmentTitle,
AttachmentDescription,
AttachmentActions,
AttachmentAction,
AttachmentTrigger,
}
+112
View File
@@ -0,0 +1,112 @@
"use client"
import * as React from "react"
import { Avatar as AvatarPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
function Avatar({
className,
size = "default",
...props
}: React.ComponentProps<typeof AvatarPrimitive.Root> & {
size?: "default" | "sm" | "lg"
}) {
return (
<AvatarPrimitive.Root
data-slot="avatar"
data-size={size}
className={cn(
"group/avatar relative flex size-8 shrink-0 rounded-full select-none after:absolute after:inset-0 after:rounded-full after:border after:border-border after:mix-blend-darken data-[size=lg]:size-10 data-[size=sm]:size-6 dark:after:mix-blend-lighten",
className
)}
{...props}
/>
)
}
function AvatarImage({
className,
...props
}: React.ComponentProps<typeof AvatarPrimitive.Image>) {
return (
<AvatarPrimitive.Image
data-slot="avatar-image"
className={cn(
"aspect-square size-full rounded-full object-cover",
className
)}
{...props}
/>
)
}
function AvatarFallback({
className,
...props
}: React.ComponentProps<typeof AvatarPrimitive.Fallback>) {
return (
<AvatarPrimitive.Fallback
data-slot="avatar-fallback"
className={cn(
"flex size-full items-center justify-center rounded-full bg-muted text-sm text-muted-foreground group-data-[size=sm]/avatar:text-xs",
className
)}
{...props}
/>
)
}
function AvatarBadge({ className, ...props }: React.ComponentProps<"span">) {
return (
<span
data-slot="avatar-badge"
className={cn(
"absolute right-0 bottom-0 z-10 inline-flex items-center justify-center rounded-full bg-primary text-primary-foreground bg-blend-color ring-2 ring-background select-none",
"group-data-[size=sm]/avatar:size-2 group-data-[size=sm]/avatar:[&>svg]:hidden",
"group-data-[size=default]/avatar:size-2.5 group-data-[size=default]/avatar:[&>svg]:size-2",
"group-data-[size=lg]/avatar:size-3 group-data-[size=lg]/avatar:[&>svg]:size-2",
className
)}
{...props}
/>
)
}
function AvatarGroup({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="avatar-group"
className={cn(
"group/avatar-group flex -space-x-2 *:data-[slot=avatar]:ring-2 *:data-[slot=avatar]:ring-background",
className
)}
{...props}
/>
)
}
function AvatarGroupCount({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="avatar-group-count"
className={cn(
"relative flex size-8 shrink-0 items-center justify-center rounded-full bg-muted text-sm text-muted-foreground ring-2 ring-background group-has-data-[size=lg]/avatar-group:size-10 group-has-data-[size=sm]/avatar-group:size-6 [&>svg]:size-4 group-has-data-[size=lg]/avatar-group:[&>svg]:size-5 group-has-data-[size=sm]/avatar-group:[&>svg]:size-3",
className
)}
{...props}
/>
)
}
export {
Avatar,
AvatarImage,
AvatarFallback,
AvatarGroup,
AvatarGroupCount,
AvatarBadge,
}
+49
View File
@@ -0,0 +1,49 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { Slot } from "radix-ui"
import { cn } from "@/lib/utils"
const badgeVariants = cva(
"group/badge inline-flex h-5 w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-4xl border border-transparent px-2 py-0.5 text-xs font-medium whitespace-nowrap transition-all focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&>svg]:pointer-events-none [&>svg]:size-3!",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground [a]:hover:bg-primary/80",
secondary:
"bg-secondary text-secondary-foreground [a]:hover:bg-secondary/80",
destructive:
"bg-destructive/10 text-destructive focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:focus-visible:ring-destructive/40 [a]:hover:bg-destructive/20",
outline:
"border-border text-foreground [a]:hover:bg-muted [a]:hover:text-muted-foreground",
ghost:
"hover:bg-muted hover:text-muted-foreground dark:hover:bg-muted/50",
link: "text-primary underline-offset-4 hover:underline",
},
},
defaultVariants: {
variant: "default",
},
}
)
function Badge({
className,
variant = "default",
asChild = false,
...props
}: React.ComponentProps<"span"> &
VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
const Comp = asChild ? Slot.Root : "span"
return (
<Comp
data-slot="badge"
data-variant={variant}
className={cn(badgeVariants({ variant }), className)}
{...props}
/>
)
}
export { Badge, badgeVariants }
+122
View File
@@ -0,0 +1,122 @@
import * as React from "react"
import { Slot } from "radix-ui"
import { cn } from "@/lib/utils"
import { ChevronRightIcon, MoreHorizontalIcon } from "lucide-react"
function Breadcrumb({ className, ...props }: React.ComponentProps<"nav">) {
return (
<nav
aria-label="breadcrumb"
data-slot="breadcrumb"
className={cn(className)}
{...props}
/>
)
}
function BreadcrumbList({ className, ...props }: React.ComponentProps<"ol">) {
return (
<ol
data-slot="breadcrumb-list"
className={cn(
"flex flex-wrap items-center gap-1.5 text-sm wrap-break-word text-muted-foreground",
className
)}
{...props}
/>
)
}
function BreadcrumbItem({ className, ...props }: React.ComponentProps<"li">) {
return (
<li
data-slot="breadcrumb-item"
className={cn("inline-flex items-center gap-1", className)}
{...props}
/>
)
}
function BreadcrumbLink({
asChild,
className,
...props
}: React.ComponentProps<"a"> & {
asChild?: boolean
}) {
const Comp = asChild ? Slot.Root : "a"
return (
<Comp
data-slot="breadcrumb-link"
className={cn("transition-colors hover:text-foreground", className)}
{...props}
/>
)
}
function BreadcrumbPage({ className, ...props }: React.ComponentProps<"span">) {
return (
<span
data-slot="breadcrumb-page"
role="link"
aria-disabled="true"
aria-current="page"
className={cn("font-normal text-foreground", className)}
{...props}
/>
)
}
function BreadcrumbSeparator({
children,
className,
...props
}: React.ComponentProps<"li">) {
return (
<li
data-slot="breadcrumb-separator"
role="presentation"
aria-hidden="true"
className={cn("[&>svg]:size-3.5", className)}
{...props}
>
{children ?? (
<ChevronRightIcon />
)}
</li>
)
}
function BreadcrumbEllipsis({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
data-slot="breadcrumb-ellipsis"
role="presentation"
aria-hidden="true"
className={cn(
"flex size-5 items-center justify-center [&>svg]:size-4",
className
)}
{...props}
>
<MoreHorizontalIcon
/>
<span className="sr-only">More</span>
</span>
)
}
export {
Breadcrumb,
BreadcrumbList,
BreadcrumbItem,
BreadcrumbLink,
BreadcrumbPage,
BreadcrumbSeparator,
BreadcrumbEllipsis,
}
+125
View File
@@ -0,0 +1,125 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { Slot } from "radix-ui"
import { cn } from "@/lib/utils"
function BubbleGroup({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="bubble-group"
className={cn("flex min-w-0 flex-col gap-2", className)}
{...props}
/>
)
}
const bubbleVariants = cva(
"group/bubble relative flex w-fit max-w-[80%] min-w-0 flex-col gap-1 group-data-[align=end]/message:self-end data-[align=end]:self-end data-[variant=ghost]:max-w-full",
{
variants: {
variant: {
default:
"*:data-[slot=bubble-content]:bg-primary *:data-[slot=bubble-content]:text-primary-foreground [&>[data-slot=bubble-content]:is(button,a):hover]:bg-primary/80",
secondary:
"*:data-[slot=bubble-content]:bg-secondary *:data-[slot=bubble-content]:text-secondary-foreground [&>[data-slot=bubble-content]:is(button,a):hover]:bg-[color-mix(in_oklch,var(--secondary),var(--foreground)_5%)]",
muted:
"*:data-[slot=bubble-content]:bg-muted [&>[data-slot=bubble-content]:is(button,a):hover]:bg-[color-mix(in_oklch,var(--muted),var(--foreground)_5%)]",
tinted:
"*:data-[slot=bubble-content]:bg-[oklch(from_var(--primary)_0.93_calc(c*0.4)_h)] *:data-[slot=bubble-content]:text-foreground dark:*:data-[slot=bubble-content]:bg-[oklch(from_var(--primary)_0.3_calc(c*0.4)_h)] [&>[data-slot=bubble-content]:is(button,a):hover]:bg-[oklch(from_var(--primary)_0.88_calc(c*0.5)_h)] dark:[&>[data-slot=bubble-content]:is(button,a):hover]:bg-[oklch(from_var(--primary)_0.35_calc(c*0.5)_h)]",
outline:
"*:data-[slot=bubble-content]:border-border *:data-[slot=bubble-content]:bg-background [&>[data-slot=bubble-content]:is(button,a):hover]:bg-muted [&>[data-slot=bubble-content]:is(button,a):hover]:text-foreground dark:[&>[data-slot=bubble-content]:is(button,a):hover]:bg-input/30",
ghost:
"border-none *:data-[slot=bubble-content]:rounded-none *:data-[slot=bubble-content]:bg-transparent *:data-[slot=bubble-content]:p-0 [&>[data-slot=bubble-content]:is(button,a):hover]:bg-muted [&>[data-slot=bubble-content]:is(button,a):hover]:text-foreground dark:[&>[data-slot=bubble-content]:is(button,a):hover]:bg-muted/50",
destructive:
"*:data-[slot=bubble-content]:bg-destructive/10 *:data-[slot=bubble-content]:text-destructive dark:*:data-[slot=bubble-content]:bg-destructive/20 [&>[data-slot=bubble-content]:is(button,a):hover]:bg-destructive/20 dark:[&>[data-slot=bubble-content]:is(button,a):hover]:bg-destructive/30",
},
},
defaultVariants: {
variant: "default",
},
}
)
function Bubble({
variant = "default",
align = "start",
className,
...props
}: React.ComponentProps<"div"> &
VariantProps<typeof bubbleVariants> & {
align?: "start" | "end"
}) {
return (
<div
data-slot="bubble"
data-variant={variant}
data-align={align}
className={cn(bubbleVariants({ variant }), className)}
{...props}
/>
)
}
function BubbleContent({
asChild = false,
className,
...props
}: React.ComponentProps<"div"> & {
asChild?: boolean
}) {
const Comp = asChild ? Slot.Root : "div"
return (
<Comp
data-slot="bubble-content"
className={cn(
"w-fit max-w-full min-w-0 overflow-hidden rounded-xl border border-transparent px-3 py-2 text-sm leading-relaxed wrap-break-word group-data-[align=end]/bubble:self-end [button]:text-left [button,a]:transition-colors [button,a]:outline-none [button,a]:focus-visible:border-ring [button,a]:focus-visible:ring-3 [button,a]:focus-visible:ring-ring/50",
className
)}
{...props}
/>
)
}
const bubbleReactionsVariants = cva(
"absolute z-10 flex w-fit shrink-0 items-center justify-center gap-1 rounded-full bg-muted px-1.5 py-0.5 text-sm ring-3 ring-card has-[button]:p-0",
{
variants: {
side: {
top: "top-0 -translate-y-3/4",
bottom: "bottom-0 translate-y-3/4",
},
align: {
start: "left-3",
end: "right-3",
},
},
defaultVariants: {
side: "bottom",
align: "end",
},
}
)
function BubbleReactions({
side = "bottom",
align = "end",
className,
...props
}: React.ComponentProps<"div"> & {
align?: "start" | "end"
side?: "top" | "bottom"
}) {
return (
<div
data-slot="bubble-reactions"
data-align={align}
data-side={side}
className={cn(bubbleReactionsVariants({ side, align }), className)}
{...props}
/>
)
}
export { BubbleGroup, Bubble, BubbleContent, BubbleReactions }
+83
View File
@@ -0,0 +1,83 @@
import { cva, type VariantProps } from "class-variance-authority"
import { Slot } from "radix-ui"
import { cn } from "@/lib/utils"
import { Separator } from "@/components/ui/separator"
const buttonGroupVariants = cva(
"group/button-group flex w-fit items-stretch *:focus-visible:relative *:focus-visible:z-10 has-[>[data-slot=button-group]]:gap-2 has-[select[aria-hidden=true]:last-child]:[&>[data-slot=select-trigger]:last-of-type]:rounded-r-lg [&>[data-slot=select-trigger]:not([class*='w-'])]:w-fit [&>input]:flex-1",
{
variants: {
orientation: {
horizontal:
"[&>*:not(:first-child)]:rounded-l-none [&>*:not(:first-child)]:border-l-0 [&>*:not(:last-child)]:rounded-r-none [&>[data-slot]:not(:has(~[data-slot]))]:rounded-r-lg!",
vertical:
"flex-col [&>*:not(:first-child)]:rounded-t-none [&>*:not(:first-child)]:border-t-0 [&>*:not(:last-child)]:rounded-b-none [&>[data-slot]:not(:has(~[data-slot]))]:rounded-b-lg!",
},
},
defaultVariants: {
orientation: "horizontal",
},
}
)
function ButtonGroup({
className,
orientation,
...props
}: React.ComponentProps<"div"> & VariantProps<typeof buttonGroupVariants>) {
return (
<div
role="group"
data-slot="button-group"
data-orientation={orientation}
className={cn(buttonGroupVariants({ orientation }), className)}
{...props}
/>
)
}
function ButtonGroupText({
className,
asChild = false,
...props
}: React.ComponentProps<"div"> & {
asChild?: boolean
}) {
const Comp = asChild ? Slot.Root : "div"
return (
<Comp
className={cn(
"flex items-center gap-2 rounded-lg border bg-muted px-2.5 text-sm font-medium [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
/>
)
}
function ButtonGroupSeparator({
className,
orientation = "vertical",
...props
}: React.ComponentProps<typeof Separator>) {
return (
<Separator
data-slot="button-group-separator"
orientation={orientation}
className={cn(
"relative self-stretch bg-input data-horizontal:mx-px data-horizontal:w-auto data-vertical:my-px data-vertical:h-auto",
className
)}
{...props}
/>
)
}
export {
ButtonGroup,
ButtonGroupSeparator,
ButtonGroupText,
buttonGroupVariants,
}
+67
View File
@@ -0,0 +1,67 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { Slot } from "radix-ui"
import { cn } from "@/lib/utils"
const buttonVariants = cva(
"group/button inline-flex shrink-0 items-center justify-center rounded-lg border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/80",
outline:
"border-border bg-background hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50",
secondary:
"bg-secondary text-secondary-foreground hover:bg-[color-mix(in_oklch,var(--secondary),var(--foreground)_5%)] aria-expanded:bg-secondary aria-expanded:text-secondary-foreground",
ghost:
"hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50",
destructive:
"bg-destructive/10 text-destructive hover:bg-destructive/20 focus-visible:border-destructive/40 focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:hover:bg-destructive/30 dark:focus-visible:ring-destructive/40",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default:
"h-8 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
xs: "h-6 gap-1 rounded-[min(var(--radius-md),10px)] px-2 text-xs in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3",
sm: "h-7 gap-1 rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem] in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5",
lg: "h-9 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
icon: "size-8",
"icon-xs":
"size-6 rounded-[min(var(--radius-md),10px)] in-data-[slot=button-group]:rounded-lg [&_svg:not([class*='size-'])]:size-3",
"icon-sm":
"size-7 rounded-[min(var(--radius-md),12px)] in-data-[slot=button-group]:rounded-lg",
"icon-lg": "size-9",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
function Button({
className,
variant = "default",
size = "default",
asChild = false,
...props
}: React.ComponentProps<"button"> &
VariantProps<typeof buttonVariants> & {
asChild?: boolean
}) {
const Comp = asChild ? Slot.Root : "button"
return (
<Comp
data-slot="button"
data-variant={variant}
data-size={size}
className={cn(buttonVariants({ variant, size, className }))}
{...props}
/>
)
}
export { Button, buttonVariants }
+222
View File
@@ -0,0 +1,222 @@
"use client"
import * as React from "react"
import {
DayPicker,
getDefaultClassNames,
type DayButton,
type Locale,
} from "react-day-picker"
import { cn } from "@/lib/utils"
import { Button, buttonVariants } from "@/components/ui/button"
import { ChevronLeftIcon, ChevronRightIcon, ChevronDownIcon } from "lucide-react"
function Calendar({
className,
classNames,
showOutsideDays = true,
captionLayout = "label",
buttonVariant = "ghost",
locale,
formatters,
components,
...props
}: React.ComponentProps<typeof DayPicker> & {
buttonVariant?: React.ComponentProps<typeof Button>["variant"]
}) {
const defaultClassNames = getDefaultClassNames()
return (
<DayPicker
showOutsideDays={showOutsideDays}
className={cn(
"group/calendar bg-background p-2 [--cell-radius:var(--radius-md)] [--cell-size:--spacing(7)] in-data-[slot=card-content]:bg-transparent in-data-[slot=popover-content]:bg-transparent",
String.raw`rtl:**:[.rdp-button\_next>svg]:rotate-180`,
String.raw`rtl:**:[.rdp-button\_previous>svg]:rotate-180`,
className
)}
captionLayout={captionLayout}
locale={locale}
formatters={{
formatMonthDropdown: (date) =>
date.toLocaleString(locale?.code, { month: "short" }),
...formatters,
}}
classNames={{
root: cn("w-fit", defaultClassNames.root),
months: cn(
"relative flex flex-col gap-4 md:flex-row",
defaultClassNames.months
),
month: cn("flex w-full flex-col gap-4", defaultClassNames.month),
nav: cn(
"absolute inset-x-0 top-0 flex w-full items-center justify-between gap-1",
defaultClassNames.nav
),
button_previous: cn(
buttonVariants({ variant: buttonVariant }),
"size-(--cell-size) p-0 select-none aria-disabled:opacity-50",
defaultClassNames.button_previous
),
button_next: cn(
buttonVariants({ variant: buttonVariant }),
"size-(--cell-size) p-0 select-none aria-disabled:opacity-50",
defaultClassNames.button_next
),
month_caption: cn(
"flex h-(--cell-size) w-full items-center justify-center px-(--cell-size)",
defaultClassNames.month_caption
),
dropdowns: cn(
"flex h-(--cell-size) w-full items-center justify-center gap-1.5 text-sm font-medium",
defaultClassNames.dropdowns
),
dropdown_root: cn(
"relative rounded-(--cell-radius)",
defaultClassNames.dropdown_root
),
dropdown: cn(
"absolute inset-0 bg-popover opacity-0",
defaultClassNames.dropdown
),
caption_label: cn(
"font-medium select-none",
captionLayout === "label"
? "text-sm"
: "flex items-center gap-1 rounded-(--cell-radius) text-sm [&>svg]:size-3.5 [&>svg]:text-muted-foreground",
defaultClassNames.caption_label
),
month_grid: cn("w-full border-collapse", defaultClassNames.month_grid),
weekdays: cn("flex", defaultClassNames.weekdays),
weekday: cn(
"flex-1 rounded-(--cell-radius) text-[0.8rem] font-normal text-muted-foreground select-none",
defaultClassNames.weekday
),
week: cn("mt-2 flex w-full", defaultClassNames.week),
week_number_header: cn(
"w-(--cell-size) select-none",
defaultClassNames.week_number_header
),
week_number: cn(
"text-[0.8rem] text-muted-foreground select-none",
defaultClassNames.week_number
),
day: cn(
"group/day relative aspect-square h-full w-full rounded-(--cell-radius) p-0 text-center select-none [&:last-child[data-selected=true]_button]:rounded-r-(--cell-radius)",
props.showWeekNumber
? "[&:nth-child(2)[data-selected=true]_button]:rounded-l-(--cell-radius)"
: "[&:first-child[data-selected=true]_button]:rounded-l-(--cell-radius)",
defaultClassNames.day
),
range_start: cn(
"relative isolate z-0 rounded-l-(--cell-radius) bg-muted after:absolute after:inset-y-0 after:right-0 after:w-4 after:bg-muted",
defaultClassNames.range_start
),
range_middle: cn("rounded-none", defaultClassNames.range_middle),
range_end: cn(
"relative isolate z-0 rounded-r-(--cell-radius) bg-muted after:absolute after:inset-y-0 after:left-0 after:w-4 after:bg-muted",
defaultClassNames.range_end
),
today: cn(
"rounded-(--cell-radius) bg-muted text-foreground data-[selected=true]:rounded-none",
defaultClassNames.today
),
outside: cn(
"text-muted-foreground aria-selected:text-muted-foreground",
defaultClassNames.outside
),
disabled: cn(
"text-muted-foreground opacity-50",
defaultClassNames.disabled
),
hidden: cn("invisible", defaultClassNames.hidden),
...classNames,
}}
components={{
Root: ({ className, rootRef, ...props }) => {
return (
<div
data-slot="calendar"
ref={rootRef}
className={cn(className)}
{...props}
/>
)
},
Chevron: ({ className, orientation, ...props }) => {
if (orientation === "left") {
return (
<ChevronLeftIcon className={cn("size-4", className)} {...props} />
)
}
if (orientation === "right") {
return (
<ChevronRightIcon className={cn("size-4", className)} {...props} />
)
}
return (
<ChevronDownIcon className={cn("size-4", className)} {...props} />
)
},
DayButton: ({ ...props }) => (
<CalendarDayButton locale={locale} {...props} />
),
WeekNumber: ({ children, ...props }) => {
return (
<td {...props}>
<div className="flex size-(--cell-size) items-center justify-center text-center">
{children}
</div>
</td>
)
},
...components,
}}
{...props}
/>
)
}
function CalendarDayButton({
className,
day,
modifiers,
locale,
...props
}: React.ComponentProps<typeof DayButton> & { locale?: Partial<Locale> }) {
const defaultClassNames = getDefaultClassNames()
const ref = React.useRef<HTMLButtonElement>(null)
React.useEffect(() => {
if (modifiers.focused) ref.current?.focus()
}, [modifiers.focused])
return (
<Button
ref={ref}
variant="ghost"
size="icon"
data-day={day.date.toLocaleDateString(locale?.code)}
data-selected-single={
modifiers.selected &&
!modifiers.range_start &&
!modifiers.range_end &&
!modifiers.range_middle
}
data-range-start={modifiers.range_start}
data-range-end={modifiers.range_end}
data-range-middle={modifiers.range_middle}
className={cn(
"relative isolate z-10 flex aspect-square size-auto w-full min-w-(--cell-size) flex-col gap-1 border-0 leading-none font-normal group-data-[focused=true]/day:relative group-data-[focused=true]/day:z-10 group-data-[focused=true]/day:border-ring group-data-[focused=true]/day:ring-[3px] group-data-[focused=true]/day:ring-ring/50 data-[range-end=true]:rounded-(--cell-radius) data-[range-end=true]:rounded-r-(--cell-radius) data-[range-end=true]:bg-primary data-[range-end=true]:text-primary-foreground data-[range-middle=true]:rounded-none data-[range-middle=true]:bg-muted data-[range-middle=true]:text-foreground data-[range-start=true]:rounded-(--cell-radius) data-[range-start=true]:rounded-l-(--cell-radius) data-[range-start=true]:bg-primary data-[range-start=true]:text-primary-foreground data-[selected-single=true]:bg-primary data-[selected-single=true]:text-primary-foreground dark:hover:text-foreground [&>span]:text-xs [&>span]:opacity-70",
defaultClassNames.day,
className
)}
{...props}
/>
)
}
export { Calendar, CalendarDayButton }
+103
View File
@@ -0,0 +1,103 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Card({
className,
size = "default",
...props
}: React.ComponentProps<"div"> & { size?: "default" | "sm" }) {
return (
<div
data-slot="card"
data-size={size}
className={cn(
"group/card flex flex-col gap-(--card-spacing) overflow-hidden rounded-xl bg-card py-(--card-spacing) text-sm text-card-foreground ring-1 ring-foreground/10 [--card-spacing:--spacing(4)] has-data-[slot=card-footer]:pb-0 has-[>img:first-child]:pt-0 data-[size=sm]:[--card-spacing:--spacing(3)] data-[size=sm]:has-data-[slot=card-footer]:pb-0 *:[img:first-child]:rounded-t-xl *:[img:last-child]:rounded-b-xl",
className
)}
{...props}
/>
)
}
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-header"
className={cn(
"group/card-header @container/card-header grid auto-rows-min items-start gap-1 rounded-t-xl px-(--card-spacing) has-data-[slot=card-action]:grid-cols-[1fr_auto] has-data-[slot=card-description]:grid-rows-[auto_auto] [.border-b]:pb-(--card-spacing)",
className
)}
{...props}
/>
)
}
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-title"
className={cn(
"font-heading text-base leading-snug font-medium group-data-[size=sm]/card:text-sm",
className
)}
{...props}
/>
)
}
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-description"
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
)
}
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-action"
className={cn(
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
className
)}
{...props}
/>
)
}
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-content"
className={cn("px-(--card-spacing)", className)}
{...props}
/>
)
}
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-footer"
className={cn(
"flex items-center rounded-b-xl border-t bg-muted/50 p-(--card-spacing)",
className
)}
{...props}
/>
)
}
export {
Card,
CardHeader,
CardFooter,
CardTitle,
CardAction,
CardDescription,
CardContent,
}
+242
View File
@@ -0,0 +1,242 @@
"use client"
import * as React from "react"
import useEmblaCarousel, {
type UseEmblaCarouselType,
} from "embla-carousel-react"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import { ChevronLeftIcon, ChevronRightIcon } from "lucide-react"
type CarouselApi = UseEmblaCarouselType[1]
type UseCarouselParameters = Parameters<typeof useEmblaCarousel>
type CarouselOptions = UseCarouselParameters[0]
type CarouselPlugin = UseCarouselParameters[1]
type CarouselProps = {
opts?: CarouselOptions
plugins?: CarouselPlugin
orientation?: "horizontal" | "vertical"
setApi?: (api: CarouselApi) => void
}
type CarouselContextProps = {
carouselRef: ReturnType<typeof useEmblaCarousel>[0]
api: ReturnType<typeof useEmblaCarousel>[1]
scrollPrev: () => void
scrollNext: () => void
canScrollPrev: boolean
canScrollNext: boolean
} & CarouselProps
const CarouselContext = React.createContext<CarouselContextProps | null>(null)
function useCarousel() {
const context = React.useContext(CarouselContext)
if (!context) {
throw new Error("useCarousel must be used within a <Carousel />")
}
return context
}
function Carousel({
orientation = "horizontal",
opts,
setApi,
plugins,
className,
children,
...props
}: React.ComponentProps<"div"> & CarouselProps) {
const [carouselRef, api] = useEmblaCarousel(
{
...opts,
axis: orientation === "horizontal" ? "x" : "y",
},
plugins
)
const [canScrollPrev, setCanScrollPrev] = React.useState(false)
const [canScrollNext, setCanScrollNext] = React.useState(false)
const onSelect = React.useCallback((api: CarouselApi) => {
if (!api) return
setCanScrollPrev(api.canScrollPrev())
setCanScrollNext(api.canScrollNext())
}, [])
const scrollPrev = React.useCallback(() => {
api?.scrollPrev()
}, [api])
const scrollNext = React.useCallback(() => {
api?.scrollNext()
}, [api])
const handleKeyDown = React.useCallback(
(event: React.KeyboardEvent<HTMLDivElement>) => {
if (event.key === "ArrowLeft") {
event.preventDefault()
scrollPrev()
} else if (event.key === "ArrowRight") {
event.preventDefault()
scrollNext()
}
},
[scrollPrev, scrollNext]
)
React.useEffect(() => {
if (!api || !setApi) return
setApi(api)
}, [api, setApi])
React.useEffect(() => {
if (!api) return
onSelect(api)
api.on("reInit", onSelect)
api.on("select", onSelect)
return () => {
api?.off("select", onSelect)
}
}, [api, onSelect])
return (
<CarouselContext.Provider
value={{
carouselRef,
api: api,
opts,
orientation:
orientation || (opts?.axis === "y" ? "vertical" : "horizontal"),
scrollPrev,
scrollNext,
canScrollPrev,
canScrollNext,
}}
>
<div
onKeyDownCapture={handleKeyDown}
className={cn("relative", className)}
role="region"
aria-roledescription="carousel"
data-slot="carousel"
{...props}
>
{children}
</div>
</CarouselContext.Provider>
)
}
function CarouselContent({ className, ...props }: React.ComponentProps<"div">) {
const { carouselRef, orientation } = useCarousel()
return (
<div
ref={carouselRef}
className="overflow-hidden"
data-slot="carousel-content"
>
<div
className={cn(
"flex",
orientation === "horizontal" ? "-ml-4" : "-mt-4 flex-col",
className
)}
{...props}
/>
</div>
)
}
function CarouselItem({ className, ...props }: React.ComponentProps<"div">) {
const { orientation } = useCarousel()
return (
<div
role="group"
aria-roledescription="slide"
data-slot="carousel-item"
className={cn(
"min-w-0 shrink-0 grow-0 basis-full",
orientation === "horizontal" ? "pl-4" : "pt-4",
className
)}
{...props}
/>
)
}
function CarouselPrevious({
className,
variant = "outline",
size = "icon-sm",
...props
}: React.ComponentProps<typeof Button>) {
const { orientation, scrollPrev, canScrollPrev } = useCarousel()
return (
<Button
data-slot="carousel-previous"
variant={variant}
size={size}
className={cn(
"absolute touch-manipulation rounded-full",
orientation === "horizontal"
? "inset-y-0 -left-12 my-auto"
: "-top-12 left-1/2 -translate-x-1/2 rotate-90",
className
)}
disabled={!canScrollPrev}
onClick={scrollPrev}
{...props}
>
<ChevronLeftIcon />
<span className="sr-only">Previous slide</span>
</Button>
)
}
function CarouselNext({
className,
variant = "outline",
size = "icon-sm",
...props
}: React.ComponentProps<typeof Button>) {
const { orientation, scrollNext, canScrollNext } = useCarousel()
return (
<Button
data-slot="carousel-next"
variant={variant}
size={size}
className={cn(
"absolute touch-manipulation rounded-full",
orientation === "horizontal"
? "inset-y-0 -right-12 my-auto"
: "-bottom-12 left-1/2 -translate-x-1/2 rotate-90",
className
)}
disabled={!canScrollNext}
onClick={scrollNext}
{...props}
>
<ChevronRightIcon />
<span className="sr-only">Next slide</span>
</Button>
)
}
export {
type CarouselApi,
Carousel,
CarouselContent,
CarouselItem,
CarouselPrevious,
CarouselNext,
useCarousel,
}
+373
View File
@@ -0,0 +1,373 @@
"use client"
import * as React from "react"
import * as RechartsPrimitive from "recharts"
import type { TooltipValueType } from "recharts"
import { cn } from "@/lib/utils"
// Format: { THEME_NAME: CSS_SELECTOR }
const THEMES = { light: "", dark: ".dark" } as const
const INITIAL_DIMENSION = { width: 320, height: 200 } as const
type TooltipNameType = number | string
export type ChartConfig = Record<
string,
{
label?: React.ReactNode
icon?: React.ComponentType
} & (
| { color?: string; theme?: never }
| { color?: never; theme: Record<keyof typeof THEMES, string> }
)
>
type ChartContextProps = {
config: ChartConfig
}
const ChartContext = React.createContext<ChartContextProps | null>(null)
function useChart() {
const context = React.useContext(ChartContext)
if (!context) {
throw new Error("useChart must be used within a <ChartContainer />")
}
return context
}
function ChartContainer({
id,
className,
children,
config,
initialDimension = INITIAL_DIMENSION,
...props
}: React.ComponentProps<"div"> & {
config: ChartConfig
children: React.ComponentProps<
typeof RechartsPrimitive.ResponsiveContainer
>["children"]
initialDimension?: {
width: number
height: number
}
}) {
const uniqueId = React.useId()
const chartId = `chart-${id ?? uniqueId.replace(/:/g, "")}`
return (
<ChartContext.Provider value={{ config }}>
<div
data-slot="chart"
data-chart={chartId}
className={cn(
"flex aspect-video justify-center text-xs [&_.recharts-cartesian-axis-tick_text]:fill-muted-foreground [&_.recharts-cartesian-grid_line[stroke='#ccc']]:stroke-border/50 [&_.recharts-curve.recharts-tooltip-cursor]:stroke-border [&_.recharts-dot[stroke='#fff']]:stroke-transparent [&_.recharts-layer]:outline-hidden [&_.recharts-polar-grid_[stroke='#ccc']]:stroke-border [&_.recharts-radial-bar-background-sector]:fill-muted [&_.recharts-rectangle.recharts-tooltip-cursor]:fill-muted [&_.recharts-reference-line_[stroke='#ccc']]:stroke-border [&_.recharts-sector]:outline-hidden [&_.recharts-sector[stroke='#fff']]:stroke-transparent [&_.recharts-surface]:outline-hidden",
className
)}
{...props}
>
<ChartStyle id={chartId} config={config} />
<RechartsPrimitive.ResponsiveContainer
initialDimension={initialDimension}
>
{children}
</RechartsPrimitive.ResponsiveContainer>
</div>
</ChartContext.Provider>
)
}
const ChartStyle = ({ id, config }: { id: string; config: ChartConfig }) => {
const colorConfig = Object.entries(config).filter(
([, config]) => config.theme ?? config.color
)
if (!colorConfig.length) {
return null
}
return (
<style
dangerouslySetInnerHTML={{
__html: Object.entries(THEMES)
.map(
([theme, prefix]) => `
${prefix} [data-chart=${id}] {
${colorConfig
.map(([key, itemConfig]) => {
const color =
itemConfig.theme?.[theme as keyof typeof itemConfig.theme] ??
itemConfig.color
return color ? ` --color-${key}: ${color};` : null
})
.join("\n")}
}
`
)
.join("\n"),
}}
/>
)
}
const ChartTooltip = RechartsPrimitive.Tooltip
function ChartTooltipContent({
active,
payload,
className,
indicator = "dot",
hideLabel = false,
hideIndicator = false,
label,
labelFormatter,
labelClassName,
formatter,
color,
nameKey,
labelKey,
}: React.ComponentProps<typeof RechartsPrimitive.Tooltip> &
React.ComponentProps<"div"> & {
hideLabel?: boolean
hideIndicator?: boolean
indicator?: "line" | "dot" | "dashed"
nameKey?: string
labelKey?: string
} & Omit<
RechartsPrimitive.DefaultTooltipContentProps<
TooltipValueType,
TooltipNameType
>,
"accessibilityLayer"
>) {
const { config } = useChart()
const tooltipLabel = React.useMemo(() => {
if (hideLabel || !payload?.length) {
return null
}
const [item] = payload
const key = `${labelKey ?? item?.dataKey ?? item?.name ?? "value"}`
const itemConfig = getPayloadConfigFromPayload(config, item, key)
const value =
!labelKey && typeof label === "string"
? (config[label]?.label ?? label)
: itemConfig?.label
if (labelFormatter) {
return (
<div className={cn("font-medium", labelClassName)}>
{labelFormatter(value, payload)}
</div>
)
}
if (!value) {
return null
}
return <div className={cn("font-medium", labelClassName)}>{value}</div>
}, [
label,
labelFormatter,
payload,
hideLabel,
labelClassName,
config,
labelKey,
])
if (!active || !payload?.length) {
return null
}
const nestLabel = payload.length === 1 && indicator !== "dot"
return (
<div
className={cn(
"grid min-w-32 items-start gap-1.5 rounded-lg border border-border/50 bg-background px-2.5 py-1.5 text-xs shadow-xl",
className
)}
>
{!nestLabel ? tooltipLabel : null}
<div className="grid gap-1.5">
{payload
.filter((item) => item.type !== "none")
.map((item, index) => {
const key = `${nameKey ?? item.name ?? item.dataKey ?? "value"}`
const itemConfig = getPayloadConfigFromPayload(config, item, key)
const indicatorColor = color ?? item.payload?.fill ?? item.color
return (
<div
key={index}
className={cn(
"flex w-full flex-wrap items-stretch gap-2 [&>svg]:h-2.5 [&>svg]:w-2.5 [&>svg]:text-muted-foreground",
indicator === "dot" && "items-center"
)}
>
{formatter && item?.value !== undefined && item.name ? (
formatter(item.value, item.name, item, index, item.payload)
) : (
<>
{itemConfig?.icon ? (
<itemConfig.icon />
) : (
!hideIndicator && (
<div
className={cn(
"shrink-0 rounded-[2px] border-(--color-border) bg-(--color-bg)",
{
"h-2.5 w-2.5": indicator === "dot",
"w-1": indicator === "line",
"w-0 border-[1.5px] border-dashed bg-transparent":
indicator === "dashed",
"my-0.5": nestLabel && indicator === "dashed",
}
)}
style={
{
"--color-bg": indicatorColor,
"--color-border": indicatorColor,
} as React.CSSProperties
}
/>
)
)}
<div
className={cn(
"flex flex-1 justify-between leading-none",
nestLabel ? "items-end" : "items-center"
)}
>
<div className="grid gap-1.5">
{nestLabel ? tooltipLabel : null}
<span className="text-muted-foreground">
{itemConfig?.label ?? item.name}
</span>
</div>
{item.value != null && (
<span className="font-mono font-medium text-foreground tabular-nums">
{typeof item.value === "number"
? item.value.toLocaleString()
: String(item.value)}
</span>
)}
</div>
</>
)}
</div>
)
})}
</div>
</div>
)
}
const ChartLegend = RechartsPrimitive.Legend
function ChartLegendContent({
className,
hideIcon = false,
payload,
verticalAlign = "bottom",
nameKey,
}: React.ComponentProps<"div"> & {
hideIcon?: boolean
nameKey?: string
} & RechartsPrimitive.DefaultLegendContentProps) {
const { config } = useChart()
if (!payload?.length) {
return null
}
return (
<div
className={cn(
"flex items-center justify-center gap-4",
verticalAlign === "top" ? "pb-3" : "pt-3",
className
)}
>
{payload
.filter((item) => item.type !== "none")
.map((item, index) => {
const key = `${nameKey ?? item.dataKey ?? "value"}`
const itemConfig = getPayloadConfigFromPayload(config, item, key)
return (
<div
key={index}
className={cn(
"flex items-center gap-1.5 [&>svg]:h-3 [&>svg]:w-3 [&>svg]:text-muted-foreground"
)}
>
{itemConfig?.icon && !hideIcon ? (
<itemConfig.icon />
) : (
<div
className="h-2 w-2 shrink-0 rounded-[2px]"
style={{
backgroundColor: item.color,
}}
/>
)}
{itemConfig?.label}
</div>
)
})}
</div>
)
}
function getPayloadConfigFromPayload(
config: ChartConfig,
payload: unknown,
key: string
) {
if (typeof payload !== "object" || payload === null) {
return undefined
}
const payloadPayload =
"payload" in payload &&
typeof payload.payload === "object" &&
payload.payload !== null
? payload.payload
: undefined
let configLabelKey: string = key
if (
key in payload &&
typeof payload[key as keyof typeof payload] === "string"
) {
configLabelKey = payload[key as keyof typeof payload] as string
} else if (
payloadPayload &&
key in payloadPayload &&
typeof payloadPayload[key as keyof typeof payloadPayload] === "string"
) {
configLabelKey = payloadPayload[
key as keyof typeof payloadPayload
] as string
}
return configLabelKey in config ? config[configLabelKey] : config[key]
}
export {
ChartContainer,
ChartTooltip,
ChartTooltipContent,
ChartLegend,
ChartLegendContent,
ChartStyle,
}
+33
View File
@@ -0,0 +1,33 @@
"use client"
import * as React from "react"
import { Checkbox as CheckboxPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
import { CheckIcon, MinusIcon } from "lucide-react"
function Checkbox({
className,
...props
}: React.ComponentProps<typeof CheckboxPrimitive.Root>) {
return (
<CheckboxPrimitive.Root
data-slot="checkbox"
className={cn(
"peer relative flex size-4 shrink-0 items-center justify-center rounded-[4px] border border-input transition-colors outline-none group-has-disabled/field:opacity-50 after:absolute after:-inset-x-3 after:-inset-y-2 focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 aria-invalid:aria-checked:border-primary dark:bg-input/30 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 data-checked:border-primary data-checked:bg-primary data-checked:text-primary-foreground dark:data-checked:bg-primary",
className
)}
{...props}
>
<CheckboxPrimitive.Indicator
data-slot="checkbox-indicator"
className="group/checkbox-indicator grid place-content-center text-current transition-none [&>svg]:size-3.5"
>
<CheckIcon className="group-data-[state=indeterminate]/checkbox-indicator:hidden" />
<MinusIcon className="hidden group-data-[state=indeterminate]/checkbox-indicator:block" />
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root>
)
}
export { Checkbox }
+33
View File
@@ -0,0 +1,33 @@
"use client"
import { Collapsible as CollapsiblePrimitive } from "radix-ui"
function Collapsible({
...props
}: React.ComponentProps<typeof CollapsiblePrimitive.Root>) {
return <CollapsiblePrimitive.Root data-slot="collapsible" {...props} />
}
function CollapsibleTrigger({
...props
}: React.ComponentProps<typeof CollapsiblePrimitive.CollapsibleTrigger>) {
return (
<CollapsiblePrimitive.CollapsibleTrigger
data-slot="collapsible-trigger"
{...props}
/>
)
}
function CollapsibleContent({
...props
}: React.ComponentProps<typeof CollapsiblePrimitive.CollapsibleContent>) {
return (
<CollapsiblePrimitive.CollapsibleContent
data-slot="collapsible-content"
{...props}
/>
)
}
export { Collapsible, CollapsibleTrigger, CollapsibleContent }
+302
View File
@@ -0,0 +1,302 @@
"use client"
import * as React from "react"
import { Combobox as ComboboxPrimitive } from "@base-ui/react"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import {
InputGroup,
InputGroupAddon,
InputGroupButton,
InputGroupInput,
} from "@/components/ui/input-group"
import { ChevronDownIcon, XIcon, CheckIcon } from "lucide-react"
const Combobox = ComboboxPrimitive.Root
function ComboboxValue({ ...props }: ComboboxPrimitive.Value.Props) {
return <ComboboxPrimitive.Value data-slot="combobox-value" {...props} />
}
function ComboboxTrigger({
className,
children,
...props
}: ComboboxPrimitive.Trigger.Props) {
return (
<ComboboxPrimitive.Trigger
data-slot="combobox-trigger"
className={cn("[&_svg:not([class*='size-'])]:size-4", className)}
{...props}
>
{children}
<ChevronDownIcon className="pointer-events-none size-4 text-muted-foreground" />
</ComboboxPrimitive.Trigger>
)
}
function ComboboxClear({ className, ...props }: ComboboxPrimitive.Clear.Props) {
return (
<ComboboxPrimitive.Clear
data-slot="combobox-clear"
render={<InputGroupButton variant="ghost" size="icon-xs" />}
className={cn(className)}
{...props}
>
<XIcon className="pointer-events-none" />
</ComboboxPrimitive.Clear>
)
}
function ComboboxInput({
className,
children,
disabled = false,
showTrigger = true,
showClear = false,
...props
}: ComboboxPrimitive.Input.Props & {
showTrigger?: boolean
showClear?: boolean
}) {
return (
<InputGroup className={cn("w-auto", className)}>
<ComboboxPrimitive.Input
render={<InputGroupInput disabled={disabled} />}
{...props}
/>
<InputGroupAddon align="inline-end">
{showTrigger && (
<InputGroupButton
size="icon-xs"
variant="ghost"
asChild
data-slot="input-group-button"
className="group-has-data-[slot=combobox-clear]/input-group:hidden data-pressed:bg-transparent"
disabled={disabled}
>
<ComboboxTrigger />
</InputGroupButton>
)}
{showClear && <ComboboxClear disabled={disabled} />}
</InputGroupAddon>
{children}
</InputGroup>
)
}
function ComboboxContent({
className,
side = "bottom",
sideOffset = 6,
align = "start",
alignOffset = 0,
anchor,
portalContainer,
...props
}: ComboboxPrimitive.Popup.Props &
Pick<
ComboboxPrimitive.Positioner.Props,
"side" | "align" | "sideOffset" | "alignOffset" | "anchor"
> & {
portalContainer?: ComboboxPrimitive.Portal.Props["container"]
}) {
return (
<ComboboxPrimitive.Portal container={portalContainer}>
<ComboboxPrimitive.Positioner
side={side}
sideOffset={sideOffset}
align={align}
alignOffset={alignOffset}
anchor={anchor}
className="isolate z-50"
>
<ComboboxPrimitive.Popup
data-slot="combobox-content"
data-chips={!!anchor}
className={cn("group/combobox-content relative max-h-(--available-height) w-(--anchor-width) max-w-(--available-width) min-w-[calc(var(--anchor-width)+--spacing(7))] origin-(--transform-origin) overflow-hidden rounded-lg bg-popover text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 data-[chips=true]:min-w-(--anchor-width) data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 *:data-[slot=input-group]:m-1 *:data-[slot=input-group]:mb-0 *:data-[slot=input-group]:h-8 *:data-[slot=input-group]:border-input/30 *:data-[slot=input-group]:bg-input/30 *:data-[slot=input-group]:shadow-none data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95", className )}
{...props}
/>
</ComboboxPrimitive.Positioner>
</ComboboxPrimitive.Portal>
)
}
function ComboboxList({ className, ...props }: ComboboxPrimitive.List.Props) {
return (
<ComboboxPrimitive.List
data-slot="combobox-list"
className={cn(
"no-scrollbar max-h-[min(calc(--spacing(72)---spacing(9)),calc(var(--available-height)---spacing(9)))] scroll-py-1 overflow-y-auto overscroll-contain p-1 data-empty:p-0",
className
)}
{...props}
/>
)
}
function ComboboxItem({
className,
children,
...props
}: ComboboxPrimitive.Item.Props) {
return (
<ComboboxPrimitive.Item
data-slot="combobox-item"
className={cn(
"relative flex w-full cursor-default items-center gap-2 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none data-highlighted:bg-accent data-highlighted:text-accent-foreground not-data-[variant=destructive]:data-highlighted:**:text-accent-foreground data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
{children}
<ComboboxPrimitive.ItemIndicator
render={
<span className="pointer-events-none absolute right-2 flex size-4 items-center justify-center" />
}
>
<CheckIcon className="pointer-events-none" />
</ComboboxPrimitive.ItemIndicator>
</ComboboxPrimitive.Item>
)
}
function ComboboxGroup({ className, ...props }: ComboboxPrimitive.Group.Props) {
return (
<ComboboxPrimitive.Group
data-slot="combobox-group"
className={cn(className)}
{...props}
/>
)
}
function ComboboxLabel({
className,
...props
}: ComboboxPrimitive.GroupLabel.Props) {
return (
<ComboboxPrimitive.GroupLabel
data-slot="combobox-label"
className={cn("px-2 py-1.5 text-xs text-muted-foreground", className)}
{...props}
/>
)
}
function ComboboxCollection({ ...props }: ComboboxPrimitive.Collection.Props) {
return (
<ComboboxPrimitive.Collection data-slot="combobox-collection" {...props} />
)
}
function ComboboxEmpty({ className, ...props }: ComboboxPrimitive.Empty.Props) {
return (
<ComboboxPrimitive.Empty
data-slot="combobox-empty"
className={cn(
"hidden w-full justify-center py-2 text-center text-sm text-muted-foreground group-data-empty/combobox-content:flex",
className
)}
{...props}
/>
)
}
function ComboboxSeparator({
className,
...props
}: ComboboxPrimitive.Separator.Props) {
return (
<ComboboxPrimitive.Separator
data-slot="combobox-separator"
className={cn("-mx-1 my-1 h-px bg-border", className)}
{...props}
/>
)
}
function ComboboxChips({
className,
...props
}: React.ComponentPropsWithRef<typeof ComboboxPrimitive.Chips> &
ComboboxPrimitive.Chips.Props) {
return (
<ComboboxPrimitive.Chips
data-slot="combobox-chips"
className={cn(
"flex min-h-8 flex-wrap items-center gap-1 rounded-lg border border-input bg-transparent bg-clip-padding px-2.5 py-1 text-sm transition-colors focus-within:border-ring focus-within:ring-3 focus-within:ring-ring/50 has-aria-invalid:border-destructive has-aria-invalid:ring-3 has-aria-invalid:ring-destructive/20 has-data-[slot=combobox-chip]:px-1 dark:bg-input/30 dark:has-aria-invalid:border-destructive/50 dark:has-aria-invalid:ring-destructive/40",
className
)}
{...props}
/>
)
}
function ComboboxChip({
className,
children,
showRemove = true,
...props
}: ComboboxPrimitive.Chip.Props & {
showRemove?: boolean
}) {
return (
<ComboboxPrimitive.Chip
data-slot="combobox-chip"
className={cn(
"flex h-[calc(--spacing(5.25))] w-fit items-center justify-center gap-1 rounded-sm bg-muted px-1.5 text-xs font-medium whitespace-nowrap text-foreground has-disabled:pointer-events-none has-disabled:cursor-not-allowed has-disabled:opacity-50 has-data-[slot=combobox-chip-remove]:pr-0",
className
)}
{...props}
>
{children}
{showRemove && (
<ComboboxPrimitive.ChipRemove
render={<Button variant="ghost" size="icon-xs" />}
className="-ml-1 opacity-50 hover:opacity-100"
data-slot="combobox-chip-remove"
>
<XIcon className="pointer-events-none" />
</ComboboxPrimitive.ChipRemove>
)}
</ComboboxPrimitive.Chip>
)
}
function ComboboxChipsInput({
className,
...props
}: ComboboxPrimitive.Input.Props) {
return (
<ComboboxPrimitive.Input
data-slot="combobox-chip-input"
className={cn("min-w-16 flex-1 outline-none", className)}
{...props}
/>
)
}
function useComboboxAnchor() {
return React.useRef<HTMLDivElement | null>(null)
}
export {
Combobox,
ComboboxInput,
ComboboxContent,
ComboboxList,
ComboboxItem,
ComboboxGroup,
ComboboxLabel,
ComboboxCollection,
ComboboxEmpty,
ComboboxSeparator,
ComboboxChips,
ComboboxChip,
ComboboxChipsInput,
ComboboxTrigger,
ComboboxValue,
useComboboxAnchor,
}
+195
View File
@@ -0,0 +1,195 @@
"use client"
import * as React from "react"
import { Command as CommandPrimitive } from "cmdk"
import { cn } from "@/lib/utils"
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog"
import {
InputGroup,
InputGroupAddon,
} from "@/components/ui/input-group"
import { SearchIcon, CheckIcon } from "lucide-react"
function Command({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive>) {
return (
<CommandPrimitive
data-slot="command"
className={cn(
"flex size-full flex-col overflow-hidden rounded-xl! bg-popover p-1 text-popover-foreground",
className
)}
{...props}
/>
)
}
function CommandDialog({
title = "Command Palette",
description = "Search for a command to run...",
children,
className,
showCloseButton = false,
...props
}: React.ComponentProps<typeof Dialog> & {
title?: string
description?: string
className?: string
showCloseButton?: boolean
}) {
return (
<Dialog {...props}>
<DialogHeader className="sr-only">
<DialogTitle>{title}</DialogTitle>
<DialogDescription>{description}</DialogDescription>
</DialogHeader>
<DialogContent
className={cn(
"top-1/3 translate-y-0 overflow-hidden rounded-xl! p-0",
className
)}
showCloseButton={showCloseButton}
>
{children}
</DialogContent>
</Dialog>
)
}
function CommandInput({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.Input>) {
return (
<div data-slot="command-input-wrapper" className="p-1 pb-0">
<InputGroup className="h-8! rounded-lg! border-input/30 bg-input/30 shadow-none! *:data-[slot=input-group-addon]:pl-2!">
<CommandPrimitive.Input
data-slot="command-input"
className={cn(
"w-full text-sm outline-hidden disabled:cursor-not-allowed disabled:opacity-50",
className
)}
{...props}
/>
<InputGroupAddon>
<SearchIcon className="size-4 shrink-0 opacity-50" />
</InputGroupAddon>
</InputGroup>
</div>
)
}
function CommandList({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.List>) {
return (
<CommandPrimitive.List
data-slot="command-list"
className={cn(
"no-scrollbar max-h-72 scroll-py-1 overflow-x-hidden overflow-y-auto outline-none",
className
)}
{...props}
/>
)
}
function CommandEmpty({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.Empty>) {
return (
<CommandPrimitive.Empty
data-slot="command-empty"
className={cn("py-6 text-center text-sm", className)}
{...props}
/>
)
}
function CommandGroup({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.Group>) {
return (
<CommandPrimitive.Group
data-slot="command-group"
className={cn(
"overflow-hidden p-1 text-foreground **:[[cmdk-group-heading]]:px-2 **:[[cmdk-group-heading]]:py-1.5 **:[[cmdk-group-heading]]:text-xs **:[[cmdk-group-heading]]:font-medium **:[[cmdk-group-heading]]:text-muted-foreground",
className
)}
{...props}
/>
)
}
function CommandSeparator({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.Separator>) {
return (
<CommandPrimitive.Separator
data-slot="command-separator"
className={cn("-mx-1 h-px bg-border", className)}
{...props}
/>
)
}
function CommandItem({
className,
children,
...props
}: React.ComponentProps<typeof CommandPrimitive.Item>) {
return (
<CommandPrimitive.Item
data-slot="command-item"
className={cn(
"group/command-item relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none in-data-[slot=dialog-content]:rounded-lg! data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 data-selected:bg-muted data-selected:text-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 data-selected:*:[svg]:text-foreground",
className
)}
{...props}
>
{children}
<CheckIcon className="ml-auto opacity-0 group-has-data-[slot=command-shortcut]/command-item:hidden group-data-[checked=true]/command-item:opacity-100" />
</CommandPrimitive.Item>
)
}
function CommandShortcut({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
data-slot="command-shortcut"
className={cn(
"ml-auto text-xs tracking-widest text-muted-foreground group-data-selected/command-item:text-foreground",
className
)}
{...props}
/>
)
}
export {
Command,
CommandDialog,
CommandInput,
CommandList,
CommandEmpty,
CommandGroup,
CommandItem,
CommandShortcut,
CommandSeparator,
}
+263
View File
@@ -0,0 +1,263 @@
"use client"
import * as React from "react"
import { ContextMenu as ContextMenuPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
import { ChevronRightIcon, CheckIcon } from "lucide-react"
function ContextMenu({
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Root>) {
return <ContextMenuPrimitive.Root data-slot="context-menu" {...props} />
}
function ContextMenuTrigger({
className,
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Trigger>) {
return (
<ContextMenuPrimitive.Trigger
data-slot="context-menu-trigger"
className={cn("select-none", className)}
{...props}
/>
)
}
function ContextMenuGroup({
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Group>) {
return (
<ContextMenuPrimitive.Group data-slot="context-menu-group" {...props} />
)
}
function ContextMenuPortal({
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Portal>) {
return (
<ContextMenuPrimitive.Portal data-slot="context-menu-portal" {...props} />
)
}
function ContextMenuSub({
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Sub>) {
return <ContextMenuPrimitive.Sub data-slot="context-menu-sub" {...props} />
}
function ContextMenuRadioGroup({
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.RadioGroup>) {
return (
<ContextMenuPrimitive.RadioGroup
data-slot="context-menu-radio-group"
{...props}
/>
)
}
function ContextMenuContent({
className,
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Content> & {
side?: "top" | "right" | "bottom" | "left"
}) {
return (
<ContextMenuPrimitive.Portal>
<ContextMenuPrimitive.Content
data-slot="context-menu-content"
className={cn("z-50 max-h-(--radix-context-menu-content-available-height) min-w-36 origin-(--radix-context-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-lg bg-popover p-1 text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95", className )}
{...props}
/>
</ContextMenuPrimitive.Portal>
)
}
function ContextMenuItem({
className,
inset,
variant = "default",
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Item> & {
inset?: boolean
variant?: "default" | "destructive"
}) {
return (
<ContextMenuPrimitive.Item
data-slot="context-menu-item"
data-inset={inset}
data-variant={variant}
className={cn(
"group/context-menu-item relative flex cursor-default items-center gap-1.5 rounded-md px-1.5 py-1 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-inset:pl-7 data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 data-[variant=destructive]:focus:text-destructive dark:data-[variant=destructive]:focus:bg-destructive/20 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 focus:*:[svg]:text-accent-foreground data-[variant=destructive]:*:[svg]:text-destructive",
className
)}
{...props}
/>
)
}
function ContextMenuSubTrigger({
className,
inset,
children,
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.SubTrigger> & {
inset?: boolean
}) {
return (
<ContextMenuPrimitive.SubTrigger
data-slot="context-menu-sub-trigger"
data-inset={inset}
className={cn(
"flex cursor-default items-center gap-1.5 rounded-md px-1.5 py-1 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-inset:pl-7 data-open:bg-accent data-open:text-accent-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
{children}
<ChevronRightIcon className="ml-auto" />
</ContextMenuPrimitive.SubTrigger>
)
}
function ContextMenuSubContent({
className,
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.SubContent>) {
return (
<ContextMenuPrimitive.SubContent
data-slot="context-menu-sub-content"
className={cn("z-50 min-w-32 origin-(--radix-context-menu-content-transform-origin) overflow-hidden rounded-lg border bg-popover p-1 text-popover-foreground shadow-lg duration-100 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95", className )}
{...props}
/>
)
}
function ContextMenuCheckboxItem({
className,
children,
checked,
inset,
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.CheckboxItem> & {
inset?: boolean
}) {
return (
<ContextMenuPrimitive.CheckboxItem
data-slot="context-menu-checkbox-item"
data-inset={inset}
className={cn(
"relative flex cursor-default items-center gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-inset:pl-7 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
checked={checked}
{...props}
>
<span className="pointer-events-none absolute right-2">
<ContextMenuPrimitive.ItemIndicator>
<CheckIcon
/>
</ContextMenuPrimitive.ItemIndicator>
</span>
{children}
</ContextMenuPrimitive.CheckboxItem>
)
}
function ContextMenuRadioItem({
className,
children,
inset,
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.RadioItem> & {
inset?: boolean
}) {
return (
<ContextMenuPrimitive.RadioItem
data-slot="context-menu-radio-item"
data-inset={inset}
className={cn(
"relative flex cursor-default items-center gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-inset:pl-7 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
<span className="pointer-events-none absolute right-2">
<ContextMenuPrimitive.ItemIndicator>
<CheckIcon
/>
</ContextMenuPrimitive.ItemIndicator>
</span>
{children}
</ContextMenuPrimitive.RadioItem>
)
}
function ContextMenuLabel({
className,
inset,
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Label> & {
inset?: boolean
}) {
return (
<ContextMenuPrimitive.Label
data-slot="context-menu-label"
data-inset={inset}
className={cn(
"px-1.5 py-1 text-xs font-medium text-muted-foreground data-inset:pl-7",
className
)}
{...props}
/>
)
}
function ContextMenuSeparator({
className,
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Separator>) {
return (
<ContextMenuPrimitive.Separator
data-slot="context-menu-separator"
className={cn("-mx-1 my-1 h-px bg-border", className)}
{...props}
/>
)
}
function ContextMenuShortcut({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
data-slot="context-menu-shortcut"
className={cn(
"ml-auto text-xs tracking-widest text-muted-foreground group-focus/context-menu-item:text-accent-foreground",
className
)}
{...props}
/>
)
}
export {
ContextMenu,
ContextMenuTrigger,
ContextMenuContent,
ContextMenuItem,
ContextMenuCheckboxItem,
ContextMenuRadioItem,
ContextMenuLabel,
ContextMenuSeparator,
ContextMenuShortcut,
ContextMenuGroup,
ContextMenuPortal,
ContextMenuSub,
ContextMenuSubContent,
ContextMenuSubTrigger,
ContextMenuRadioGroup,
}
+181
View File
@@ -0,0 +1,181 @@
"use client"
import * as React from "react"
import { Dialog as DialogPrimitive } from "radix-ui"
import { cn, radixOverlayWasOpenAtPointerDown } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import { XIcon } from "lucide-react"
function Dialog({
...props
}: React.ComponentProps<typeof DialogPrimitive.Root>) {
return <DialogPrimitive.Root data-slot="dialog" {...props} />
}
function DialogTrigger({
...props
}: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />
}
function DialogPortal({
...props
}: React.ComponentProps<typeof DialogPrimitive.Portal>) {
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />
}
function DialogClose({
...props
}: React.ComponentProps<typeof DialogPrimitive.Close>) {
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />
}
function DialogOverlay({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Overlay>) {
return (
<DialogPrimitive.Overlay
data-slot="dialog-overlay"
className={cn(
"fixed inset-0 isolate z-50 bg-black/10 duration-100 supports-backdrop-filter:backdrop-blur-xs data-open:animate-in data-open:fade-in-0 data-closed:animate-out data-closed:fade-out-0",
className
)}
{...props}
/>
)
}
function DialogContent({
className,
children,
showCloseButton = true,
onInteractOutside,
...props
}: React.ComponentProps<typeof DialogPrimitive.Content> & {
showCloseButton?: boolean
}) {
return (
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Content
data-slot="dialog-content"
// 关闭对话框的唯一条件:点击的是遮罩(灰色背景)本身,且此刻没有任何 Radix 弹层
// (Select 下拉等)开着。其余"外部交互"一律挡掉(Esc、右上角 ✕ 仍可关):
// · 点弹层里的选项 → target 不是遮罩 → 挡;
// · 弹层开着时点对话框外/遮罩想收起它 → 有弹层开着 → 挡(只收弹层,不关对话框);
// · 弹层收起时焦点移动被 Radix 误判为焦点移出 → target 不是遮罩 → 挡。
// (onInteractOutside 在指针/焦点两条路径都会触发。)调用方仍可追加逻辑。
onInteractOutside={(e) => {
const target = e.detail.originalEvent.target as Element | null
const onOverlay = !!target?.closest?.("[data-slot='dialog-overlay']")
if (!onOverlay || radixOverlayWasOpenAtPointerDown()) e.preventDefault()
onInteractOutside?.(e)
}}
className={cn(
"fixed top-1/2 left-1/2 z-50 grid max-h-[90dvh] w-full max-w-[calc(100%-2rem)] -translate-x-1/2 -translate-y-1/2 gap-4 overflow-y-auto rounded-xl bg-popover p-4 text-sm text-popover-foreground ring-1 ring-foreground/10 duration-100 outline-none sm:max-w-sm data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
className
)}
{...props}
>
{children}
{showCloseButton && (
<DialogPrimitive.Close data-slot="dialog-close" asChild>
<Button
variant="ghost"
className="absolute top-2 right-2"
size="icon-sm"
>
<XIcon
/>
<span className="sr-only">Close</span>
</Button>
</DialogPrimitive.Close>
)}
</DialogPrimitive.Content>
</DialogPortal>
)
}
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="dialog-header"
className={cn("flex flex-col gap-2", className)}
{...props}
/>
)
}
function DialogFooter({
className,
showCloseButton = false,
children,
...props
}: React.ComponentProps<"div"> & {
showCloseButton?: boolean
}) {
return (
<div
data-slot="dialog-footer"
className={cn(
"-mx-4 -mb-4 flex flex-col-reverse gap-2 rounded-b-xl border-t bg-muted/50 p-4 sm:flex-row sm:justify-end",
className
)}
{...props}
>
{children}
{showCloseButton && (
<DialogPrimitive.Close asChild>
<Button variant="outline">Close</Button>
</DialogPrimitive.Close>
)}
</div>
)
}
function DialogTitle({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Title>) {
return (
<DialogPrimitive.Title
data-slot="dialog-title"
className={cn(
"font-heading text-base leading-none font-medium",
className
)}
{...props}
/>
)
}
function DialogDescription({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Description>) {
return (
<DialogPrimitive.Description
data-slot="dialog-description"
className={cn(
"text-sm text-muted-foreground *:[a]:underline *:[a]:underline-offset-3 *:[a]:hover:text-foreground",
className
)}
{...props}
/>
)
}
export {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogOverlay,
DialogPortal,
DialogTitle,
DialogTrigger,
}
+22
View File
@@ -0,0 +1,22 @@
"use client"
import * as React from "react"
import { Direction } from "radix-ui"
function DirectionProvider({
dir,
direction,
children,
}: React.ComponentProps<typeof Direction.DirectionProvider> & {
direction?: React.ComponentProps<typeof Direction.DirectionProvider>["dir"]
}) {
return (
<Direction.DirectionProvider dir={direction ?? dir}>
{children}
</Direction.DirectionProvider>
)
}
const useDirection = Direction.useDirection
export { DirectionProvider, useDirection }
+134
View File
@@ -0,0 +1,134 @@
"use client"
import * as React from "react"
import { Drawer as DrawerPrimitive } from "vaul"
import { cn } from "@/lib/utils"
function Drawer({
...props
}: React.ComponentProps<typeof DrawerPrimitive.Root>) {
return <DrawerPrimitive.Root data-slot="drawer" {...props} />
}
function DrawerTrigger({
...props
}: React.ComponentProps<typeof DrawerPrimitive.Trigger>) {
return <DrawerPrimitive.Trigger data-slot="drawer-trigger" {...props} />
}
function DrawerPortal({
...props
}: React.ComponentProps<typeof DrawerPrimitive.Portal>) {
return <DrawerPrimitive.Portal data-slot="drawer-portal" {...props} />
}
function DrawerClose({
...props
}: React.ComponentProps<typeof DrawerPrimitive.Close>) {
return <DrawerPrimitive.Close data-slot="drawer-close" {...props} />
}
function DrawerOverlay({
className,
...props
}: React.ComponentProps<typeof DrawerPrimitive.Overlay>) {
return (
<DrawerPrimitive.Overlay
data-slot="drawer-overlay"
className={cn(
"fixed inset-0 z-50 bg-black/10 supports-backdrop-filter:backdrop-blur-xs data-open:animate-in data-open:fade-in-0 data-closed:animate-out data-closed:fade-out-0",
className
)}
{...props}
/>
)
}
function DrawerContent({
className,
children,
...props
}: React.ComponentProps<typeof DrawerPrimitive.Content>) {
return (
<DrawerPortal data-slot="drawer-portal">
<DrawerOverlay />
<DrawerPrimitive.Content
data-slot="drawer-content"
className={cn(
"group/drawer-content fixed z-50 flex h-auto flex-col bg-popover text-sm text-popover-foreground data-[vaul-drawer-direction=bottom]:inset-x-0 data-[vaul-drawer-direction=bottom]:bottom-0 data-[vaul-drawer-direction=bottom]:mt-24 data-[vaul-drawer-direction=bottom]:max-h-[80vh] data-[vaul-drawer-direction=bottom]:rounded-t-xl data-[vaul-drawer-direction=bottom]:border-t data-[vaul-drawer-direction=left]:inset-y-0 data-[vaul-drawer-direction=left]:left-0 data-[vaul-drawer-direction=left]:w-3/4 data-[vaul-drawer-direction=left]:rounded-r-xl data-[vaul-drawer-direction=left]:border-r data-[vaul-drawer-direction=right]:inset-y-0 data-[vaul-drawer-direction=right]:right-0 data-[vaul-drawer-direction=right]:w-3/4 data-[vaul-drawer-direction=right]:rounded-l-xl data-[vaul-drawer-direction=right]:border-l data-[vaul-drawer-direction=top]:inset-x-0 data-[vaul-drawer-direction=top]:top-0 data-[vaul-drawer-direction=top]:mb-24 data-[vaul-drawer-direction=top]:max-h-[80vh] data-[vaul-drawer-direction=top]:rounded-b-xl data-[vaul-drawer-direction=top]:border-b data-[vaul-drawer-direction=left]:sm:max-w-sm data-[vaul-drawer-direction=right]:sm:max-w-sm",
className
)}
{...props}
>
<div className="mx-auto mt-4 hidden h-1 w-[100px] shrink-0 rounded-full bg-muted group-data-[vaul-drawer-direction=bottom]/drawer-content:block" />
{children}
</DrawerPrimitive.Content>
</DrawerPortal>
)
}
function DrawerHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="drawer-header"
className={cn(
"flex flex-col gap-0.5 p-4 group-data-[vaul-drawer-direction=bottom]/drawer-content:text-center group-data-[vaul-drawer-direction=top]/drawer-content:text-center md:gap-0.5 md:text-left",
className
)}
{...props}
/>
)
}
function DrawerFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="drawer-footer"
className={cn("mt-auto flex flex-col gap-2 p-4", className)}
{...props}
/>
)
}
function DrawerTitle({
className,
...props
}: React.ComponentProps<typeof DrawerPrimitive.Title>) {
return (
<DrawerPrimitive.Title
data-slot="drawer-title"
className={cn(
"font-heading text-base font-medium text-foreground",
className
)}
{...props}
/>
)
}
function DrawerDescription({
className,
...props
}: React.ComponentProps<typeof DrawerPrimitive.Description>) {
return (
<DrawerPrimitive.Description
data-slot="drawer-description"
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
)
}
export {
Drawer,
DrawerPortal,
DrawerOverlay,
DrawerTrigger,
DrawerClose,
DrawerContent,
DrawerHeader,
DrawerFooter,
DrawerTitle,
DrawerDescription,
}
+269
View File
@@ -0,0 +1,269 @@
"use client"
import * as React from "react"
import { DropdownMenu as DropdownMenuPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
import { CheckIcon, ChevronRightIcon } from "lucide-react"
function DropdownMenu({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Root>) {
return <DropdownMenuPrimitive.Root data-slot="dropdown-menu" {...props} />
}
function DropdownMenuPortal({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Portal>) {
return (
<DropdownMenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />
)
}
function DropdownMenuTrigger({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Trigger>) {
return (
<DropdownMenuPrimitive.Trigger
data-slot="dropdown-menu-trigger"
{...props}
/>
)
}
function DropdownMenuContent({
className,
align = "start",
sideOffset = 4,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Content>) {
return (
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.Content
data-slot="dropdown-menu-content"
sideOffset={sideOffset}
align={align}
className={cn("z-50 max-h-(--radix-dropdown-menu-content-available-height) w-(--radix-dropdown-menu-trigger-width) min-w-32 origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-lg bg-popover p-1 text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:overflow-hidden data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95", className )}
{...props}
/>
</DropdownMenuPrimitive.Portal>
)
}
function DropdownMenuGroup({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Group>) {
return (
<DropdownMenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />
)
}
function DropdownMenuItem({
className,
inset,
variant = "default",
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Item> & {
inset?: boolean
variant?: "default" | "destructive"
}) {
return (
<DropdownMenuPrimitive.Item
data-slot="dropdown-menu-item"
data-inset={inset}
data-variant={variant}
className={cn(
"group/dropdown-menu-item relative flex cursor-default items-center gap-1.5 rounded-md px-1.5 py-1 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-inset:pl-7 data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 data-[variant=destructive]:focus:text-destructive dark:data-[variant=destructive]:focus:bg-destructive/20 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 data-[variant=destructive]:*:[svg]:text-destructive",
className
)}
{...props}
/>
)
}
function DropdownMenuCheckboxItem({
className,
children,
checked,
inset,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.CheckboxItem> & {
inset?: boolean
}) {
return (
<DropdownMenuPrimitive.CheckboxItem
data-slot="dropdown-menu-checkbox-item"
data-inset={inset}
className={cn(
"relative flex cursor-default items-center gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground focus:**:text-accent-foreground data-inset:pl-7 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
checked={checked}
{...props}
>
<span
className="pointer-events-none absolute right-2 flex items-center justify-center"
data-slot="dropdown-menu-checkbox-item-indicator"
>
<DropdownMenuPrimitive.ItemIndicator>
<CheckIcon
/>
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.CheckboxItem>
)
}
function DropdownMenuRadioGroup({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioGroup>) {
return (
<DropdownMenuPrimitive.RadioGroup
data-slot="dropdown-menu-radio-group"
{...props}
/>
)
}
function DropdownMenuRadioItem({
className,
children,
inset,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioItem> & {
inset?: boolean
}) {
return (
<DropdownMenuPrimitive.RadioItem
data-slot="dropdown-menu-radio-item"
data-inset={inset}
className={cn(
"relative flex cursor-default items-center gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground focus:**:text-accent-foreground data-inset:pl-7 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
<span
className="pointer-events-none absolute right-2 flex items-center justify-center"
data-slot="dropdown-menu-radio-item-indicator"
>
<DropdownMenuPrimitive.ItemIndicator>
<CheckIcon
/>
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.RadioItem>
)
}
function DropdownMenuLabel({
className,
inset,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Label> & {
inset?: boolean
}) {
return (
<DropdownMenuPrimitive.Label
data-slot="dropdown-menu-label"
data-inset={inset}
className={cn(
"px-1.5 py-1 text-xs font-medium text-muted-foreground data-inset:pl-7",
className
)}
{...props}
/>
)
}
function DropdownMenuSeparator({
className,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Separator>) {
return (
<DropdownMenuPrimitive.Separator
data-slot="dropdown-menu-separator"
className={cn("-mx-1 my-1 h-px bg-border", className)}
{...props}
/>
)
}
function DropdownMenuShortcut({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
data-slot="dropdown-menu-shortcut"
className={cn(
"ml-auto text-xs tracking-widest text-muted-foreground group-focus/dropdown-menu-item:text-accent-foreground",
className
)}
{...props}
/>
)
}
function DropdownMenuSub({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Sub>) {
return <DropdownMenuPrimitive.Sub data-slot="dropdown-menu-sub" {...props} />
}
function DropdownMenuSubTrigger({
className,
inset,
children,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubTrigger> & {
inset?: boolean
}) {
return (
<DropdownMenuPrimitive.SubTrigger
data-slot="dropdown-menu-sub-trigger"
data-inset={inset}
className={cn(
"flex cursor-default items-center gap-1.5 rounded-md px-1.5 py-1 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-inset:pl-7 data-open:bg-accent data-open:text-accent-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
{children}
<ChevronRightIcon className="ml-auto" />
</DropdownMenuPrimitive.SubTrigger>
)
}
function DropdownMenuSubContent({
className,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubContent>) {
return (
<DropdownMenuPrimitive.SubContent
data-slot="dropdown-menu-sub-content"
className={cn("z-50 min-w-[96px] origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-lg bg-popover p-1 text-popover-foreground shadow-lg ring-1 ring-foreground/10 duration-100 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95", className )}
{...props}
/>
)
}
export {
DropdownMenu,
DropdownMenuPortal,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuLabel,
DropdownMenuItem,
DropdownMenuCheckboxItem,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuSub,
DropdownMenuSubTrigger,
DropdownMenuSubContent,
}
+104
View File
@@ -0,0 +1,104 @@
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
function Empty({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="empty"
className={cn(
"flex w-full min-w-0 flex-1 flex-col items-center justify-center gap-4 rounded-xl border-dashed p-6 text-center text-balance",
className
)}
{...props}
/>
)
}
function EmptyHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="empty-header"
className={cn("flex max-w-sm flex-col items-center gap-2", className)}
{...props}
/>
)
}
const emptyMediaVariants = cva(
"mb-2 flex shrink-0 items-center justify-center [&_svg]:pointer-events-none [&_svg]:shrink-0",
{
variants: {
variant: {
default: "bg-transparent",
icon: "flex size-8 shrink-0 items-center justify-center rounded-lg bg-muted text-foreground [&_svg:not([class*='size-'])]:size-4",
},
},
defaultVariants: {
variant: "default",
},
}
)
function EmptyMedia({
className,
variant = "default",
...props
}: React.ComponentProps<"div"> & VariantProps<typeof emptyMediaVariants>) {
return (
<div
data-slot="empty-icon"
data-variant={variant}
className={cn(emptyMediaVariants({ variant, className }))}
{...props}
/>
)
}
function EmptyTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="empty-title"
className={cn(
"font-heading text-sm font-medium tracking-tight",
className
)}
{...props}
/>
)
}
function EmptyDescription({ className, ...props }: React.ComponentProps<"p">) {
return (
<div
data-slot="empty-description"
className={cn(
"text-sm/relaxed text-muted-foreground [&>a]:underline [&>a]:underline-offset-4 [&>a:hover]:text-primary",
className
)}
{...props}
/>
)
}
function EmptyContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="empty-content"
className={cn(
"flex w-full max-w-sm min-w-0 flex-col items-center gap-2.5 text-sm text-balance",
className
)}
{...props}
/>
)
}
export {
Empty,
EmptyHeader,
EmptyTitle,
EmptyDescription,
EmptyContent,
EmptyMedia,
}
+238
View File
@@ -0,0 +1,238 @@
"use client"
import { useMemo } from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
import { Label } from "@/components/ui/label"
import { Separator } from "@/components/ui/separator"
function FieldSet({ className, ...props }: React.ComponentProps<"fieldset">) {
return (
<fieldset
data-slot="field-set"
className={cn(
"flex flex-col gap-4 has-[>[data-slot=checkbox-group]]:gap-3 has-[>[data-slot=radio-group]]:gap-3",
className
)}
{...props}
/>
)
}
function FieldLegend({
className,
variant = "legend",
...props
}: React.ComponentProps<"legend"> & { variant?: "legend" | "label" }) {
return (
<legend
data-slot="field-legend"
data-variant={variant}
className={cn(
"mb-1.5 font-medium data-[variant=label]:text-sm data-[variant=legend]:text-base",
className
)}
{...props}
/>
)
}
function FieldGroup({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="field-group"
className={cn(
"group/field-group @container/field-group flex w-full flex-col gap-5 data-[slot=checkbox-group]:gap-3 *:data-[slot=field-group]:gap-4",
className
)}
{...props}
/>
)
}
const fieldVariants = cva(
"group/field flex w-full gap-2 data-[invalid=true]:text-destructive",
{
variants: {
orientation: {
vertical: "flex-col *:w-full [&>.sr-only]:w-auto",
horizontal:
"flex-row items-center has-[>[data-slot=field-content]]:items-start *:data-[slot=field-label]:flex-auto has-[>[data-slot=field-content]]:[&>[role=checkbox],[role=radio]]:mt-px",
responsive:
"flex-col *:w-full @md/field-group:flex-row @md/field-group:items-center @md/field-group:*:w-auto @md/field-group:has-[>[data-slot=field-content]]:items-start @md/field-group:*:data-[slot=field-label]:flex-auto [&>.sr-only]:w-auto @md/field-group:has-[>[data-slot=field-content]]:[&>[role=checkbox],[role=radio]]:mt-px",
},
},
defaultVariants: {
orientation: "vertical",
},
}
)
function Field({
className,
orientation = "vertical",
...props
}: React.ComponentProps<"div"> & VariantProps<typeof fieldVariants>) {
return (
<div
role="group"
data-slot="field"
data-orientation={orientation}
className={cn(fieldVariants({ orientation }), className)}
{...props}
/>
)
}
function FieldContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="field-content"
className={cn(
"group/field-content flex flex-1 flex-col gap-0.5 leading-snug",
className
)}
{...props}
/>
)
}
function FieldLabel({
className,
...props
}: React.ComponentProps<typeof Label>) {
return (
<Label
data-slot="field-label"
className={cn(
"group/field-label peer/field-label flex w-fit gap-2 leading-snug group-data-[disabled=true]/field:opacity-50 has-data-checked:border-primary/30 has-data-checked:bg-primary/5 has-[>[data-slot=field]]:rounded-lg has-[>[data-slot=field]]:border *:data-[slot=field]:p-2.5 dark:has-data-checked:border-primary/20 dark:has-data-checked:bg-primary/10",
"has-[>[data-slot=field]]:w-full has-[>[data-slot=field]]:flex-col",
className
)}
{...props}
/>
)
}
function FieldTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="field-label"
className={cn(
"flex w-fit items-center gap-2 text-sm font-medium group-data-[disabled=true]/field:opacity-50",
className
)}
{...props}
/>
)
}
function FieldDescription({ className, ...props }: React.ComponentProps<"p">) {
return (
<p
data-slot="field-description"
className={cn(
"text-left text-sm leading-normal font-normal text-muted-foreground group-has-data-horizontal/field:text-balance [[data-variant=legend]+&]:-mt-1.5",
"last:mt-0 nth-last-2:-mt-1",
"[&>a]:underline [&>a]:underline-offset-4 [&>a:hover]:text-primary",
className
)}
{...props}
/>
)
}
function FieldSeparator({
children,
className,
...props
}: React.ComponentProps<"div"> & {
children?: React.ReactNode
}) {
return (
<div
data-slot="field-separator"
data-content={!!children}
className={cn(
"relative -my-2 h-5 text-sm group-data-[variant=outline]/field-group:-mb-2",
className
)}
{...props}
>
<Separator className="absolute inset-0 top-1/2" />
{children && (
<span
className="relative mx-auto block w-fit bg-background px-2 text-muted-foreground"
data-slot="field-separator-content"
>
{children}
</span>
)}
</div>
)
}
function FieldError({
className,
children,
errors,
...props
}: React.ComponentProps<"div"> & {
errors?: Array<{ message?: string } | undefined>
}) {
const content = useMemo(() => {
if (children) {
return children
}
if (!errors?.length) {
return null
}
const uniqueErrors = [
...new Map(errors.map((error) => [error?.message, error])).values(),
]
if (uniqueErrors?.length == 1) {
return uniqueErrors[0]?.message
}
return (
<ul className="ml-4 flex list-disc flex-col gap-1">
{uniqueErrors.map(
(error, index) =>
error?.message && <li key={index}>{error.message}</li>
)}
</ul>
)
}, [children, errors])
if (!content) {
return null
}
return (
<div
role="alert"
data-slot="field-error"
className={cn("text-sm font-normal text-destructive", className)}
{...props}
>
{content}
</div>
)
}
export {
Field,
FieldLabel,
FieldDescription,
FieldError,
FieldGroup,
FieldLegend,
FieldSeparator,
FieldSet,
FieldContent,
FieldTitle,
}
+44
View File
@@ -0,0 +1,44 @@
"use client"
import * as React from "react"
import { HoverCard as HoverCardPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
function HoverCard({
...props
}: React.ComponentProps<typeof HoverCardPrimitive.Root>) {
return <HoverCardPrimitive.Root data-slot="hover-card" {...props} />
}
function HoverCardTrigger({
...props
}: React.ComponentProps<typeof HoverCardPrimitive.Trigger>) {
return (
<HoverCardPrimitive.Trigger data-slot="hover-card-trigger" {...props} />
)
}
function HoverCardContent({
className,
align = "center",
sideOffset = 4,
...props
}: React.ComponentProps<typeof HoverCardPrimitive.Content>) {
return (
<HoverCardPrimitive.Portal data-slot="hover-card-portal">
<HoverCardPrimitive.Content
data-slot="hover-card-content"
align={align}
sideOffset={sideOffset}
className={cn(
"z-50 w-64 origin-(--radix-hover-card-content-transform-origin) rounded-lg bg-popover p-2.5 text-sm text-popover-foreground shadow-md ring-1 ring-foreground/10 outline-hidden duration-100 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
className
)}
{...props}
/>
</HoverCardPrimitive.Portal>
)
}
export { HoverCard, HoverCardTrigger, HoverCardContent }
+156
View File
@@ -0,0 +1,156 @@
"use client"
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Textarea } from "@/components/ui/textarea"
function InputGroup({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="input-group"
role="group"
className={cn(
"group/input-group relative flex h-8 w-full min-w-0 items-center rounded-lg border border-input transition-colors outline-none in-data-[slot=combobox-content]:focus-within:border-inherit in-data-[slot=combobox-content]:focus-within:ring-0 has-disabled:bg-input/50 has-disabled:opacity-50 has-[[data-slot=input-group-control]:focus-visible]:border-ring has-[[data-slot=input-group-control]:focus-visible]:ring-3 has-[[data-slot=input-group-control]:focus-visible]:ring-ring/50 has-[[data-slot][aria-invalid=true]]:border-destructive has-[[data-slot][aria-invalid=true]]:ring-3 has-[[data-slot][aria-invalid=true]]:ring-destructive/20 has-[>[data-align=block-end]]:h-auto has-[>[data-align=block-end]]:flex-col has-[>[data-align=block-start]]:h-auto has-[>[data-align=block-start]]:flex-col has-[>textarea]:h-auto dark:bg-input/30 dark:has-disabled:bg-input/80 dark:has-[[data-slot][aria-invalid=true]]:ring-destructive/40 has-[>[data-align=block-end]]:[&>input]:pt-3 has-[>[data-align=block-start]]:[&>input]:pb-3 has-[>[data-align=inline-end]]:[&>input]:pr-1.5 has-[>[data-align=inline-start]]:[&>input]:pl-1.5",
className
)}
{...props}
/>
)
}
const inputGroupAddonVariants = cva(
"flex h-auto cursor-text items-center justify-center gap-2 py-1.5 text-sm font-medium text-muted-foreground select-none group-data-[disabled=true]/input-group:opacity-50 [&>kbd]:rounded-[calc(var(--radius)-5px)] [&>svg:not([class*='size-'])]:size-4",
{
variants: {
align: {
"inline-start":
"order-first pl-2 has-[>button]:ml-[-0.3rem] has-[>kbd]:ml-[-0.15rem]",
"inline-end":
"order-last pr-2 has-[>button]:mr-[-0.3rem] has-[>kbd]:mr-[-0.15rem]",
"block-start":
"order-first w-full justify-start px-2.5 pt-2 group-has-[>input]/input-group:pt-2 [.border-b]:pb-2",
"block-end":
"order-last w-full justify-start px-2.5 pb-2 group-has-[>input]/input-group:pb-2 [.border-t]:pt-2",
},
},
defaultVariants: {
align: "inline-start",
},
}
)
function InputGroupAddon({
className,
align = "inline-start",
...props
}: React.ComponentProps<"div"> & VariantProps<typeof inputGroupAddonVariants>) {
return (
<div
role="group"
data-slot="input-group-addon"
data-align={align}
className={cn(inputGroupAddonVariants({ align }), className)}
onClick={(e) => {
if ((e.target as HTMLElement).closest("button")) {
return
}
e.currentTarget.parentElement?.querySelector("input")?.focus()
}}
{...props}
/>
)
}
const inputGroupButtonVariants = cva(
"flex items-center gap-2 text-sm shadow-none",
{
variants: {
size: {
xs: "h-6 gap-1 rounded-[calc(var(--radius)-3px)] px-1.5 [&>svg:not([class*='size-'])]:size-3.5",
sm: "",
"icon-xs":
"size-6 rounded-[calc(var(--radius)-3px)] p-0 has-[>svg]:p-0",
"icon-sm": "size-8 p-0 has-[>svg]:p-0",
},
},
defaultVariants: {
size: "xs",
},
}
)
function InputGroupButton({
className,
type = "button",
variant = "ghost",
size = "xs",
...props
}: Omit<React.ComponentProps<typeof Button>, "size"> &
VariantProps<typeof inputGroupButtonVariants>) {
return (
<Button
type={type}
data-size={size}
variant={variant}
className={cn(inputGroupButtonVariants({ size }), className)}
{...props}
/>
)
}
function InputGroupText({ className, ...props }: React.ComponentProps<"span">) {
return (
<span
className={cn(
"flex items-center gap-2 text-sm text-muted-foreground [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
/>
)
}
function InputGroupInput({
className,
...props
}: React.ComponentProps<"input">) {
return (
<Input
data-slot="input-group-control"
className={cn(
"flex-1 rounded-none border-0 bg-transparent shadow-none ring-0 focus-visible:ring-0 disabled:bg-transparent aria-invalid:ring-0 dark:bg-transparent dark:disabled:bg-transparent",
className
)}
{...props}
/>
)
}
function InputGroupTextarea({
className,
...props
}: React.ComponentProps<"textarea">) {
return (
<Textarea
data-slot="input-group-control"
className={cn(
"flex-1 resize-none rounded-none border-0 bg-transparent py-2 shadow-none ring-0 focus-visible:ring-0 disabled:bg-transparent aria-invalid:ring-0 dark:bg-transparent dark:disabled:bg-transparent",
className
)}
{...props}
/>
)
}
export {
InputGroup,
InputGroupAddon,
InputGroupButton,
InputGroupText,
InputGroupInput,
InputGroupTextarea,
}
+87
View File
@@ -0,0 +1,87 @@
"use client"
import * as React from "react"
import { OTPInput, OTPInputContext } from "input-otp"
import { cn } from "@/lib/utils"
import { MinusIcon } from "lucide-react"
function InputOTP({
className,
containerClassName,
...props
}: React.ComponentProps<typeof OTPInput> & {
containerClassName?: string
}) {
return (
<OTPInput
data-slot="input-otp"
containerClassName={cn(
"cn-input-otp flex items-center has-disabled:opacity-50",
containerClassName
)}
spellCheck={false}
className={cn("disabled:cursor-not-allowed", className)}
{...props}
/>
)
}
function InputOTPGroup({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="input-otp-group"
className={cn(
"flex items-center rounded-lg has-aria-invalid:border-destructive has-aria-invalid:ring-3 has-aria-invalid:ring-destructive/20 dark:has-aria-invalid:ring-destructive/40",
className
)}
{...props}
/>
)
}
function InputOTPSlot({
index,
className,
...props
}: React.ComponentProps<"div"> & {
index: number
}) {
const inputOTPContext = React.useContext(OTPInputContext)
const { char, hasFakeCaret, isActive } = inputOTPContext?.slots[index] ?? {}
return (
<div
data-slot="input-otp-slot"
data-active={isActive}
className={cn(
"relative flex size-8 items-center justify-center border-y border-r border-input text-sm transition-all outline-none first:rounded-l-lg first:border-l last:rounded-r-lg aria-invalid:border-destructive data-[active=true]:z-10 data-[active=true]:border-ring data-[active=true]:ring-3 data-[active=true]:ring-ring/50 data-[active=true]:aria-invalid:border-destructive data-[active=true]:aria-invalid:ring-destructive/20 dark:bg-input/30 dark:data-[active=true]:aria-invalid:ring-destructive/40",
className
)}
{...props}
>
{char}
{hasFakeCaret && (
<div className="pointer-events-none absolute inset-0 flex items-center justify-center">
<div className="h-4 w-px animate-caret-blink bg-foreground duration-1000" />
</div>
)}
</div>
)
}
function InputOTPSeparator({ ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="input-otp-separator"
className="flex items-center [&_svg:not([class*='size-'])]:size-4"
role="separator"
{...props}
>
<MinusIcon
/>
</div>
)
}
export { InputOTP, InputOTPGroup, InputOTPSlot, InputOTPSeparator }
+19
View File
@@ -0,0 +1,19 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
return (
<input
type={type}
data-slot="input"
className={cn(
"h-8 w-full min-w-0 rounded-lg border border-input bg-transparent px-2.5 py-1 text-base transition-colors outline-none file:inline-flex file:h-6 file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:pointer-events-none disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40",
className
)}
{...props}
/>
)
}
export { Input }
+196
View File
@@ -0,0 +1,196 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { Slot } from "radix-ui"
import { cn } from "@/lib/utils"
import { Separator } from "@/components/ui/separator"
function ItemGroup({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
role="list"
data-slot="item-group"
className={cn(
"group/item-group flex w-full flex-col gap-4 has-data-[size=sm]:gap-2.5 has-data-[size=xs]:gap-2",
className
)}
{...props}
/>
)
}
function ItemSeparator({
className,
...props
}: React.ComponentProps<typeof Separator>) {
return (
<Separator
data-slot="item-separator"
orientation="horizontal"
className={cn("my-2", className)}
{...props}
/>
)
}
const itemVariants = cva(
"group/item flex w-full flex-wrap items-center rounded-lg border text-sm transition-colors duration-100 outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 [a]:transition-colors [a]:hover:bg-muted",
{
variants: {
variant: {
default: "border-transparent",
outline: "border-border",
muted: "border-transparent bg-muted/50",
},
size: {
default: "gap-2.5 px-3 py-2.5",
sm: "gap-2.5 px-3 py-2.5",
xs: "gap-2 px-2.5 py-2 in-data-[slot=dropdown-menu-content]:p-0",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
function Item({
className,
variant = "default",
size = "default",
asChild = false,
...props
}: React.ComponentProps<"div"> &
VariantProps<typeof itemVariants> & { asChild?: boolean }) {
const Comp = asChild ? Slot.Root : "div"
return (
<Comp
data-slot="item"
data-variant={variant}
data-size={size}
className={cn(itemVariants({ variant, size, className }))}
{...props}
/>
)
}
const itemMediaVariants = cva(
"flex shrink-0 items-center justify-center gap-2 group-has-data-[slot=item-description]/item:translate-y-0.5 group-has-data-[slot=item-description]/item:self-start [&_svg]:pointer-events-none",
{
variants: {
variant: {
default: "bg-transparent",
icon: "[&_svg:not([class*='size-'])]:size-4",
image:
"size-10 overflow-hidden rounded-sm group-data-[size=sm]/item:size-8 group-data-[size=xs]/item:size-6 [&_img]:size-full [&_img]:object-cover",
},
},
defaultVariants: {
variant: "default",
},
}
)
function ItemMedia({
className,
variant = "default",
...props
}: React.ComponentProps<"div"> & VariantProps<typeof itemMediaVariants>) {
return (
<div
data-slot="item-media"
data-variant={variant}
className={cn(itemMediaVariants({ variant, className }))}
{...props}
/>
)
}
function ItemContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="item-content"
className={cn(
"flex flex-1 flex-col gap-1 group-data-[size=xs]/item:gap-0 [&+[data-slot=item-content]]:flex-none",
className
)}
{...props}
/>
)
}
function ItemTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="item-title"
className={cn(
"line-clamp-1 flex w-fit items-center gap-2 text-sm leading-snug font-medium underline-offset-4",
className
)}
{...props}
/>
)
}
function ItemDescription({ className, ...props }: React.ComponentProps<"p">) {
return (
<p
data-slot="item-description"
className={cn(
"line-clamp-2 text-left text-sm leading-normal font-normal text-muted-foreground group-data-[size=xs]/item:text-xs [&>a]:underline [&>a]:underline-offset-4 [&>a:hover]:text-primary",
className
)}
{...props}
/>
)
}
function ItemActions({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="item-actions"
className={cn("flex items-center gap-2", className)}
{...props}
/>
)
}
function ItemHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="item-header"
className={cn(
"flex basis-full items-center justify-between gap-2",
className
)}
{...props}
/>
)
}
function ItemFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="item-footer"
className={cn(
"flex basis-full items-center justify-between gap-2",
className
)}
{...props}
/>
)
}
export {
Item,
ItemMedia,
ItemContent,
ItemActions,
ItemGroup,
ItemSeparator,
ItemTitle,
ItemDescription,
ItemHeader,
ItemFooter,
}
+26
View File
@@ -0,0 +1,26 @@
import { cn } from "@/lib/utils"
function Kbd({ className, ...props }: React.ComponentProps<"kbd">) {
return (
<kbd
data-slot="kbd"
className={cn(
"pointer-events-none inline-flex h-5 w-fit min-w-5 items-center justify-center gap-1 rounded-sm bg-muted px-1 font-sans text-xs font-medium text-muted-foreground select-none in-data-[slot=tooltip-content]:bg-background/20 in-data-[slot=tooltip-content]:text-background dark:in-data-[slot=tooltip-content]:bg-background/10 [&_svg:not([class*='size-'])]:size-3",
className
)}
{...props}
/>
)
}
function KbdGroup({ className, ...props }: React.ComponentProps<"div">) {
return (
<kbd
data-slot="kbd-group"
className={cn("inline-flex items-center gap-1", className)}
{...props}
/>
)
}
export { Kbd, KbdGroup }
+24
View File
@@ -0,0 +1,24 @@
"use client"
import * as React from "react"
import { Label as LabelPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
function Label({
className,
...props
}: React.ComponentProps<typeof LabelPrimitive.Root>) {
return (
<LabelPrimitive.Root
data-slot="label"
className={cn(
"flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
className
)}
{...props}
/>
)
}
export { Label }
+69
View File
@@ -0,0 +1,69 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { Slot } from "radix-ui"
import { cn } from "@/lib/utils"
const markerVariants = cva(
"group/marker relative flex min-h-4 w-full items-center gap-2 text-left text-sm text-muted-foreground [&_svg:not([class*='size-'])]:size-4 [a]:underline [a]:underline-offset-3 [a]:hover:text-foreground",
{
variants: {
variant: {
default: "",
separator:
"before:mr-1 before:h-px before:min-w-0 before:flex-1 before:bg-border after:ml-1 after:h-px after:min-w-0 after:flex-1 after:bg-border",
border: "border-b border-border pb-2",
},
},
}
)
function Marker({
className,
variant = "default",
asChild = false,
...props
}: React.ComponentProps<"div"> &
VariantProps<typeof markerVariants> & {
asChild?: boolean
}) {
const Comp = asChild ? Slot.Root : "div"
return (
<Comp
data-slot="marker"
data-variant={variant}
className={cn(markerVariants({ variant, className }))}
{...props}
/>
)
}
function MarkerIcon({ className, ...props }: React.ComponentProps<"span">) {
return (
<span
data-slot="marker-icon"
aria-hidden="true"
className={cn(
"size-4 shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
/>
)
}
function MarkerContent({ className, ...props }: React.ComponentProps<"span">) {
return (
<span
data-slot="marker-content"
className={cn(
"min-w-0 wrap-break-word group-data-[variant=separator]/marker:flex-none group-data-[variant=separator]/marker:text-center *:[a]:underline *:[a]:underline-offset-3 *:[a]:hover:text-foreground",
className
)}
{...props}
/>
)
}
export { Marker, MarkerIcon, MarkerContent, markerVariants }
+280
View File
@@ -0,0 +1,280 @@
"use client"
import * as React from "react"
import { Menubar as MenubarPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
import { CheckIcon, ChevronRightIcon } from "lucide-react"
function Menubar({
className,
...props
}: React.ComponentProps<typeof MenubarPrimitive.Root>) {
return (
<MenubarPrimitive.Root
data-slot="menubar"
className={cn(
"flex h-8 items-center gap-0.5 rounded-lg border p-[3px]",
className
)}
{...props}
/>
)
}
function MenubarMenu({
...props
}: React.ComponentProps<typeof MenubarPrimitive.Menu>) {
return <MenubarPrimitive.Menu data-slot="menubar-menu" {...props} />
}
function MenubarGroup({
...props
}: React.ComponentProps<typeof MenubarPrimitive.Group>) {
return <MenubarPrimitive.Group data-slot="menubar-group" {...props} />
}
function MenubarPortal({
...props
}: React.ComponentProps<typeof MenubarPrimitive.Portal>) {
return <MenubarPrimitive.Portal data-slot="menubar-portal" {...props} />
}
function MenubarRadioGroup({
...props
}: React.ComponentProps<typeof MenubarPrimitive.RadioGroup>) {
return (
<MenubarPrimitive.RadioGroup data-slot="menubar-radio-group" {...props} />
)
}
function MenubarTrigger({
className,
...props
}: React.ComponentProps<typeof MenubarPrimitive.Trigger>) {
return (
<MenubarPrimitive.Trigger
data-slot="menubar-trigger"
className={cn(
"flex items-center rounded-sm px-1.5 py-[2px] text-sm font-medium outline-hidden select-none hover:bg-muted aria-expanded:bg-muted",
className
)}
{...props}
/>
)
}
function MenubarContent({
className,
align = "start",
alignOffset = -4,
sideOffset = 8,
...props
}: React.ComponentProps<typeof MenubarPrimitive.Content>) {
return (
<MenubarPortal>
<MenubarPrimitive.Content
data-slot="menubar-content"
align={align}
alignOffset={alignOffset}
sideOffset={sideOffset}
className={cn("z-50 min-w-36 origin-(--radix-menubar-content-transform-origin) overflow-hidden rounded-lg bg-popover p-1 text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95", className )}
{...props}
/>
</MenubarPortal>
)
}
function MenubarItem({
className,
inset,
variant = "default",
...props
}: React.ComponentProps<typeof MenubarPrimitive.Item> & {
inset?: boolean
variant?: "default" | "destructive"
}) {
return (
<MenubarPrimitive.Item
data-slot="menubar-item"
data-inset={inset}
data-variant={variant}
className={cn(
"group/menubar-item relative flex cursor-default items-center gap-1.5 rounded-md px-1.5 py-1 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-inset:pl-7 data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 data-[variant=destructive]:focus:text-destructive dark:data-[variant=destructive]:focus:bg-destructive/20 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 data-[variant=destructive]:*:[svg]:text-destructive!",
className
)}
{...props}
/>
)
}
function MenubarCheckboxItem({
className,
children,
checked,
inset,
...props
}: React.ComponentProps<typeof MenubarPrimitive.CheckboxItem> & {
inset?: boolean
}) {
return (
<MenubarPrimitive.CheckboxItem
data-slot="menubar-checkbox-item"
data-inset={inset}
className={cn(
"relative flex cursor-default items-center gap-1.5 rounded-md py-1 pr-1.5 pl-7 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground focus:**:text-accent-foreground data-inset:pl-7 data-disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0",
className
)}
checked={checked}
{...props}
>
<span className="pointer-events-none absolute left-1.5 flex size-4 items-center justify-center [&_svg:not([class*='size-'])]:size-4">
<MenubarPrimitive.ItemIndicator>
<CheckIcon
/>
</MenubarPrimitive.ItemIndicator>
</span>
{children}
</MenubarPrimitive.CheckboxItem>
)
}
function MenubarRadioItem({
className,
children,
inset,
...props
}: React.ComponentProps<typeof MenubarPrimitive.RadioItem> & {
inset?: boolean
}) {
return (
<MenubarPrimitive.RadioItem
data-slot="menubar-radio-item"
data-inset={inset}
className={cn(
"relative flex cursor-default items-center gap-1.5 rounded-md py-1 pr-1.5 pl-7 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground focus:**:text-accent-foreground data-inset:pl-7 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
<span className="pointer-events-none absolute left-1.5 flex size-4 items-center justify-center [&_svg:not([class*='size-'])]:size-4">
<MenubarPrimitive.ItemIndicator>
<CheckIcon
/>
</MenubarPrimitive.ItemIndicator>
</span>
{children}
</MenubarPrimitive.RadioItem>
)
}
function MenubarLabel({
className,
inset,
...props
}: React.ComponentProps<typeof MenubarPrimitive.Label> & {
inset?: boolean
}) {
return (
<MenubarPrimitive.Label
data-slot="menubar-label"
data-inset={inset}
className={cn(
"px-1.5 py-1 text-sm font-medium data-inset:pl-7",
className
)}
{...props}
/>
)
}
function MenubarSeparator({
className,
...props
}: React.ComponentProps<typeof MenubarPrimitive.Separator>) {
return (
<MenubarPrimitive.Separator
data-slot="menubar-separator"
className={cn("-mx-1 my-1 h-px bg-border", className)}
{...props}
/>
)
}
function MenubarShortcut({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
data-slot="menubar-shortcut"
className={cn(
"ml-auto text-xs tracking-widest text-muted-foreground group-focus/menubar-item:text-accent-foreground",
className
)}
{...props}
/>
)
}
function MenubarSub({
...props
}: React.ComponentProps<typeof MenubarPrimitive.Sub>) {
return <MenubarPrimitive.Sub data-slot="menubar-sub" {...props} />
}
function MenubarSubTrigger({
className,
inset,
children,
...props
}: React.ComponentProps<typeof MenubarPrimitive.SubTrigger> & {
inset?: boolean
}) {
return (
<MenubarPrimitive.SubTrigger
data-slot="menubar-sub-trigger"
data-inset={inset}
className={cn(
"flex cursor-default items-center gap-1.5 rounded-md px-1.5 py-1 text-sm outline-none select-none focus:bg-accent focus:text-accent-foreground data-inset:pl-7 data-open:bg-accent data-open:text-accent-foreground [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
{children}
<ChevronRightIcon className="ml-auto size-4" />
</MenubarPrimitive.SubTrigger>
)
}
function MenubarSubContent({
className,
...props
}: React.ComponentProps<typeof MenubarPrimitive.SubContent>) {
return (
<MenubarPrimitive.SubContent
data-slot="menubar-sub-content"
className={cn("z-50 min-w-32 origin-(--radix-menubar-content-transform-origin) overflow-hidden rounded-lg bg-popover p-1 text-popover-foreground shadow-lg ring-1 ring-foreground/10 duration-100 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95", className )}
{...props}
/>
)
}
export {
Menubar,
MenubarPortal,
MenubarMenu,
MenubarTrigger,
MenubarContent,
MenubarGroup,
MenubarSeparator,
MenubarLabel,
MenubarItem,
MenubarShortcut,
MenubarCheckboxItem,
MenubarRadioGroup,
MenubarRadioItem,
MenubarSub,
MenubarSubTrigger,
MenubarSubContent,
}
+131
View File
@@ -0,0 +1,131 @@
"use client"
import * as React from "react"
import {
MessageScroller as MessageScrollerPrimitive,
useMessageScroller,
useMessageScrollerScrollable,
useMessageScrollerVisibility,
} from "@shadcn/react/message-scroller"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import { ArrowDownIcon } from "lucide-react"
function MessageScrollerProvider(
props: React.ComponentProps<typeof MessageScrollerPrimitive.Provider>
) {
return <MessageScrollerPrimitive.Provider {...props} />
}
function MessageScroller({
className,
...props
}: React.ComponentProps<typeof MessageScrollerPrimitive.Root>) {
return (
<MessageScrollerPrimitive.Root
data-slot="message-scroller"
className={cn(
"group/message-scroller relative flex size-full min-h-0 flex-col overflow-hidden",
className
)}
{...props}
/>
)
}
function MessageScrollerViewport({
className,
...props
}: React.ComponentProps<typeof MessageScrollerPrimitive.Viewport>) {
return (
<MessageScrollerPrimitive.Viewport
data-slot="message-scroller-viewport"
className={cn(
"size-full min-h-0 min-w-0 scroll-fade-b scrollbar-thin scrollbar-gutter-stable overflow-y-auto overscroll-contain contain-content data-autoscrolling:scrollbar-thumb-transparent data-autoscrolling:scrollbar-track-transparent",
className
)}
{...props}
/>
)
}
function MessageScrollerContent({
className,
...props
}: React.ComponentProps<typeof MessageScrollerPrimitive.Content>) {
return (
<MessageScrollerPrimitive.Content
data-slot="message-scroller-content"
className={cn("flex h-max min-h-full flex-col gap-6", className)}
{...props}
/>
)
}
function MessageScrollerItem({
className,
scrollAnchor = false,
...props
}: React.ComponentProps<typeof MessageScrollerPrimitive.Item>) {
return (
<MessageScrollerPrimitive.Item
data-slot="message-scroller-item"
scrollAnchor={scrollAnchor}
className={cn(
"min-w-0 shrink-0 [contain-intrinsic-size:auto_10rem] [content-visibility:auto]",
className
)}
{...props}
/>
)
}
function MessageScrollerButton({
direction = "end",
className,
children,
render,
variant = "secondary",
size = "icon-sm",
...props
}: React.ComponentProps<typeof MessageScrollerPrimitive.Button> &
Pick<React.ComponentProps<typeof Button>, "variant" | "size">) {
return (
<MessageScrollerPrimitive.Button
data-slot="message-scroller-button"
data-direction={direction}
data-variant={variant}
data-size={size}
direction={direction}
className={cn(
"absolute inset-s-1/2 -translate-x-1/2 border-border bg-background text-foreground transition-[translate,scale,opacity] duration-200 hover:bg-muted hover:text-foreground data-[active=false]:pointer-events-none data-[active=false]:scale-95 data-[active=false]:opacity-0 data-[active=false]:duration-400 data-[active=false]:ease-[cubic-bezier(0.7,0,0.84,0)] data-[active=true]:translate-y-0 data-[active=true]:scale-100 data-[active=true]:opacity-100 data-[active=true]:ease-[cubic-bezier(0.23,1,0.32,1)] data-[direction=end]:bottom-4 data-[direction=end]:data-[active=false]:translate-y-full data-[direction=start]:top-4 data-[direction=start]:data-[active=false]:-translate-y-full rtl:translate-x-1/2 data-[direction=start]:[&_svg]:rotate-180",
className
)}
render={render ?? <Button variant={variant} size={size} />}
{...props}
>
{children ?? (
<>
<ArrowDownIcon
/>
<span className="sr-only">
{direction === "end" ? "Scroll to end" : "Scroll to start"}
</span>
</>
)}
</MessageScrollerPrimitive.Button>
)
}
export {
MessageScrollerProvider,
MessageScroller,
MessageScrollerViewport,
MessageScrollerContent,
MessageScrollerItem,
MessageScrollerButton,
useMessageScroller,
useMessageScrollerScrollable,
useMessageScrollerVisibility,
}
+92
View File
@@ -0,0 +1,92 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function MessageGroup({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="message-group"
className={cn("flex min-w-0 flex-col gap-2", className)}
{...props}
/>
)
}
function Message({
className,
align = "start",
...props
}: React.ComponentProps<"div"> & { align?: "start" | "end" }) {
return (
<div
data-slot="message"
data-align={align}
className={cn(
"group/message relative flex w-full min-w-0 gap-2 text-sm data-[align=end]:flex-row-reverse",
className
)}
{...props}
/>
)
}
function MessageAvatar({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="message-avatar"
className={cn(
"flex w-fit min-w-8 shrink-0 items-center justify-center self-end overflow-hidden rounded-full bg-muted group-has-data-[slot=message-footer]/message:-translate-y-8",
className
)}
{...props}
/>
)
}
function MessageContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="message-content"
className={cn(
"flex w-full min-w-0 flex-col gap-2.5 wrap-break-word group-data-[align=end]/message:*:data-slot:self-end",
className
)}
{...props}
/>
)
}
function MessageHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="message-header"
className={cn(
"flex max-w-full min-w-0 items-center px-3 text-xs font-medium text-muted-foreground group-has-data-[variant=ghost]/message:px-0",
className
)}
{...props}
/>
)
}
function MessageFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="message-footer"
className={cn(
"flex max-w-full min-w-0 items-center px-3 text-xs font-medium text-muted-foreground group-has-data-[variant=ghost]/message:px-0 group-data-[align=end]/message:justify-end",
className
)}
{...props}
/>
)
}
export {
MessageGroup,
Message,
MessageAvatar,
MessageContent,
MessageFooter,
MessageHeader,
}
+61
View File
@@ -0,0 +1,61 @@
import * as React from "react"
import { cn } from "@/lib/utils"
import { ChevronDownIcon } from "lucide-react"
type NativeSelectProps = Omit<React.ComponentProps<"select">, "size"> & {
size?: "sm" | "default"
}
function NativeSelect({
className,
size = "default",
...props
}: NativeSelectProps) {
return (
<div
className={cn(
"group/native-select relative w-fit has-[select:disabled]:opacity-50",
className
)}
data-slot="native-select-wrapper"
data-size={size}
>
<select
data-slot="native-select"
data-size={size}
className="h-8 w-full min-w-0 appearance-none rounded-lg border border-input bg-transparent py-1 pr-8 pl-2.5 text-sm transition-colors outline-none select-none selection:bg-primary selection:text-primary-foreground placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:pointer-events-none disabled:cursor-not-allowed aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 data-[size=sm]:h-7 data-[size=sm]:rounded-[min(var(--radius-md),10px)] data-[size=sm]:py-0.5 dark:bg-input/30 dark:hover:bg-input/50 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40"
{...props}
/>
<ChevronDownIcon className="pointer-events-none absolute top-1/2 right-2.5 size-4 -translate-y-1/2 text-muted-foreground select-none" aria-hidden="true" data-slot="native-select-icon" />
</div>
)
}
function NativeSelectOption({
className,
...props
}: React.ComponentProps<"option">) {
return (
<option
data-slot="native-select-option"
className={cn("bg-[Canvas] text-[CanvasText]", className)}
{...props}
/>
)
}
function NativeSelectOptGroup({
className,
...props
}: React.ComponentProps<"optgroup">) {
return (
<optgroup
data-slot="native-select-optgroup"
className={cn("bg-[Canvas] text-[CanvasText]", className)}
{...props}
/>
)
}
export { NativeSelect, NativeSelectOptGroup, NativeSelectOption }
+164
View File
@@ -0,0 +1,164 @@
import * as React from "react"
import { cva } from "class-variance-authority"
import { NavigationMenu as NavigationMenuPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
import { ChevronDownIcon } from "lucide-react"
function NavigationMenu({
className,
children,
viewport = true,
...props
}: React.ComponentProps<typeof NavigationMenuPrimitive.Root> & {
viewport?: boolean
}) {
return (
<NavigationMenuPrimitive.Root
data-slot="navigation-menu"
data-viewport={viewport}
className={cn(
"group/navigation-menu relative flex max-w-max flex-1 items-center justify-center",
className
)}
{...props}
>
{children}
{viewport && <NavigationMenuViewport />}
</NavigationMenuPrimitive.Root>
)
}
function NavigationMenuList({
className,
...props
}: React.ComponentProps<typeof NavigationMenuPrimitive.List>) {
return (
<NavigationMenuPrimitive.List
data-slot="navigation-menu-list"
className={cn(
"group flex flex-1 list-none items-center justify-center gap-0",
className
)}
{...props}
/>
)
}
function NavigationMenuItem({
className,
...props
}: React.ComponentProps<typeof NavigationMenuPrimitive.Item>) {
return (
<NavigationMenuPrimitive.Item
data-slot="navigation-menu-item"
className={cn("relative", className)}
{...props}
/>
)
}
const navigationMenuTriggerStyle = cva(
"group/navigation-menu-trigger inline-flex h-9 w-max items-center justify-center rounded-lg px-2.5 py-1.5 text-sm font-medium transition-all outline-none hover:bg-muted focus:bg-muted focus-visible:ring-3 focus-visible:ring-ring/50 focus-visible:outline-1 disabled:pointer-events-none disabled:opacity-50 data-popup-open:bg-muted/50 data-popup-open:hover:bg-muted data-open:bg-muted/50 data-open:hover:bg-muted data-open:focus:bg-muted"
)
function NavigationMenuTrigger({
className,
children,
...props
}: React.ComponentProps<typeof NavigationMenuPrimitive.Trigger>) {
return (
<NavigationMenuPrimitive.Trigger
data-slot="navigation-menu-trigger"
className={cn(navigationMenuTriggerStyle(), "group", className)}
{...props}
>
{children}{" "}
<ChevronDownIcon className="relative top-px ml-1 size-3 transition duration-300 group-data-popup-open/navigation-menu-trigger:rotate-180 group-data-open/navigation-menu-trigger:rotate-180" aria-hidden="true" />
</NavigationMenuPrimitive.Trigger>
)
}
function NavigationMenuContent({
className,
...props
}: React.ComponentProps<typeof NavigationMenuPrimitive.Content>) {
return (
<NavigationMenuPrimitive.Content
data-slot="navigation-menu-content"
className={cn(
"top-0 left-0 w-full p-1 ease-[cubic-bezier(0.22,1,0.36,1)] group-data-[viewport=false]/navigation-menu:top-full group-data-[viewport=false]/navigation-menu:mt-1.5 group-data-[viewport=false]/navigation-menu:overflow-hidden group-data-[viewport=false]/navigation-menu:rounded-lg group-data-[viewport=false]/navigation-menu:bg-popover group-data-[viewport=false]/navigation-menu:text-popover-foreground group-data-[viewport=false]/navigation-menu:shadow group-data-[viewport=false]/navigation-menu:ring-1 group-data-[viewport=false]/navigation-menu:ring-foreground/10 group-data-[viewport=false]/navigation-menu:duration-300 data-[motion=from-end]:slide-in-from-right-52 data-[motion=from-start]:slide-in-from-left-52 data-[motion=to-end]:slide-out-to-right-52 data-[motion=to-start]:slide-out-to-left-52 data-[motion^=from-]:animate-in data-[motion^=from-]:fade-in data-[motion^=to-]:animate-out data-[motion^=to-]:fade-out **:data-[slot=navigation-menu-link]:focus:ring-0 **:data-[slot=navigation-menu-link]:focus:outline-none md:absolute md:w-auto group-data-[viewport=false]/navigation-menu:data-open:animate-in group-data-[viewport=false]/navigation-menu:data-open:fade-in-0 group-data-[viewport=false]/navigation-menu:data-open:zoom-in-95 group-data-[viewport=false]/navigation-menu:data-closed:animate-out group-data-[viewport=false]/navigation-menu:data-closed:fade-out-0 group-data-[viewport=false]/navigation-menu:data-closed:zoom-out-95",
className
)}
{...props}
/>
)
}
function NavigationMenuViewport({
className,
...props
}: React.ComponentProps<typeof NavigationMenuPrimitive.Viewport>) {
return (
<div
className={cn(
"absolute top-full left-0 isolate z-50 flex justify-center"
)}
>
<NavigationMenuPrimitive.Viewport
data-slot="navigation-menu-viewport"
className={cn(
"origin-top-center relative mt-1.5 h-(--radix-navigation-menu-viewport-height) w-full overflow-hidden rounded-lg bg-popover text-popover-foreground shadow ring-1 ring-foreground/10 duration-100 md:w-(--radix-navigation-menu-viewport-width) data-open:animate-in data-open:zoom-in-90 data-closed:animate-out data-closed:zoom-out-90",
className
)}
{...props}
/>
</div>
)
}
function NavigationMenuLink({
className,
...props
}: React.ComponentProps<typeof NavigationMenuPrimitive.Link>) {
return (
<NavigationMenuPrimitive.Link
data-slot="navigation-menu-link"
className={cn(
"flex items-center gap-2 rounded-lg p-2 text-sm transition-all outline-none hover:bg-muted focus:bg-muted focus-visible:ring-3 focus-visible:ring-ring/50 focus-visible:outline-1 in-data-[slot=navigation-menu-content]:rounded-md data-active:bg-muted/50 data-active:hover:bg-muted data-active:focus:bg-muted [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
/>
)
}
function NavigationMenuIndicator({
className,
...props
}: React.ComponentProps<typeof NavigationMenuPrimitive.Indicator>) {
return (
<NavigationMenuPrimitive.Indicator
data-slot="navigation-menu-indicator"
className={cn(
"top-full z-1 flex h-1.5 items-end justify-center overflow-hidden data-[state=hidden]:animate-out data-[state=hidden]:fade-out data-[state=visible]:animate-in data-[state=visible]:fade-in",
className
)}
{...props}
>
<div className="relative top-[60%] h-2 w-2 rotate-45 rounded-tl-sm bg-border shadow-md" />
</NavigationMenuPrimitive.Indicator>
)
}
export {
NavigationMenu,
NavigationMenuList,
NavigationMenuItem,
NavigationMenuContent,
NavigationMenuTrigger,
NavigationMenuLink,
NavigationMenuIndicator,
NavigationMenuViewport,
navigationMenuTriggerStyle,
}
+129
View File
@@ -0,0 +1,129 @@
import * as React from "react"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import { ChevronLeftIcon, ChevronRightIcon, MoreHorizontalIcon } from "lucide-react"
function Pagination({ className, ...props }: React.ComponentProps<"nav">) {
return (
<nav
role="navigation"
aria-label="pagination"
data-slot="pagination"
className={cn("mx-auto flex w-full justify-center", className)}
{...props}
/>
)
}
function PaginationContent({
className,
...props
}: React.ComponentProps<"ul">) {
return (
<ul
data-slot="pagination-content"
className={cn("flex items-center gap-0.5", className)}
{...props}
/>
)
}
function PaginationItem({ ...props }: React.ComponentProps<"li">) {
return <li data-slot="pagination-item" {...props} />
}
type PaginationLinkProps = {
isActive?: boolean
} & Pick<React.ComponentProps<typeof Button>, "size"> &
React.ComponentProps<"a">
function PaginationLink({
className,
isActive,
size = "icon",
...props
}: PaginationLinkProps) {
return (
<Button
asChild
variant={isActive ? "outline" : "ghost"}
size={size}
className={cn(className)}
>
<a
aria-current={isActive ? "page" : undefined}
data-slot="pagination-link"
data-active={isActive}
{...props}
/>
</Button>
)
}
function PaginationPrevious({
className,
text = "Previous",
...props
}: React.ComponentProps<typeof PaginationLink> & { text?: string }) {
return (
<PaginationLink
aria-label="Go to previous page"
size="default"
className={cn("pl-1.5!", className)}
{...props}
>
<ChevronLeftIcon data-icon="inline-start" />
<span className="hidden sm:block">{text}</span>
</PaginationLink>
)
}
function PaginationNext({
className,
text = "Next",
...props
}: React.ComponentProps<typeof PaginationLink> & { text?: string }) {
return (
<PaginationLink
aria-label="Go to next page"
size="default"
className={cn("pr-1.5!", className)}
{...props}
>
<span className="hidden sm:block">{text}</span>
<ChevronRightIcon data-icon="inline-end" />
</PaginationLink>
)
}
function PaginationEllipsis({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
aria-hidden
data-slot="pagination-ellipsis"
className={cn(
"flex size-8 items-center justify-center [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
<MoreHorizontalIcon
/>
<span className="sr-only">More pages</span>
</span>
)
}
export {
Pagination,
PaginationContent,
PaginationEllipsis,
PaginationItem,
PaginationLink,
PaginationNext,
PaginationPrevious,
}
+89
View File
@@ -0,0 +1,89 @@
"use client"
import * as React from "react"
import { Popover as PopoverPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
function Popover({
...props
}: React.ComponentProps<typeof PopoverPrimitive.Root>) {
return <PopoverPrimitive.Root data-slot="popover" {...props} />
}
function PopoverTrigger({
...props
}: React.ComponentProps<typeof PopoverPrimitive.Trigger>) {
return <PopoverPrimitive.Trigger data-slot="popover-trigger" {...props} />
}
function PopoverContent({
className,
align = "center",
sideOffset = 4,
...props
}: React.ComponentProps<typeof PopoverPrimitive.Content>) {
return (
<PopoverPrimitive.Portal>
<PopoverPrimitive.Content
data-slot="popover-content"
align={align}
sideOffset={sideOffset}
className={cn(
"z-50 flex w-72 origin-(--radix-popover-content-transform-origin) flex-col gap-2.5 rounded-lg bg-popover p-2.5 text-sm text-popover-foreground shadow-md ring-1 ring-foreground/10 outline-hidden duration-100 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
className
)}
{...props}
/>
</PopoverPrimitive.Portal>
)
}
function PopoverAnchor({
...props
}: React.ComponentProps<typeof PopoverPrimitive.Anchor>) {
return <PopoverPrimitive.Anchor data-slot="popover-anchor" {...props} />
}
function PopoverHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="popover-header"
className={cn("flex flex-col gap-0.5 text-sm", className)}
{...props}
/>
)
}
function PopoverTitle({ className, ...props }: React.ComponentProps<"h2">) {
return (
<div
data-slot="popover-title"
className={cn("font-medium", className)}
{...props}
/>
)
}
function PopoverDescription({
className,
...props
}: React.ComponentProps<"p">) {
return (
<p
data-slot="popover-description"
className={cn("text-muted-foreground", className)}
{...props}
/>
)
}
export {
Popover,
PopoverAnchor,
PopoverContent,
PopoverDescription,
PopoverHeader,
PopoverTitle,
PopoverTrigger,
}
+31
View File
@@ -0,0 +1,31 @@
"use client"
import * as React from "react"
import { Progress as ProgressPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
function Progress({
className,
value,
...props
}: React.ComponentProps<typeof ProgressPrimitive.Root>) {
return (
<ProgressPrimitive.Root
data-slot="progress"
className={cn(
"relative flex h-1 w-full items-center overflow-x-hidden rounded-full bg-muted",
className
)}
{...props}
>
<ProgressPrimitive.Indicator
data-slot="progress-indicator"
className="size-full flex-1 bg-primary transition-all"
style={{ transform: `translateX(-${100 - (value || 0)}%)` }}
/>
</ProgressPrimitive.Root>
)
}
export { Progress }
+44
View File
@@ -0,0 +1,44 @@
"use client"
import * as React from "react"
import { RadioGroup as RadioGroupPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
function RadioGroup({
className,
...props
}: React.ComponentProps<typeof RadioGroupPrimitive.Root>) {
return (
<RadioGroupPrimitive.Root
data-slot="radio-group"
className={cn("grid w-full gap-2", className)}
{...props}
/>
)
}
function RadioGroupItem({
className,
...props
}: React.ComponentProps<typeof RadioGroupPrimitive.Item>) {
return (
<RadioGroupPrimitive.Item
data-slot="radio-group-item"
className={cn(
"group/radio-group-item peer relative flex aspect-square size-4 shrink-0 rounded-full border border-input outline-none after:absolute after:-inset-x-3 after:-inset-y-2 focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 aria-invalid:aria-checked:border-primary dark:bg-input/30 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 data-checked:border-primary data-checked:bg-primary data-checked:text-primary-foreground dark:data-checked:bg-primary",
className
)}
{...props}
>
<RadioGroupPrimitive.Indicator
data-slot="radio-group-indicator"
className="flex size-4 items-center justify-center"
>
<span className="absolute top-1/2 left-1/2 size-2 -translate-x-1/2 -translate-y-1/2 rounded-full bg-primary-foreground" />
</RadioGroupPrimitive.Indicator>
</RadioGroupPrimitive.Item>
)
}
export { RadioGroup, RadioGroupItem }
+50
View File
@@ -0,0 +1,50 @@
"use client"
import * as ResizablePrimitive from "react-resizable-panels"
import { cn } from "@/lib/utils"
function ResizablePanelGroup({
className,
...props
}: ResizablePrimitive.GroupProps) {
return (
<ResizablePrimitive.Group
data-slot="resizable-panel-group"
className={cn(
"flex h-full w-full aria-[orientation=vertical]:flex-col",
className
)}
{...props}
/>
)
}
function ResizablePanel({ ...props }: ResizablePrimitive.PanelProps) {
return <ResizablePrimitive.Panel data-slot="resizable-panel" {...props} />
}
function ResizableHandle({
withHandle,
className,
...props
}: ResizablePrimitive.SeparatorProps & {
withHandle?: boolean
}) {
return (
<ResizablePrimitive.Separator
data-slot="resizable-handle"
className={cn(
"relative flex w-px items-center justify-center bg-border ring-offset-background after:absolute after:inset-y-0 after:left-1/2 after:w-1 after:-translate-x-1/2 focus-visible:ring-1 focus-visible:ring-ring focus-visible:outline-hidden aria-[orientation=horizontal]:h-px aria-[orientation=horizontal]:w-full aria-[orientation=horizontal]:after:left-0 aria-[orientation=horizontal]:after:h-1 aria-[orientation=horizontal]:after:w-full aria-[orientation=horizontal]:after:translate-x-0 aria-[orientation=horizontal]:after:-translate-y-1/2 [&[aria-orientation=horizontal]>div]:rotate-90",
className
)}
{...props}
>
{withHandle && (
<div className="z-10 flex h-6 w-1 shrink-0 rounded-lg bg-border" />
)}
</ResizablePrimitive.Separator>
)
}
export { ResizableHandle, ResizablePanel, ResizablePanelGroup }
+55
View File
@@ -0,0 +1,55 @@
"use client"
import * as React from "react"
import { ScrollArea as ScrollAreaPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
function ScrollArea({
className,
children,
...props
}: React.ComponentProps<typeof ScrollAreaPrimitive.Root>) {
return (
<ScrollAreaPrimitive.Root
data-slot="scroll-area"
className={cn("relative", className)}
{...props}
>
<ScrollAreaPrimitive.Viewport
data-slot="scroll-area-viewport"
className="size-full rounded-[inherit] transition-[color,box-shadow] outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-1"
>
{children}
</ScrollAreaPrimitive.Viewport>
<ScrollBar />
<ScrollAreaPrimitive.Corner />
</ScrollAreaPrimitive.Root>
)
}
function ScrollBar({
className,
orientation = "vertical",
...props
}: React.ComponentProps<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>) {
return (
<ScrollAreaPrimitive.ScrollAreaScrollbar
data-slot="scroll-area-scrollbar"
data-orientation={orientation}
orientation={orientation}
className={cn(
"flex touch-none p-px transition-colors select-none data-horizontal:h-2.5 data-horizontal:flex-col data-horizontal:border-t data-horizontal:border-t-transparent data-vertical:h-full data-vertical:w-2.5 data-vertical:border-l data-vertical:border-l-transparent",
className
)}
{...props}
>
<ScrollAreaPrimitive.ScrollAreaThumb
data-slot="scroll-area-thumb"
className="relative flex-1 rounded-full bg-border"
/>
</ScrollAreaPrimitive.ScrollAreaScrollbar>
)
}
export { ScrollArea, ScrollBar }
+192
View File
@@ -0,0 +1,192 @@
"use client"
import * as React from "react"
import { Select as SelectPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
import { ChevronDownIcon, CheckIcon, ChevronUpIcon } from "lucide-react"
function Select({
...props
}: React.ComponentProps<typeof SelectPrimitive.Root>) {
return <SelectPrimitive.Root data-slot="select" {...props} />
}
function SelectGroup({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.Group>) {
return (
<SelectPrimitive.Group
data-slot="select-group"
className={cn("scroll-my-1 p-1", className)}
{...props}
/>
)
}
function SelectValue({
...props
}: React.ComponentProps<typeof SelectPrimitive.Value>) {
return <SelectPrimitive.Value data-slot="select-value" {...props} />
}
function SelectTrigger({
className,
size = "default",
children,
...props
}: React.ComponentProps<typeof SelectPrimitive.Trigger> & {
size?: "sm" | "default"
}) {
return (
<SelectPrimitive.Trigger
data-slot="select-trigger"
data-size={size}
className={cn(
"flex w-fit items-center justify-between gap-1.5 rounded-lg border border-input bg-transparent py-2 pr-2 pl-2.5 text-sm whitespace-nowrap transition-colors outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 data-placeholder:text-muted-foreground data-[size=default]:h-8 data-[size=sm]:h-7 data-[size=sm]:rounded-[min(var(--radius-md),10px)] *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-1.5 dark:bg-input/30 dark:hover:bg-input/50 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
{children}
<SelectPrimitive.Icon asChild>
<ChevronDownIcon className="pointer-events-none size-4 text-muted-foreground" />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
)
}
function SelectContent({
className,
children,
position = "item-aligned",
align = "center",
...props
}: React.ComponentProps<typeof SelectPrimitive.Content>) {
return (
<SelectPrimitive.Portal>
<SelectPrimitive.Content
data-slot="select-content"
data-align-trigger={position === "item-aligned"}
className={cn("relative z-50 max-h-(--radix-select-content-available-height) min-w-36 origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-lg bg-popover text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 data-[align-trigger=true]:animate-none data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95", position ==="popper"&&"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1", className )}
position={position}
align={align}
{...props}
>
<SelectScrollUpButton />
<SelectPrimitive.Viewport
data-position={position}
className={cn(
"data-[position=popper]:h-(--radix-select-trigger-height) data-[position=popper]:w-full data-[position=popper]:min-w-(--radix-select-trigger-width)",
position === "popper" && ""
)}
>
{children}
</SelectPrimitive.Viewport>
<SelectScrollDownButton />
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
)
}
function SelectLabel({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.Label>) {
return (
<SelectPrimitive.Label
data-slot="select-label"
className={cn("px-1.5 py-1 text-xs text-muted-foreground", className)}
{...props}
/>
)
}
function SelectItem({
className,
children,
...props
}: React.ComponentProps<typeof SelectPrimitive.Item>) {
return (
<SelectPrimitive.Item
data-slot="select-item"
className={cn(
"relative flex w-full cursor-default items-center gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
className
)}
{...props}
>
<span className="pointer-events-none absolute right-2 flex size-4 items-center justify-center">
<SelectPrimitive.ItemIndicator>
<CheckIcon className="pointer-events-none" />
</SelectPrimitive.ItemIndicator>
</span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
)
}
function SelectSeparator({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.Separator>) {
return (
<SelectPrimitive.Separator
data-slot="select-separator"
className={cn("pointer-events-none -mx-1 my-1 h-px bg-border", className)}
{...props}
/>
)
}
function SelectScrollUpButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollUpButton>) {
return (
<SelectPrimitive.ScrollUpButton
data-slot="select-scroll-up-button"
className={cn(
"z-10 flex cursor-default items-center justify-center bg-popover py-1 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
<ChevronUpIcon
/>
</SelectPrimitive.ScrollUpButton>
)
}
function SelectScrollDownButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollDownButton>) {
return (
<SelectPrimitive.ScrollDownButton
data-slot="select-scroll-down-button"
className={cn(
"z-10 flex cursor-default items-center justify-center bg-popover py-1 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
<ChevronDownIcon
/>
</SelectPrimitive.ScrollDownButton>
)
}
export {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectScrollDownButton,
SelectScrollUpButton,
SelectSeparator,
SelectTrigger,
SelectValue,
}
+28
View File
@@ -0,0 +1,28 @@
"use client"
import * as React from "react"
import { Separator as SeparatorPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
function Separator({
className,
orientation = "horizontal",
decorative = true,
...props
}: React.ComponentProps<typeof SeparatorPrimitive.Root>) {
return (
<SeparatorPrimitive.Root
data-slot="separator"
decorative={decorative}
orientation={orientation}
className={cn(
"shrink-0 bg-border data-horizontal:h-px data-horizontal:w-full data-vertical:w-px data-vertical:self-stretch",
className
)}
{...props}
/>
)
}
export { Separator }
+147
View File
@@ -0,0 +1,147 @@
"use client"
import * as React from "react"
import { Dialog as SheetPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import { XIcon } from "lucide-react"
function Sheet({ ...props }: React.ComponentProps<typeof SheetPrimitive.Root>) {
return <SheetPrimitive.Root data-slot="sheet" {...props} />
}
function SheetTrigger({
...props
}: React.ComponentProps<typeof SheetPrimitive.Trigger>) {
return <SheetPrimitive.Trigger data-slot="sheet-trigger" {...props} />
}
function SheetClose({
...props
}: React.ComponentProps<typeof SheetPrimitive.Close>) {
return <SheetPrimitive.Close data-slot="sheet-close" {...props} />
}
function SheetPortal({
...props
}: React.ComponentProps<typeof SheetPrimitive.Portal>) {
return <SheetPrimitive.Portal data-slot="sheet-portal" {...props} />
}
function SheetOverlay({
className,
...props
}: React.ComponentProps<typeof SheetPrimitive.Overlay>) {
return (
<SheetPrimitive.Overlay
data-slot="sheet-overlay"
className={cn(
"fixed inset-0 z-50 bg-black/10 duration-100 supports-backdrop-filter:backdrop-blur-xs data-open:animate-in data-open:fade-in-0 data-closed:animate-out data-closed:fade-out-0",
className
)}
{...props}
/>
)
}
function SheetContent({
className,
children,
side = "right",
showCloseButton = true,
...props
}: React.ComponentProps<typeof SheetPrimitive.Content> & {
side?: "top" | "right" | "bottom" | "left"
showCloseButton?: boolean
}) {
return (
<SheetPortal>
<SheetOverlay />
<SheetPrimitive.Content
data-slot="sheet-content"
data-side={side}
className={cn(
"fixed z-50 flex flex-col gap-4 bg-popover bg-clip-padding text-sm text-popover-foreground shadow-lg transition duration-200 ease-in-out data-[side=bottom]:inset-x-0 data-[side=bottom]:bottom-0 data-[side=bottom]:h-auto data-[side=bottom]:border-t data-[side=left]:inset-y-0 data-[side=left]:left-0 data-[side=left]:h-full data-[side=left]:w-3/4 data-[side=left]:border-r data-[side=right]:inset-y-0 data-[side=right]:right-0 data-[side=right]:h-full data-[side=right]:w-3/4 data-[side=right]:border-l data-[side=top]:inset-x-0 data-[side=top]:top-0 data-[side=top]:h-auto data-[side=top]:border-b data-[side=left]:sm:max-w-sm data-[side=right]:sm:max-w-sm data-open:animate-in data-open:fade-in-0 data-[side=bottom]:data-open:slide-in-from-bottom-10 data-[side=left]:data-open:slide-in-from-left-10 data-[side=right]:data-open:slide-in-from-right-10 data-[side=top]:data-open:slide-in-from-top-10 data-closed:animate-out data-closed:fade-out-0 data-[side=bottom]:data-closed:slide-out-to-bottom-10 data-[side=left]:data-closed:slide-out-to-left-10 data-[side=right]:data-closed:slide-out-to-right-10 data-[side=top]:data-closed:slide-out-to-top-10",
className
)}
{...props}
>
{children}
{showCloseButton && (
<SheetPrimitive.Close data-slot="sheet-close" asChild>
<Button
variant="ghost"
className="absolute top-3 right-3"
size="icon-sm"
>
<XIcon
/>
<span className="sr-only">Close</span>
</Button>
</SheetPrimitive.Close>
)}
</SheetPrimitive.Content>
</SheetPortal>
)
}
function SheetHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sheet-header"
className={cn("flex flex-col gap-0.5 p-4", className)}
{...props}
/>
)
}
function SheetFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sheet-footer"
className={cn("mt-auto flex flex-col gap-2 p-4", className)}
{...props}
/>
)
}
function SheetTitle({
className,
...props
}: React.ComponentProps<typeof SheetPrimitive.Title>) {
return (
<SheetPrimitive.Title
data-slot="sheet-title"
className={cn(
"font-heading text-base font-medium text-foreground",
className
)}
{...props}
/>
)
}
function SheetDescription({
className,
...props
}: React.ComponentProps<typeof SheetPrimitive.Description>) {
return (
<SheetPrimitive.Description
data-slot="sheet-description"
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
)
}
export {
Sheet,
SheetTrigger,
SheetClose,
SheetContent,
SheetHeader,
SheetFooter,
SheetTitle,
SheetDescription,
}
+702
View File
@@ -0,0 +1,702 @@
"use client"
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { Slot } from "radix-ui"
import { useIsMobile } from "@/hooks/use-mobile"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Separator } from "@/components/ui/separator"
import {
Sheet,
SheetContent,
SheetDescription,
SheetHeader,
SheetTitle,
} from "@/components/ui/sheet"
import { Skeleton } from "@/components/ui/skeleton"
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip"
import { PanelLeftIcon } from "lucide-react"
const SIDEBAR_COOKIE_NAME = "sidebar_state"
const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7
const SIDEBAR_WIDTH = "16rem"
const SIDEBAR_WIDTH_MOBILE = "18rem"
const SIDEBAR_WIDTH_ICON = "3rem"
const SIDEBAR_KEYBOARD_SHORTCUT = "b"
type SidebarContextProps = {
state: "expanded" | "collapsed"
open: boolean
setOpen: (open: boolean) => void
openMobile: boolean
setOpenMobile: (open: boolean) => void
isMobile: boolean
toggleSidebar: () => void
}
const SidebarContext = React.createContext<SidebarContextProps | null>(null)
function useSidebar() {
const context = React.useContext(SidebarContext)
if (!context) {
throw new Error("useSidebar must be used within a SidebarProvider.")
}
return context
}
function SidebarProvider({
defaultOpen = true,
open: openProp,
onOpenChange: setOpenProp,
className,
style,
children,
...props
}: React.ComponentProps<"div"> & {
defaultOpen?: boolean
open?: boolean
onOpenChange?: (open: boolean) => void
}) {
const isMobile = useIsMobile()
const [openMobile, setOpenMobile] = React.useState(false)
// This is the internal state of the sidebar.
// We use openProp and setOpenProp for control from outside the component.
const [_open, _setOpen] = React.useState(defaultOpen)
const open = openProp ?? _open
const setOpen = React.useCallback(
(value: boolean | ((value: boolean) => boolean)) => {
const openState = typeof value === "function" ? value(open) : value
if (setOpenProp) {
setOpenProp(openState)
} else {
_setOpen(openState)
}
// This sets the cookie to keep the sidebar state.
document.cookie = `${SIDEBAR_COOKIE_NAME}=${openState}; path=/; max-age=${SIDEBAR_COOKIE_MAX_AGE}`
},
[setOpenProp, open]
)
// Helper to toggle the sidebar.
const toggleSidebar = React.useCallback(() => {
return isMobile ? setOpenMobile((open) => !open) : setOpen((open) => !open)
}, [isMobile, setOpen, setOpenMobile])
// Adds a keyboard shortcut to toggle the sidebar.
React.useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if (
event.key === SIDEBAR_KEYBOARD_SHORTCUT &&
(event.metaKey || event.ctrlKey)
) {
event.preventDefault()
toggleSidebar()
}
}
window.addEventListener("keydown", handleKeyDown)
return () => window.removeEventListener("keydown", handleKeyDown)
}, [toggleSidebar])
// We add a state so that we can do data-state="expanded" or "collapsed".
// This makes it easier to style the sidebar with Tailwind classes.
const state = open ? "expanded" : "collapsed"
const contextValue = React.useMemo<SidebarContextProps>(
() => ({
state,
open,
setOpen,
isMobile,
openMobile,
setOpenMobile,
toggleSidebar,
}),
[state, open, setOpen, isMobile, openMobile, setOpenMobile, toggleSidebar]
)
return (
<SidebarContext.Provider value={contextValue}>
<div
data-slot="sidebar-wrapper"
style={
{
"--sidebar-width": SIDEBAR_WIDTH,
"--sidebar-width-icon": SIDEBAR_WIDTH_ICON,
...style,
} as React.CSSProperties
}
className={cn(
"group/sidebar-wrapper flex min-h-svh w-full has-data-[variant=inset]:bg-sidebar",
className
)}
{...props}
>
{children}
</div>
</SidebarContext.Provider>
)
}
function Sidebar({
side = "left",
variant = "sidebar",
collapsible = "offcanvas",
className,
children,
dir,
...props
}: React.ComponentProps<"div"> & {
side?: "left" | "right"
variant?: "sidebar" | "floating" | "inset"
collapsible?: "offcanvas" | "icon" | "none"
}) {
const { isMobile, state, openMobile, setOpenMobile } = useSidebar()
if (collapsible === "none") {
return (
<div
data-slot="sidebar"
className={cn(
"flex h-full w-(--sidebar-width) flex-col bg-sidebar text-sidebar-foreground",
className
)}
{...props}
>
{children}
</div>
)
}
if (isMobile) {
return (
<Sheet open={openMobile} onOpenChange={setOpenMobile} {...props}>
<SheetContent
dir={dir}
data-sidebar="sidebar"
data-slot="sidebar"
data-mobile="true"
className="w-(--sidebar-width) bg-sidebar p-0 text-sidebar-foreground [&>button]:hidden"
style={
{
"--sidebar-width": SIDEBAR_WIDTH_MOBILE,
} as React.CSSProperties
}
side={side}
>
<SheetHeader className="sr-only">
<SheetTitle>Sidebar</SheetTitle>
<SheetDescription>Displays the mobile sidebar.</SheetDescription>
</SheetHeader>
<div className="flex h-full w-full flex-col">{children}</div>
</SheetContent>
</Sheet>
)
}
return (
<div
className="group peer hidden text-sidebar-foreground md:block"
data-state={state}
data-collapsible={state === "collapsed" ? collapsible : ""}
data-variant={variant}
data-side={side}
data-slot="sidebar"
>
{/* This is what handles the sidebar gap on desktop */}
<div
data-slot="sidebar-gap"
className={cn(
"relative w-(--sidebar-width) bg-transparent transition-[width] duration-200 ease-linear",
"group-data-[collapsible=offcanvas]:w-0",
"group-data-[side=right]:rotate-180",
variant === "floating" || variant === "inset"
? "group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4)))]"
: "group-data-[collapsible=icon]:w-(--sidebar-width-icon)"
)}
/>
<div
data-slot="sidebar-container"
data-side={side}
className={cn(
"fixed inset-y-0 z-10 hidden h-svh w-(--sidebar-width) transition-[left,right,width] duration-200 ease-linear data-[side=left]:left-0 data-[side=left]:group-data-[collapsible=offcanvas]:left-[calc(var(--sidebar-width)*-1)] data-[side=right]:right-0 data-[side=right]:group-data-[collapsible=offcanvas]:right-[calc(var(--sidebar-width)*-1)] md:flex",
// Adjust the padding for floating and inset variants.
variant === "floating" || variant === "inset"
? "p-2 group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4))+2px)]"
: "group-data-[collapsible=icon]:w-(--sidebar-width-icon) group-data-[side=left]:border-r group-data-[side=right]:border-l",
className
)}
{...props}
>
<div
data-sidebar="sidebar"
data-slot="sidebar-inner"
className="flex size-full flex-col bg-sidebar group-data-[variant=floating]:rounded-lg group-data-[variant=floating]:shadow-sm group-data-[variant=floating]:ring-1 group-data-[variant=floating]:ring-sidebar-border"
>
{children}
</div>
</div>
</div>
)
}
function SidebarTrigger({
className,
onClick,
...props
}: React.ComponentProps<typeof Button>) {
const { toggleSidebar } = useSidebar()
return (
<Button
data-sidebar="trigger"
data-slot="sidebar-trigger"
variant="ghost"
size="icon-sm"
className={cn(className)}
onClick={(event) => {
onClick?.(event)
toggleSidebar()
}}
{...props}
>
<PanelLeftIcon />
<span className="sr-only">Toggle Sidebar</span>
</Button>
)
}
function SidebarRail({ className, ...props }: React.ComponentProps<"button">) {
const { toggleSidebar } = useSidebar()
return (
<button
data-sidebar="rail"
data-slot="sidebar-rail"
aria-label="Toggle Sidebar"
tabIndex={-1}
onClick={toggleSidebar}
title="Toggle Sidebar"
className={cn(
"absolute inset-y-0 z-20 hidden w-4 transition-all ease-linear group-data-[side=left]:-right-4 group-data-[side=right]:left-0 after:absolute after:inset-y-0 after:start-1/2 after:w-[2px] hover:after:bg-sidebar-border sm:flex ltr:-translate-x-1/2 rtl:-translate-x-1/2",
"in-data-[side=left]:cursor-w-resize in-data-[side=right]:cursor-e-resize",
"[[data-side=left][data-state=collapsed]_&]:cursor-e-resize [[data-side=right][data-state=collapsed]_&]:cursor-w-resize",
"group-data-[collapsible=offcanvas]:translate-x-0 group-data-[collapsible=offcanvas]:after:left-full hover:group-data-[collapsible=offcanvas]:bg-sidebar",
"[[data-side=left][data-collapsible=offcanvas]_&]:-right-2",
"[[data-side=right][data-collapsible=offcanvas]_&]:-left-2",
className
)}
{...props}
/>
)
}
function SidebarInset({ className, ...props }: React.ComponentProps<"main">) {
return (
<main
data-slot="sidebar-inset"
className={cn(
"relative flex w-full flex-1 flex-col bg-background md:peer-data-[variant=inset]:m-2 md:peer-data-[variant=inset]:ml-0 md:peer-data-[variant=inset]:rounded-xl md:peer-data-[variant=inset]:shadow-sm md:peer-data-[variant=inset]:peer-data-[state=collapsed]:ml-2",
className
)}
{...props}
/>
)
}
function SidebarInput({
className,
...props
}: React.ComponentProps<typeof Input>) {
return (
<Input
data-slot="sidebar-input"
data-sidebar="input"
className={cn("h-8 w-full bg-background shadow-none", className)}
{...props}
/>
)
}
function SidebarHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sidebar-header"
data-sidebar="header"
className={cn("flex flex-col gap-2 p-2", className)}
{...props}
/>
)
}
function SidebarFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sidebar-footer"
data-sidebar="footer"
className={cn("flex flex-col gap-2 p-2", className)}
{...props}
/>
)
}
function SidebarSeparator({
className,
...props
}: React.ComponentProps<typeof Separator>) {
return (
<Separator
data-slot="sidebar-separator"
data-sidebar="separator"
className={cn("mx-2 w-auto bg-sidebar-border", className)}
{...props}
/>
)
}
function SidebarContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sidebar-content"
data-sidebar="content"
className={cn(
"no-scrollbar flex min-h-0 flex-1 flex-col gap-0 overflow-auto group-data-[collapsible=icon]:overflow-hidden",
className
)}
{...props}
/>
)
}
function SidebarGroup({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sidebar-group"
data-sidebar="group"
className={cn("relative flex w-full min-w-0 flex-col p-2", className)}
{...props}
/>
)
}
function SidebarGroupLabel({
className,
asChild = false,
...props
}: React.ComponentProps<"div"> & { asChild?: boolean }) {
const Comp = asChild ? Slot.Root : "div"
return (
<Comp
data-slot="sidebar-group-label"
data-sidebar="group-label"
className={cn(
"flex h-8 shrink-0 items-center rounded-md px-2 text-xs font-medium text-sidebar-foreground/70 ring-sidebar-ring outline-hidden transition-[margin,opacity] duration-200 ease-linear group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0 focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
className
)}
{...props}
/>
)
}
function SidebarGroupAction({
className,
asChild = false,
...props
}: React.ComponentProps<"button"> & { asChild?: boolean }) {
const Comp = asChild ? Slot.Root : "button"
return (
<Comp
data-slot="sidebar-group-action"
data-sidebar="group-action"
className={cn(
"absolute top-3.5 right-3 flex aspect-square w-5 items-center justify-center rounded-md p-0 text-sidebar-foreground ring-sidebar-ring outline-hidden transition-transform group-data-[collapsible=icon]:hidden after:absolute after:-inset-2 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 md:after:hidden [&>svg]:size-4 [&>svg]:shrink-0",
className
)}
{...props}
/>
)
}
function SidebarGroupContent({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="sidebar-group-content"
data-sidebar="group-content"
className={cn("w-full text-sm", className)}
{...props}
/>
)
}
function SidebarMenu({ className, ...props }: React.ComponentProps<"ul">) {
return (
<ul
data-slot="sidebar-menu"
data-sidebar="menu"
className={cn("flex w-full min-w-0 flex-col gap-0", className)}
{...props}
/>
)
}
function SidebarMenuItem({ className, ...props }: React.ComponentProps<"li">) {
return (
<li
data-slot="sidebar-menu-item"
data-sidebar="menu-item"
className={cn("group/menu-item relative", className)}
{...props}
/>
)
}
const sidebarMenuButtonVariants = cva(
"peer/menu-button group/menu-button flex w-full items-center gap-2 overflow-hidden rounded-md p-2 text-left text-sm ring-sidebar-ring outline-hidden transition-[width,height,padding] group-has-data-[sidebar=menu-action]/menu-item:pr-8 group-data-[collapsible=icon]:size-8! group-data-[collapsible=icon]:p-2! hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-open:hover:bg-sidebar-accent data-open:hover:text-sidebar-accent-foreground data-active:bg-sidebar-accent data-active:font-medium data-active:text-sidebar-accent-foreground [&_svg]:size-4 [&_svg]:shrink-0 [&>span:last-child]:truncate",
{
variants: {
variant: {
default: "hover:bg-sidebar-accent hover:text-sidebar-accent-foreground",
outline:
"bg-background shadow-[0_0_0_1px_var(--sidebar-border)] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground hover:shadow-[0_0_0_1px_var(--sidebar-accent)]",
},
size: {
default: "h-8 text-sm",
sm: "h-7 text-xs",
lg: "h-12 text-sm group-data-[collapsible=icon]:p-0!",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
function SidebarMenuButton({
asChild = false,
isActive = false,
variant = "default",
size = "default",
tooltip,
className,
...props
}: React.ComponentProps<"button"> & {
asChild?: boolean
isActive?: boolean
tooltip?: string | React.ComponentProps<typeof TooltipContent>
} & VariantProps<typeof sidebarMenuButtonVariants>) {
const Comp = asChild ? Slot.Root : "button"
const { isMobile, state } = useSidebar()
const button = (
<Comp
data-slot="sidebar-menu-button"
data-sidebar="menu-button"
data-size={size}
data-active={isActive}
className={cn(sidebarMenuButtonVariants({ variant, size }), className)}
{...props}
/>
)
if (!tooltip) {
return button
}
if (typeof tooltip === "string") {
tooltip = {
children: tooltip,
}
}
return (
<Tooltip>
<TooltipTrigger asChild>{button}</TooltipTrigger>
<TooltipContent
side="right"
align="center"
hidden={state !== "collapsed" || isMobile}
{...tooltip}
/>
</Tooltip>
)
}
function SidebarMenuAction({
className,
asChild = false,
showOnHover = false,
...props
}: React.ComponentProps<"button"> & {
asChild?: boolean
showOnHover?: boolean
}) {
const Comp = asChild ? Slot.Root : "button"
return (
<Comp
data-slot="sidebar-menu-action"
data-sidebar="menu-action"
className={cn(
"absolute top-1.5 right-1 flex aspect-square w-5 items-center justify-center rounded-md p-0 text-sidebar-foreground ring-sidebar-ring outline-hidden transition-transform group-data-[collapsible=icon]:hidden peer-hover/menu-button:text-sidebar-accent-foreground peer-data-[size=default]/menu-button:top-1.5 peer-data-[size=lg]/menu-button:top-2.5 peer-data-[size=sm]/menu-button:top-1 after:absolute after:-inset-2 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 md:after:hidden [&>svg]:size-4 [&>svg]:shrink-0",
showOnHover &&
"group-focus-within/menu-item:opacity-100 group-hover/menu-item:opacity-100 peer-data-active/menu-button:text-sidebar-accent-foreground aria-expanded:opacity-100 md:opacity-0",
className
)}
{...props}
/>
)
}
function SidebarMenuBadge({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="sidebar-menu-badge"
data-sidebar="menu-badge"
className={cn(
"pointer-events-none absolute right-1 flex h-5 min-w-5 items-center justify-center rounded-md px-1 text-xs font-medium text-sidebar-foreground tabular-nums select-none group-data-[collapsible=icon]:hidden peer-hover/menu-button:text-sidebar-accent-foreground peer-data-[size=default]/menu-button:top-1.5 peer-data-[size=lg]/menu-button:top-2.5 peer-data-[size=sm]/menu-button:top-1 peer-data-active/menu-button:text-sidebar-accent-foreground",
className
)}
{...props}
/>
)
}
function SidebarMenuSkeleton({
className,
showIcon = false,
...props
}: React.ComponentProps<"div"> & {
showIcon?: boolean
}) {
// Random width between 50 to 90%.
const [width] = React.useState(() => {
return `${Math.floor(Math.random() * 40) + 50}%`
})
return (
<div
data-slot="sidebar-menu-skeleton"
data-sidebar="menu-skeleton"
className={cn("flex h-8 items-center gap-2 rounded-md px-2", className)}
{...props}
>
{showIcon && (
<Skeleton
className="size-4 rounded-md"
data-sidebar="menu-skeleton-icon"
/>
)}
<Skeleton
className="h-4 max-w-(--skeleton-width) flex-1"
data-sidebar="menu-skeleton-text"
style={
{
"--skeleton-width": width,
} as React.CSSProperties
}
/>
</div>
)
}
function SidebarMenuSub({ className, ...props }: React.ComponentProps<"ul">) {
return (
<ul
data-slot="sidebar-menu-sub"
data-sidebar="menu-sub"
className={cn(
"mx-3.5 flex min-w-0 translate-x-px flex-col gap-1 border-l border-sidebar-border px-2.5 py-0.5 group-data-[collapsible=icon]:hidden",
className
)}
{...props}
/>
)
}
function SidebarMenuSubItem({
className,
...props
}: React.ComponentProps<"li">) {
return (
<li
data-slot="sidebar-menu-sub-item"
data-sidebar="menu-sub-item"
className={cn("group/menu-sub-item relative", className)}
{...props}
/>
)
}
function SidebarMenuSubButton({
asChild = false,
size = "md",
isActive = false,
className,
...props
}: React.ComponentProps<"a"> & {
asChild?: boolean
size?: "sm" | "md"
isActive?: boolean
}) {
const Comp = asChild ? Slot.Root : "a"
return (
<Comp
data-slot="sidebar-menu-sub-button"
data-sidebar="menu-sub-button"
data-size={size}
data-active={isActive}
className={cn(
"flex h-7 min-w-0 -translate-x-px items-center gap-2 overflow-hidden rounded-md px-2 text-sidebar-foreground ring-sidebar-ring outline-hidden group-data-[collapsible=icon]:hidden hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-[size=md]:text-sm data-[size=sm]:text-xs data-active:bg-sidebar-accent data-active:text-sidebar-accent-foreground [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0 [&>svg]:text-sidebar-accent-foreground",
className
)}
{...props}
/>
)
}
export {
Sidebar,
SidebarContent,
SidebarFooter,
SidebarGroup,
SidebarGroupAction,
SidebarGroupContent,
SidebarGroupLabel,
SidebarHeader,
SidebarInput,
SidebarInset,
SidebarMenu,
SidebarMenuAction,
SidebarMenuBadge,
SidebarMenuButton,
SidebarMenuItem,
SidebarMenuSkeleton,
SidebarMenuSub,
SidebarMenuSubButton,
SidebarMenuSubItem,
SidebarProvider,
SidebarRail,
SidebarSeparator,
SidebarTrigger,
useSidebar,
}
+13
View File
@@ -0,0 +1,13 @@
import { cn } from "@/lib/utils"
function Skeleton({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="skeleton"
className={cn("animate-pulse rounded-md bg-muted", className)}
{...props}
/>
)
}
export { Skeleton }
+59
View File
@@ -0,0 +1,59 @@
"use client"
import * as React from "react"
import { Slider as SliderPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
function Slider({
className,
defaultValue,
value,
min = 0,
max = 100,
...props
}: React.ComponentProps<typeof SliderPrimitive.Root>) {
const _values = React.useMemo(
() =>
Array.isArray(value)
? value
: Array.isArray(defaultValue)
? defaultValue
: [min, max],
[value, defaultValue, min, max]
)
return (
<SliderPrimitive.Root
data-slot="slider"
defaultValue={defaultValue}
value={value}
min={min}
max={max}
className={cn(
"relative flex w-full touch-none items-center select-none data-disabled:opacity-50 data-vertical:h-full data-vertical:min-h-40 data-vertical:w-auto data-vertical:flex-col",
className
)}
{...props}
>
<SliderPrimitive.Track
data-slot="slider-track"
className="relative grow overflow-hidden rounded-full bg-muted data-horizontal:h-1 data-horizontal:w-full data-vertical:h-full data-vertical:w-1"
>
<SliderPrimitive.Range
data-slot="slider-range"
className="absolute bg-primary select-none data-horizontal:h-full data-vertical:w-full"
/>
</SliderPrimitive.Track>
{Array.from({ length: _values.length }, (_, index) => (
<SliderPrimitive.Thumb
data-slot="slider-thumb"
key={index}
className="relative block size-3 shrink-0 rounded-full border border-ring bg-white ring-ring/50 transition-[color,box-shadow] select-none after:absolute after:-inset-2 hover:ring-3 focus-visible:ring-3 focus-visible:outline-hidden active:ring-3 disabled:pointer-events-none disabled:opacity-50"
/>
))}
</SliderPrimitive.Root>
)
}
export { Slider }
+49
View File
@@ -0,0 +1,49 @@
"use client"
import { useTheme } from "next-themes"
import { Toaster as Sonner, type ToasterProps } from "sonner"
import { CircleCheckIcon, InfoIcon, TriangleAlertIcon, OctagonXIcon, Loader2Icon } from "lucide-react"
const Toaster = ({ ...props }: ToasterProps) => {
const { theme = "system" } = useTheme()
return (
<Sonner
theme={theme as ToasterProps["theme"]}
className="toaster group"
icons={{
success: (
<CircleCheckIcon className="size-4" />
),
info: (
<InfoIcon className="size-4" />
),
warning: (
<TriangleAlertIcon className="size-4" />
),
error: (
<OctagonXIcon className="size-4" />
),
loading: (
<Loader2Icon className="size-4 animate-spin" />
),
}}
style={
{
"--normal-bg": "var(--popover)",
"--normal-text": "var(--popover-foreground)",
"--normal-border": "var(--border)",
"--border-radius": "var(--radius)",
} as React.CSSProperties
}
toastOptions={{
classNames: {
toast: "cn-toast",
},
}}
{...props}
/>
)
}
export { Toaster }
+59
View File
@@ -0,0 +1,59 @@
"use client";
import { ArrowDownIcon, ArrowUpDownIcon, ArrowUpIcon } from "lucide-react";
import { useTranslations } from "next-intl";
import type * as React from "react";
import { TableHead } from "@/components/ui/table";
import type { SortDirection } from "@/lib/sort-preference";
import { cn } from "@/lib/utils";
// SortableHead is a table header cell that toggles a column's sort on click.
// It renders a neutral up/down glyph when inactive and a directional arrow when
// its field is the active sort, mirroring the pattern first used on the tasks
// table so sortable columns look and behave the same across the app.
export function SortableHead<Field extends string>({
field,
label,
activeField,
direction,
align = "left",
className,
onSort,
}: {
field: Field;
label: string;
activeField: Field | null;
direction: SortDirection;
align?: "left" | "right";
className?: string;
onSort: (field: Field) => void;
}) {
const t = useTranslations("sortableHead");
const active = activeField === field;
let ariaSort: React.AriaAttributes["aria-sort"] = "none";
if (active) ariaSort = direction === "asc" ? "ascending" : "descending";
let actionLabel = t("sortBy", { label });
if (active) actionLabel = t("active", { label, direction });
let icon = <ArrowUpDownIcon className="size-3.5 opacity-40 transition-opacity group-hover/sort:opacity-100" />;
if (active) icon = direction === "asc" ? <ArrowUpIcon className="size-3.5" /> : <ArrowDownIcon className="size-3.5" />;
return (
<TableHead className={className} aria-sort={ariaSort}>
<button
type="button"
className={cn(
"group/sort inline-flex h-full w-full items-center gap-1 outline-none focus-visible:underline",
align === "right" && "justify-end",
)}
aria-label={actionLabel}
onClick={() => onSort(field)}
>
<span>{label}</span>
{icon}
</button>
</TableHead>
);
}
+10
View File
@@ -0,0 +1,10 @@
import { cn } from "@/lib/utils"
import { Loader2Icon } from "lucide-react"
function Spinner({ className, ...props }: React.ComponentProps<"svg">) {
return (
<Loader2Icon data-slot="spinner" role="status" aria-label="Loading" className={cn("size-4 animate-spin", className)} {...props} />
)
}
export { Spinner }
+33
View File
@@ -0,0 +1,33 @@
"use client"
import * as React from "react"
import { Switch as SwitchPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
function Switch({
className,
size = "default",
...props
}: React.ComponentProps<typeof SwitchPrimitive.Root> & {
size?: "sm" | "default"
}) {
return (
<SwitchPrimitive.Root
data-slot="switch"
data-size={size}
className={cn(
"peer group/switch relative inline-flex shrink-0 items-center rounded-full border border-transparent transition-all outline-none after:absolute after:-inset-x-3 after:-inset-y-2 focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 data-[size=default]:h-[18.4px] data-[size=default]:w-[32px] data-[size=sm]:h-[14px] data-[size=sm]:w-[24px] dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 data-checked:bg-primary data-unchecked:bg-input dark:data-unchecked:bg-input/80 data-disabled:cursor-not-allowed data-disabled:opacity-50",
className
)}
{...props}
>
<SwitchPrimitive.Thumb
data-slot="switch-thumb"
className="pointer-events-none block rounded-full bg-background ring-0 transition-transform group-data-[size=default]/switch:size-4 group-data-[size=sm]/switch:size-3 group-data-[size=default]/switch:data-checked:translate-x-[calc(100%-2px)] group-data-[size=sm]/switch:data-checked:translate-x-[calc(100%-2px)] dark:data-checked:bg-primary-foreground group-data-[size=default]/switch:data-unchecked:translate-x-0 group-data-[size=sm]/switch:data-unchecked:translate-x-0 dark:data-unchecked:bg-foreground"
/>
</SwitchPrimitive.Root>
)
}
export { Switch }
+116
View File
@@ -0,0 +1,116 @@
"use client"
import * as React from "react"
import { cn } from "@/lib/utils"
function Table({ className, ...props }: React.ComponentProps<"table">) {
return (
<div
data-slot="table-container"
className="relative w-full overflow-x-auto"
>
<table
data-slot="table"
className={cn("w-full caption-bottom text-sm", className)}
{...props}
/>
</div>
)
}
function TableHeader({ className, ...props }: React.ComponentProps<"thead">) {
return (
<thead
data-slot="table-header"
className={cn("[&_tr]:border-b", className)}
{...props}
/>
)
}
function TableBody({ className, ...props }: React.ComponentProps<"tbody">) {
return (
<tbody
data-slot="table-body"
className={cn("[&_tr:last-child]:border-0", className)}
{...props}
/>
)
}
function TableFooter({ className, ...props }: React.ComponentProps<"tfoot">) {
return (
<tfoot
data-slot="table-footer"
className={cn(
"border-t bg-muted/50 font-medium [&>tr]:last:border-b-0",
className
)}
{...props}
/>
)
}
function TableRow({ className, ...props }: React.ComponentProps<"tr">) {
return (
<tr
data-slot="table-row"
className={cn(
"border-b transition-colors hover:bg-muted/50 has-aria-expanded:bg-muted/50 data-[state=selected]:bg-muted",
className
)}
{...props}
/>
)
}
function TableHead({ className, ...props }: React.ComponentProps<"th">) {
return (
<th
data-slot="table-head"
className={cn(
"h-10 px-2 text-left align-middle font-medium whitespace-nowrap text-foreground [&:has([role=checkbox])]:pr-0",
className
)}
{...props}
/>
)
}
function TableCell({ className, ...props }: React.ComponentProps<"td">) {
return (
<td
data-slot="table-cell"
className={cn(
"p-2 align-middle whitespace-nowrap [&:has([role=checkbox])]:pr-0",
className
)}
{...props}
/>
)
}
function TableCaption({
className,
...props
}: React.ComponentProps<"caption">) {
return (
<caption
data-slot="table-caption"
className={cn("mt-4 text-sm text-muted-foreground", className)}
{...props}
/>
)
}
export {
Table,
TableHeader,
TableBody,
TableFooter,
TableHead,
TableRow,
TableCell,
TableCaption,
}
+90
View File
@@ -0,0 +1,90 @@
"use client"
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { Tabs as TabsPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
function Tabs({
className,
orientation = "horizontal",
...props
}: React.ComponentProps<typeof TabsPrimitive.Root>) {
return (
<TabsPrimitive.Root
data-slot="tabs"
data-orientation={orientation}
className={cn(
"group/tabs flex gap-2 data-horizontal:flex-col",
className
)}
{...props}
/>
)
}
const tabsListVariants = cva(
"group/tabs-list inline-flex w-fit items-center justify-center rounded-lg p-[3px] text-muted-foreground group-data-horizontal/tabs:h-8 group-data-vertical/tabs:h-fit group-data-vertical/tabs:flex-col data-[variant=line]:rounded-none",
{
variants: {
variant: {
default: "bg-muted",
line: "gap-1 bg-transparent",
},
},
defaultVariants: {
variant: "default",
},
}
)
function TabsList({
className,
variant = "default",
...props
}: React.ComponentProps<typeof TabsPrimitive.List> &
VariantProps<typeof tabsListVariants>) {
return (
<TabsPrimitive.List
data-slot="tabs-list"
data-variant={variant}
className={cn(tabsListVariants({ variant }), className)}
{...props}
/>
)
}
function TabsTrigger({
className,
...props
}: React.ComponentProps<typeof TabsPrimitive.Trigger>) {
return (
<TabsPrimitive.Trigger
data-slot="tabs-trigger"
className={cn(
"relative inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-1.5 py-0.5 text-sm font-medium whitespace-nowrap text-foreground/60 transition-all group-data-vertical/tabs:w-full group-data-vertical/tabs:justify-start hover:text-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-1 focus-visible:outline-ring disabled:pointer-events-none disabled:opacity-50 has-data-[icon=inline-end]:pr-1 has-data-[icon=inline-start]:pl-1 dark:text-muted-foreground dark:hover:text-foreground group-data-[variant=default]/tabs-list:data-active:shadow-sm group-data-[variant=line]/tabs-list:data-active:shadow-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
"group-data-[variant=line]/tabs-list:bg-transparent group-data-[variant=line]/tabs-list:data-active:bg-transparent dark:group-data-[variant=line]/tabs-list:data-active:border-transparent dark:group-data-[variant=line]/tabs-list:data-active:bg-transparent",
"data-active:bg-background data-active:text-foreground dark:data-active:border-input dark:data-active:bg-input/30 dark:data-active:text-foreground",
"after:absolute after:bg-foreground after:opacity-0 after:transition-opacity group-data-horizontal/tabs:after:inset-x-0 group-data-horizontal/tabs:after:bottom-[-5px] group-data-horizontal/tabs:after:h-0.5 group-data-vertical/tabs:after:inset-y-0 group-data-vertical/tabs:after:-right-1 group-data-vertical/tabs:after:w-0.5 group-data-[variant=line]/tabs-list:data-active:after:opacity-100",
className
)}
{...props}
/>
)
}
function TabsContent({
className,
...props
}: React.ComponentProps<typeof TabsPrimitive.Content>) {
return (
<TabsPrimitive.Content
data-slot="tabs-content"
className={cn("flex-1 text-sm outline-none", className)}
{...props}
/>
)
}
export { Tabs, TabsList, TabsTrigger, TabsContent, tabsListVariants }
+21
View File
@@ -0,0 +1,21 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
return (
<textarea
data-slot="textarea"
className={cn(
// field-sizing-content auto-grows the textarea to its content; cap the
// growth (and scroll past it) so a large paste can't blow a dialog past the
// viewport. Call sites can override via a max-h-* class (twMerge wins).
"flex field-sizing-content max-h-[45vh] min-h-16 w-full overflow-auto rounded-lg border border-input bg-transparent px-2.5 py-2 text-base transition-colors outline-none placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40",
className
)}
{...props}
/>
)
}
export { Textarea }
+89
View File
@@ -0,0 +1,89 @@
"use client"
import * as React from "react"
import { type VariantProps } from "class-variance-authority"
import { ToggleGroup as ToggleGroupPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
import { toggleVariants } from "@/components/ui/toggle"
const ToggleGroupContext = React.createContext<
VariantProps<typeof toggleVariants> & {
spacing?: number
orientation?: "horizontal" | "vertical"
}
>({
size: "default",
variant: "default",
spacing: 2,
orientation: "horizontal",
})
function ToggleGroup({
className,
variant,
size,
spacing = 2,
orientation = "horizontal",
children,
...props
}: React.ComponentProps<typeof ToggleGroupPrimitive.Root> &
VariantProps<typeof toggleVariants> & {
spacing?: number
orientation?: "horizontal" | "vertical"
}) {
return (
<ToggleGroupPrimitive.Root
data-slot="toggle-group"
data-variant={variant}
data-size={size}
data-spacing={spacing}
data-orientation={orientation}
style={{ "--gap": spacing } as React.CSSProperties}
className={cn(
"group/toggle-group flex w-fit flex-row items-center gap-[--spacing(var(--gap))] rounded-lg data-[size=sm]:rounded-[min(var(--radius-md),10px)] data-vertical:flex-col data-vertical:items-stretch",
className
)}
{...props}
>
<ToggleGroupContext.Provider
value={{ variant, size, spacing, orientation }}
>
{children}
</ToggleGroupContext.Provider>
</ToggleGroupPrimitive.Root>
)
}
function ToggleGroupItem({
className,
children,
variant = "default",
size = "default",
...props
}: React.ComponentProps<typeof ToggleGroupPrimitive.Item> &
VariantProps<typeof toggleVariants>) {
const context = React.useContext(ToggleGroupContext)
return (
<ToggleGroupPrimitive.Item
data-slot="toggle-group-item"
data-variant={context.variant || variant}
data-size={context.size || size}
data-spacing={context.spacing}
className={cn(
"shrink-0 group-data-[spacing=0]/toggle-group:rounded-none group-data-[spacing=0]/toggle-group:px-2 focus:z-10 focus-visible:z-10 group-data-[spacing=0]/toggle-group:has-data-[icon=inline-end]:pr-1.5 group-data-[spacing=0]/toggle-group:has-data-[icon=inline-start]:pl-1.5 group-data-horizontal/toggle-group:data-[spacing=0]:first:rounded-l-lg group-data-vertical/toggle-group:data-[spacing=0]:first:rounded-t-lg group-data-horizontal/toggle-group:data-[spacing=0]:last:rounded-r-lg group-data-vertical/toggle-group:data-[spacing=0]:last:rounded-b-lg group-data-horizontal/toggle-group:data-[spacing=0]:data-[variant=outline]:border-l-0 group-data-vertical/toggle-group:data-[spacing=0]:data-[variant=outline]:border-t-0 group-data-horizontal/toggle-group:data-[spacing=0]:data-[variant=outline]:first:border-l group-data-vertical/toggle-group:data-[spacing=0]:data-[variant=outline]:first:border-t",
toggleVariants({
variant: context.variant || variant,
size: context.size || size,
}),
className
)}
{...props}
>
{children}
</ToggleGroupPrimitive.Item>
)
}
export { ToggleGroup, ToggleGroupItem }
+47
View File
@@ -0,0 +1,47 @@
"use client"
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { Toggle as TogglePrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
const toggleVariants = cva(
"group/toggle inline-flex items-center justify-center gap-1 rounded-lg text-sm font-medium whitespace-nowrap transition-all outline-none hover:bg-muted hover:text-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 aria-pressed:bg-muted data-[state=on]:bg-muted dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
{
variants: {
variant: {
default: "bg-transparent",
outline: "border border-input bg-transparent hover:bg-muted",
},
size: {
default:
"h-8 min-w-8 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
sm: "h-7 min-w-7 rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem] has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5",
lg: "h-9 min-w-9 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
function Toggle({
className,
variant = "default",
size = "default",
...props
}: React.ComponentProps<typeof TogglePrimitive.Root> &
VariantProps<typeof toggleVariants>) {
return (
<TogglePrimitive.Root
data-slot="toggle"
className={cn(toggleVariants({ variant, size, className }))}
{...props}
/>
)
}
export { Toggle, toggleVariants }
+57
View File
@@ -0,0 +1,57 @@
"use client"
import * as React from "react"
import { Tooltip as TooltipPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
function TooltipProvider({
delayDuration = 0,
...props
}: React.ComponentProps<typeof TooltipPrimitive.Provider>) {
return (
<TooltipPrimitive.Provider
data-slot="tooltip-provider"
delayDuration={delayDuration}
{...props}
/>
)
}
function Tooltip({
...props
}: React.ComponentProps<typeof TooltipPrimitive.Root>) {
return <TooltipPrimitive.Root data-slot="tooltip" {...props} />
}
function TooltipTrigger({
...props
}: React.ComponentProps<typeof TooltipPrimitive.Trigger>) {
return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />
}
function TooltipContent({
className,
sideOffset = 0,
children,
...props
}: React.ComponentProps<typeof TooltipPrimitive.Content>) {
return (
<TooltipPrimitive.Portal>
<TooltipPrimitive.Content
data-slot="tooltip-content"
sideOffset={sideOffset}
className={cn(
"z-50 inline-flex w-fit max-w-xs origin-(--radix-tooltip-content-transform-origin) items-center gap-1.5 rounded-md bg-foreground px-3 py-1.5 text-xs text-background has-data-[slot=kbd]:pr-1.5 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 **:data-[slot=kbd]:relative **:data-[slot=kbd]:isolate **:data-[slot=kbd]:z-50 **:data-[slot=kbd]:rounded-sm data-[state=delayed-open]:animate-in data-[state=delayed-open]:fade-in-0 data-[state=delayed-open]:zoom-in-95 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
className
)}
{...props}
>
{children}
<TooltipPrimitive.Arrow className="z-50 size-2.5 translate-y-[calc(-50%_-_2px)] rotate-45 rounded-[2px] bg-foreground fill-foreground" />
</TooltipPrimitive.Content>
</TooltipPrimitive.Portal>
)
}
export { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger }