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