mirror of
https://github.com/AmruthPillai/Reactive-Resume.git
synced 2026-07-25 09:24:54 +10:00
use vite+
This commit is contained in:
+313
-313
@@ -6,12 +6,12 @@ import { t } from "@lingui/core/macro";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import { eventIteratorToUnproxiedDataStream } from "@orpc/client";
|
||||
import {
|
||||
CheckCircleIcon,
|
||||
CircleNotchIcon,
|
||||
PaperPlaneRightIcon,
|
||||
SparkleIcon,
|
||||
StopIcon,
|
||||
TrashSimpleIcon,
|
||||
CheckCircleIcon,
|
||||
CircleNotchIcon,
|
||||
PaperPlaneRightIcon,
|
||||
SparkleIcon,
|
||||
StopIcon,
|
||||
TrashSimpleIcon,
|
||||
} from "@phosphor-icons/react";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
@@ -31,32 +31,32 @@ import { useResumeStore } from "../resume/store/resume";
|
||||
* Returns operations that haven't been applied yet (tracked by processedToolCallIds).
|
||||
*/
|
||||
function extractNewPatchOperations(
|
||||
messages: UIMessage[],
|
||||
processedIds: Set<string>,
|
||||
messages: UIMessage[],
|
||||
processedIds: Set<string>,
|
||||
): { operations: Operation[]; newIds: string[] } {
|
||||
const operations: Operation[] = [];
|
||||
const newIds: string[] = [];
|
||||
const operations: Operation[] = [];
|
||||
const newIds: string[] = [];
|
||||
|
||||
for (const message of messages) {
|
||||
if (message.role !== "assistant") continue;
|
||||
for (const part of message.parts) {
|
||||
if (part.type !== "tool-patch_resume") continue;
|
||||
for (const message of messages) {
|
||||
if (message.role !== "assistant") continue;
|
||||
for (const part of message.parts) {
|
||||
if (part.type !== "tool-patch_resume") continue;
|
||||
|
||||
const toolPart = part as any;
|
||||
if (toolPart.state !== "output-available") continue;
|
||||
const toolPart = part as any;
|
||||
if (toolPart.state !== "output-available") continue;
|
||||
|
||||
const callId = toolPart.toolCallId as string;
|
||||
if (processedIds.has(callId)) continue;
|
||||
const callId = toolPart.toolCallId as string;
|
||||
if (processedIds.has(callId)) continue;
|
||||
|
||||
const result = toolPart.output as { success: boolean; appliedOperations?: Operation[] } | undefined;
|
||||
if (result?.success && result.appliedOperations) {
|
||||
operations.push(...result.appliedOperations);
|
||||
newIds.push(callId);
|
||||
}
|
||||
}
|
||||
}
|
||||
const result = toolPart.output as { success: boolean; appliedOperations?: Operation[] } | undefined;
|
||||
if (result?.success && result.appliedOperations) {
|
||||
operations.push(...result.appliedOperations);
|
||||
newIds.push(callId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { operations, newIds };
|
||||
return { operations, newIds };
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -65,346 +65,346 @@ function extractNewPatchOperations(
|
||||
const STORAGE_KEY_PREFIX = "ai-chat-messages";
|
||||
|
||||
function getStorageKey(resumeId: string): string {
|
||||
return `${STORAGE_KEY_PREFIX}:${resumeId}`;
|
||||
return `${STORAGE_KEY_PREFIX}:${resumeId}`;
|
||||
}
|
||||
|
||||
function loadStoredMessages(resumeId: string): UIMessage[] {
|
||||
try {
|
||||
const stored = localStorage.getItem(getStorageKey(resumeId));
|
||||
if (!stored) return [];
|
||||
return JSON.parse(stored) as UIMessage[];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
try {
|
||||
const stored = localStorage.getItem(getStorageKey(resumeId));
|
||||
if (!stored) return [];
|
||||
return JSON.parse(stored) as UIMessage[];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function saveStoredMessages(resumeId: string, messages: UIMessage[]): void {
|
||||
try {
|
||||
localStorage.setItem(getStorageKey(resumeId), JSON.stringify(messages));
|
||||
} catch {
|
||||
// Silently fail if localStorage is full or unavailable
|
||||
}
|
||||
try {
|
||||
localStorage.setItem(getStorageKey(resumeId), JSON.stringify(messages));
|
||||
} catch {
|
||||
// Silently fail if localStorage is full or unavailable
|
||||
}
|
||||
}
|
||||
|
||||
function clearStoredMessages(resumeId: string): void {
|
||||
localStorage.removeItem(getStorageKey(resumeId));
|
||||
localStorage.removeItem(getStorageKey(resumeId));
|
||||
}
|
||||
|
||||
function formatPath(path: string): string {
|
||||
return path.replace(/^\//, "").replace(/\//g, " › ");
|
||||
return path.replace(/^\//, "").replace(/\//g, " › ");
|
||||
}
|
||||
|
||||
function formatOperationLabel(op: string): string {
|
||||
switch (op) {
|
||||
case "replace":
|
||||
return t`Updated`;
|
||||
case "add":
|
||||
return t`Added`;
|
||||
case "remove":
|
||||
return t`Removed`;
|
||||
default:
|
||||
return op;
|
||||
}
|
||||
switch (op) {
|
||||
case "replace":
|
||||
return t`Updated`;
|
||||
case "add":
|
||||
return t`Added`;
|
||||
case "remove":
|
||||
return t`Removed`;
|
||||
default:
|
||||
return op;
|
||||
}
|
||||
}
|
||||
|
||||
function ToolBadge({ state, operations }: { state: string; operations?: { op: string; path: string }[] }) {
|
||||
const isDone = state === "output-available";
|
||||
const isDone = state === "output-available";
|
||||
|
||||
return (
|
||||
<div className="my-0.5 flex flex-col gap-1.5">
|
||||
<span
|
||||
data-state={state}
|
||||
className={cn(
|
||||
"inline-flex w-fit items-center gap-1.5 rounded-full border px-2.5 py-1 text-[11px] font-medium",
|
||||
"data-[state=output-available]:border-emerald-500/30 data-[state=output-available]:bg-emerald-500/10 data-[state=output-available]:text-emerald-600 dark:data-[state=output-available]:text-emerald-400",
|
||||
)}
|
||||
>
|
||||
{isDone ? (
|
||||
<CheckCircleIcon weight="fill" className="size-3" />
|
||||
) : (
|
||||
<CircleNotchIcon className="size-3 animate-spin" />
|
||||
)}
|
||||
{isDone ? "Changes applied" : "Applying changes..."}
|
||||
</span>
|
||||
return (
|
||||
<div className="my-0.5 flex flex-col gap-1.5">
|
||||
<span
|
||||
data-state={state}
|
||||
className={cn(
|
||||
"inline-flex w-fit items-center gap-1.5 rounded-full border px-2.5 py-1 text-[11px] font-medium",
|
||||
"data-[state=output-available]:border-emerald-500/30 data-[state=output-available]:bg-emerald-500/10 data-[state=output-available]:text-emerald-600 dark:data-[state=output-available]:text-emerald-400",
|
||||
)}
|
||||
>
|
||||
{isDone ? (
|
||||
<CheckCircleIcon weight="fill" className="size-3" />
|
||||
) : (
|
||||
<CircleNotchIcon className="size-3 animate-spin" />
|
||||
)}
|
||||
{isDone ? "Changes applied" : "Applying changes..."}
|
||||
</span>
|
||||
|
||||
{isDone && operations && operations.length > 0 && (
|
||||
<div className="flex flex-col gap-0.5 pl-1 text-[11px] text-muted-foreground">
|
||||
{operations.map((op, i) => (
|
||||
<span key={i} className="flex items-center gap-1">
|
||||
<span className="opacity-40">•</span>
|
||||
{formatOperationLabel(op.op)} {formatPath(op.path)}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
{isDone && operations && operations.length > 0 && (
|
||||
<div className="flex flex-col gap-0.5 pl-1 text-[11px] text-muted-foreground">
|
||||
{operations.map((op, i) => (
|
||||
<span key={i} className="flex items-center gap-1">
|
||||
<span className="opacity-40">•</span>
|
||||
{formatOperationLabel(op.op)} {formatPath(op.path)}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MessageParts({ message }: { message: UIMessage }) {
|
||||
const parts = message.parts;
|
||||
const parts = message.parts;
|
||||
|
||||
if (parts.length === 0) return null;
|
||||
if (parts.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
{parts.map((part, i) => {
|
||||
if (part.type === "step-start") {
|
||||
if (i === 0) return null;
|
||||
return <hr key={i} className="my-0.5 border-border/40" />;
|
||||
}
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
{parts.map((part, i) => {
|
||||
if (part.type === "step-start") {
|
||||
if (i === 0) return null;
|
||||
return <hr key={i} className="my-0.5 border-border/40" />;
|
||||
}
|
||||
|
||||
if (part.type === "text" && part.text.trim()) {
|
||||
return (
|
||||
<p key={i} className="text-[13px] leading-relaxed whitespace-pre-wrap">
|
||||
{part.text}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
if (part.type === "text" && part.text.trim()) {
|
||||
return (
|
||||
<p key={i} className="text-[13px] leading-relaxed whitespace-pre-wrap">
|
||||
{part.text}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
if (part.type === "reasoning" && part.text.trim()) {
|
||||
return (
|
||||
<p key={i} className="text-[11px] leading-relaxed whitespace-pre-wrap text-muted-foreground italic">
|
||||
{part.text}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
if (part.type === "reasoning" && part.text.trim()) {
|
||||
return (
|
||||
<p key={i} className="text-[11px] leading-relaxed whitespace-pre-wrap text-muted-foreground italic">
|
||||
{part.text}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
if (part.type === "tool-patch_resume") {
|
||||
const toolPart = part as any;
|
||||
const state = toolPart.state as string;
|
||||
const operations = toolPart.input?.operations as { op: string; path: string }[] | undefined;
|
||||
return <ToolBadge key={i} state={state} operations={operations} />;
|
||||
}
|
||||
if (part.type === "tool-patch_resume") {
|
||||
const toolPart = part as any;
|
||||
const state = toolPart.state as string;
|
||||
const operations = toolPart.input?.operations as { op: string; path: string }[] | undefined;
|
||||
return <ToolBadge key={i} state={state} operations={operations} />;
|
||||
}
|
||||
|
||||
return null;
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
return null;
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function AIChat() {
|
||||
const enabled = useAIStore((s) => s.enabled);
|
||||
const provider = useAIStore((s) => s.provider);
|
||||
const model = useAIStore((s) => s.model);
|
||||
const apiKey = useAIStore((s) => s.apiKey);
|
||||
const baseURL = useAIStore((s) => s.baseURL);
|
||||
const enabled = useAIStore((s) => s.enabled);
|
||||
const provider = useAIStore((s) => s.provider);
|
||||
const model = useAIStore((s) => s.model);
|
||||
const apiKey = useAIStore((s) => s.apiKey);
|
||||
const baseURL = useAIStore((s) => s.baseURL);
|
||||
|
||||
const resumeId = useResumeStore((s) => s.resume.id);
|
||||
const resumeData = useResumeStore((s) => s.resume.data);
|
||||
const updateResumeData = useResumeStore((s) => s.updateResumeData);
|
||||
const resumeId = useResumeStore((s) => s.resume.id);
|
||||
const resumeData = useResumeStore((s) => s.resume.data);
|
||||
const updateResumeData = useResumeStore((s) => s.updateResumeData);
|
||||
|
||||
const [input, setInput] = useState("");
|
||||
const [open, setOpen] = useState(false);
|
||||
const bottomRef = useRef<HTMLDivElement>(null);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const processedToolCallIds = useRef(new Set<string>());
|
||||
const [input, setInput] = useState("");
|
||||
const [open, setOpen] = useState(false);
|
||||
const bottomRef = useRef<HTMLDivElement>(null);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const processedToolCallIds = useRef(new Set<string>());
|
||||
|
||||
// Load stored messages once on mount and pre-populate processedToolCallIds
|
||||
// so that already-applied patches are not re-applied.
|
||||
const [initialMessages] = useState(() => {
|
||||
const stored = loadStoredMessages(resumeId);
|
||||
// Load stored messages once on mount and pre-populate processedToolCallIds
|
||||
// so that already-applied patches are not re-applied.
|
||||
const [initialMessages] = useState(() => {
|
||||
const stored = loadStoredMessages(resumeId);
|
||||
|
||||
for (const msg of stored) {
|
||||
if (msg.role !== "assistant") continue;
|
||||
for (const part of msg.parts) {
|
||||
if (part.type !== "tool-patch_resume") continue;
|
||||
const toolPart = part as any;
|
||||
if (toolPart.state === "output-available") {
|
||||
processedToolCallIds.current.add(toolPart.toolCallId as string);
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const msg of stored) {
|
||||
if (msg.role !== "assistant") continue;
|
||||
for (const part of msg.parts) {
|
||||
if (part.type !== "tool-patch_resume") continue;
|
||||
const toolPart = part as any;
|
||||
if (toolPart.state === "output-available") {
|
||||
processedToolCallIds.current.add(toolPart.toolCallId as string);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return stored;
|
||||
});
|
||||
return stored;
|
||||
});
|
||||
|
||||
const { messages, sendMessage, status, stop, setMessages } = useChat({
|
||||
messages: initialMessages,
|
||||
transport: {
|
||||
async sendMessages(options) {
|
||||
return eventIteratorToUnproxiedDataStream(
|
||||
await client.ai.chat(
|
||||
{
|
||||
provider,
|
||||
model,
|
||||
apiKey,
|
||||
baseURL,
|
||||
messages: options.messages,
|
||||
resumeData,
|
||||
},
|
||||
{ signal: options.abortSignal },
|
||||
),
|
||||
);
|
||||
},
|
||||
reconnectToStream() {
|
||||
throw new Error("Unsupported");
|
||||
},
|
||||
},
|
||||
onError(error) {
|
||||
toast.error("AI chat error", { description: error.message });
|
||||
},
|
||||
});
|
||||
const { messages, sendMessage, status, stop, setMessages } = useChat({
|
||||
messages: initialMessages,
|
||||
transport: {
|
||||
async sendMessages(options) {
|
||||
return eventIteratorToUnproxiedDataStream(
|
||||
await client.ai.chat(
|
||||
{
|
||||
provider,
|
||||
model,
|
||||
apiKey,
|
||||
baseURL,
|
||||
messages: options.messages,
|
||||
resumeData,
|
||||
},
|
||||
{ signal: options.abortSignal },
|
||||
),
|
||||
);
|
||||
},
|
||||
reconnectToStream() {
|
||||
throw new Error("Unsupported");
|
||||
},
|
||||
},
|
||||
onError(error) {
|
||||
toast.error("AI chat error", { description: error.message });
|
||||
},
|
||||
});
|
||||
|
||||
// Apply patches when new tool results arrive
|
||||
useEffect(() => {
|
||||
const { operations, newIds } = extractNewPatchOperations(messages, processedToolCallIds.current);
|
||||
if (operations.length === 0) return;
|
||||
// Apply patches when new tool results arrive
|
||||
useEffect(() => {
|
||||
const { operations, newIds } = extractNewPatchOperations(messages, processedToolCallIds.current);
|
||||
if (operations.length === 0) return;
|
||||
|
||||
try {
|
||||
const patched = applyResumePatches(resumeData, operations);
|
||||
updateResumeData((draft) => {
|
||||
Object.assign(draft, patched);
|
||||
});
|
||||
for (const id of newIds) processedToolCallIds.current.add(id);
|
||||
} catch (error) {
|
||||
toast.error("Failed to apply resume changes", {
|
||||
description: error instanceof Error ? error.message : "Unknown error",
|
||||
});
|
||||
}
|
||||
}, [messages, resumeData, updateResumeData]);
|
||||
try {
|
||||
const patched = applyResumePatches(resumeData, operations);
|
||||
updateResumeData((draft) => {
|
||||
Object.assign(draft, patched);
|
||||
});
|
||||
for (const id of newIds) processedToolCallIds.current.add(id);
|
||||
} catch (error) {
|
||||
toast.error("Failed to apply resume changes", {
|
||||
description: error instanceof Error ? error.message : "Unknown error",
|
||||
});
|
||||
}
|
||||
}, [messages, resumeData, updateResumeData]);
|
||||
|
||||
// Persist messages to localStorage whenever they change
|
||||
useEffect(() => {
|
||||
saveStoredMessages(resumeId, messages);
|
||||
}, [resumeId, messages]);
|
||||
// Persist messages to localStorage whenever they change
|
||||
useEffect(() => {
|
||||
saveStoredMessages(resumeId, messages);
|
||||
}, [resumeId, messages]);
|
||||
|
||||
// Auto-scroll to bottom (sentinel so it works with ScrollArea viewport)
|
||||
useEffect(() => {
|
||||
bottomRef.current?.scrollIntoView({ behavior: "smooth" });
|
||||
}, [messages]);
|
||||
// Auto-scroll to bottom (sentinel so it works with ScrollArea viewport)
|
||||
useEffect(() => {
|
||||
bottomRef.current?.scrollIntoView({ behavior: "smooth" });
|
||||
}, [messages]);
|
||||
|
||||
// Refocus the input when the AI finishes responding
|
||||
useEffect(() => {
|
||||
if (status === "ready") inputRef.current?.focus();
|
||||
}, [status]);
|
||||
// Refocus the input when the AI finishes responding
|
||||
useEffect(() => {
|
||||
if (status === "ready") inputRef.current?.focus();
|
||||
}, [status]);
|
||||
|
||||
const handleOpenChange = useCallback((nextOpen: boolean) => {
|
||||
setOpen(nextOpen);
|
||||
}, []);
|
||||
const handleOpenChange = useCallback((nextOpen: boolean) => {
|
||||
setOpen(nextOpen);
|
||||
}, []);
|
||||
|
||||
const handleClearMessages = useCallback(() => {
|
||||
setMessages([]);
|
||||
clearStoredMessages(resumeId);
|
||||
processedToolCallIds.current.clear();
|
||||
}, [resumeId, setMessages]);
|
||||
const handleClearMessages = useCallback(() => {
|
||||
setMessages([]);
|
||||
clearStoredMessages(resumeId);
|
||||
processedToolCallIds.current.clear();
|
||||
}, [resumeId, setMessages]);
|
||||
|
||||
const handleSubmit = useCallback(
|
||||
async (e: React.SubmitEvent) => {
|
||||
e.preventDefault();
|
||||
if (!input.trim() || status !== "ready") return;
|
||||
await sendMessage({ text: input });
|
||||
setInput("");
|
||||
},
|
||||
[input, status, sendMessage],
|
||||
);
|
||||
const handleSubmit = useCallback(
|
||||
async (e: React.SubmitEvent) => {
|
||||
e.preventDefault();
|
||||
if (!input.trim() || status !== "ready") return;
|
||||
await sendMessage({ text: input });
|
||||
setInput("");
|
||||
},
|
||||
[input, status, sendMessage],
|
||||
);
|
||||
|
||||
if (!enabled) return null;
|
||||
if (!enabled) return null;
|
||||
|
||||
const isLoading = status === "submitted" || status === "streaming";
|
||||
const isLoading = status === "submitted" || status === "streaming";
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={handleOpenChange}>
|
||||
<PopoverTrigger
|
||||
render={
|
||||
<Button size="icon" variant="ghost">
|
||||
<SparkleIcon />
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
return (
|
||||
<Popover open={open} onOpenChange={handleOpenChange}>
|
||||
<PopoverTrigger
|
||||
render={
|
||||
<Button size="icon" variant="ghost">
|
||||
<SparkleIcon />
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
<PopoverContent className="flex h-128 w-md flex-col gap-y-0 overflow-hidden p-0" side="top" align="center">
|
||||
{/* Header with clear button */}
|
||||
<div className="flex shrink-0 items-center justify-between border-b px-3 py-1.5">
|
||||
<p className="text-xs font-medium text-muted-foreground">
|
||||
<Trans>AI Chat</Trans>
|
||||
</p>
|
||||
<Button
|
||||
size="icon-sm"
|
||||
variant="ghost"
|
||||
className="size-7"
|
||||
title={t`Clear chat history`}
|
||||
onClick={handleClearMessages}
|
||||
disabled={messages.length === 0 || isLoading}
|
||||
>
|
||||
<TrashSimpleIcon className="size-3" />
|
||||
</Button>
|
||||
</div>
|
||||
<PopoverContent className="flex h-128 w-md flex-col gap-y-0 overflow-hidden p-0" side="top" align="center">
|
||||
{/* Header with clear button */}
|
||||
<div className="flex shrink-0 items-center justify-between border-b px-3 py-1.5">
|
||||
<p className="text-xs font-medium text-muted-foreground">
|
||||
<Trans>AI Chat</Trans>
|
||||
</p>
|
||||
<Button
|
||||
size="icon-sm"
|
||||
variant="ghost"
|
||||
className="size-7"
|
||||
title={t`Clear chat history`}
|
||||
onClick={handleClearMessages}
|
||||
disabled={messages.length === 0 || isLoading}
|
||||
>
|
||||
<TrashSimpleIcon className="size-3" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Message list — min-h-0 so flex constrains height and viewport scrolls */}
|
||||
<ScrollArea className="min-h-0 flex-1 px-4">
|
||||
<div className="flex flex-col gap-y-4 pt-4">
|
||||
{messages.length === 0 && (
|
||||
<div className="flex h-full items-center justify-center py-6">
|
||||
<p className="text-center text-xs text-muted-foreground">
|
||||
<Trans>Ask me to update your resume...</Trans>
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{/* Message list — min-h-0 so flex constrains height and viewport scrolls */}
|
||||
<ScrollArea className="min-h-0 flex-1 px-4">
|
||||
<div className="flex flex-col gap-y-4 pt-4">
|
||||
{messages.length === 0 && (
|
||||
<div className="flex h-full items-center justify-center py-6">
|
||||
<p className="text-center text-xs text-muted-foreground">
|
||||
<Trans>Ask me to update your resume...</Trans>
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-col gap-4">
|
||||
{messages.map((message) => (
|
||||
<div key={message.id} className={cn("flex", message.role === "user" ? "justify-end" : "justify-start")}>
|
||||
<div
|
||||
data-role={message.role}
|
||||
className={cn(
|
||||
"max-w-[85%] rounded-xl px-3.5 py-2.5",
|
||||
"data-[role=user]:rounded-br-sm data-[role=user]:bg-primary data-[role=user]:text-primary-foreground",
|
||||
"data-[role=assistant]:rounded-bl-sm data-[role=assistant]:bg-muted data-[role=assistant]:text-foreground",
|
||||
)}
|
||||
>
|
||||
{message.role === "user" ? (
|
||||
<p className="text-[13px] leading-relaxed">
|
||||
{message.parts.map((part, i) =>
|
||||
part.type === "text" ? <span key={i}>{part.text}</span> : null,
|
||||
)}
|
||||
</p>
|
||||
) : (
|
||||
<MessageParts message={message} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
<div className="flex flex-col gap-4">
|
||||
{messages.map((message) => (
|
||||
<div key={message.id} className={cn("flex", message.role === "user" ? "justify-end" : "justify-start")}>
|
||||
<div
|
||||
data-role={message.role}
|
||||
className={cn(
|
||||
"max-w-[85%] rounded-xl px-3.5 py-2.5",
|
||||
"data-[role=user]:rounded-br-sm data-[role=user]:bg-primary data-[role=user]:text-primary-foreground",
|
||||
"data-[role=assistant]:rounded-bl-sm data-[role=assistant]:bg-muted data-[role=assistant]:text-foreground",
|
||||
)}
|
||||
>
|
||||
{message.role === "user" ? (
|
||||
<p className="text-[13px] leading-relaxed">
|
||||
{message.parts.map((part, i) =>
|
||||
part.type === "text" ? <span key={i}>{part.text}</span> : null,
|
||||
)}
|
||||
</p>
|
||||
) : (
|
||||
<MessageParts message={message} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{status === "submitted" && (
|
||||
<div className="flex justify-start">
|
||||
<div className="rounded-xl rounded-bl-sm bg-muted px-3.5 py-2.5">
|
||||
<div className="flex items-center gap-2 text-[13px] text-muted-foreground">
|
||||
<CircleNotchIcon className="size-3 animate-spin" />
|
||||
<span>
|
||||
<Trans>Thinking...</Trans>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{status === "submitted" && (
|
||||
<div className="flex justify-start">
|
||||
<div className="rounded-xl rounded-bl-sm bg-muted px-3.5 py-2.5">
|
||||
<div className="flex items-center gap-2 text-[13px] text-muted-foreground">
|
||||
<CircleNotchIcon className="size-3 animate-spin" />
|
||||
<span>
|
||||
<Trans>Thinking...</Trans>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div ref={bottomRef} aria-hidden />
|
||||
</div>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
<div ref={bottomRef} aria-hidden />
|
||||
</div>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
|
||||
{/* Input form — shrink-0 so it stays visible when content scrolls */}
|
||||
<form onSubmit={handleSubmit} className="flex shrink-0 items-center gap-1.5 border-t px-3 py-2">
|
||||
<input
|
||||
ref={inputRef}
|
||||
value={input}
|
||||
disabled={!enabled || isLoading}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
placeholder={t`e.g. Change my name to...`}
|
||||
className="flex-1 bg-transparent text-[13px] outline-none placeholder:text-muted-foreground"
|
||||
/>
|
||||
{isLoading ? (
|
||||
<Button type="button" size="icon" variant="ghost" onClick={stop} className="size-7 shrink-0">
|
||||
<StopIcon className="size-3.5" />
|
||||
</Button>
|
||||
) : (
|
||||
<Button type="submit" size="icon" variant="ghost" disabled={!input.trim()} className="size-7 shrink-0">
|
||||
<PaperPlaneRightIcon className="size-3.5" />
|
||||
</Button>
|
||||
)}
|
||||
</form>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
{/* Input form — shrink-0 so it stays visible when content scrolls */}
|
||||
<form onSubmit={handleSubmit} className="flex shrink-0 items-center gap-1.5 border-t px-3 py-2">
|
||||
<input
|
||||
ref={inputRef}
|
||||
value={input}
|
||||
disabled={!enabled || isLoading}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
placeholder={t`e.g. Change my name to...`}
|
||||
className="flex-1 bg-transparent text-[13px] outline-none placeholder:text-muted-foreground"
|
||||
/>
|
||||
{isLoading ? (
|
||||
<Button type="button" size="icon" variant="ghost" onClick={stop} className="size-7 shrink-0">
|
||||
<StopIcon className="size-3.5" />
|
||||
</Button>
|
||||
) : (
|
||||
<Button type="submit" size="icon" variant="ghost" disabled={!input.trim()} className="size-7 shrink-0">
|
||||
<PaperPlaneRightIcon className="size-3.5" />
|
||||
</Button>
|
||||
)}
|
||||
</form>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -6,83 +6,83 @@ import { useRef } from "react";
|
||||
import { cn } from "@/utils/style";
|
||||
|
||||
type Props = {
|
||||
rotateDepth?: number;
|
||||
translateDepth?: number;
|
||||
glareOpacity?: number;
|
||||
scaleFactor?: number;
|
||||
className?: string;
|
||||
children: React.ReactNode;
|
||||
rotateDepth?: number;
|
||||
translateDepth?: number;
|
||||
glareOpacity?: number;
|
||||
scaleFactor?: number;
|
||||
className?: string;
|
||||
children: React.ReactNode;
|
||||
};
|
||||
|
||||
export const CometCard = ({
|
||||
rotateDepth = 17.5,
|
||||
translateDepth = 20,
|
||||
glareOpacity = 0.4,
|
||||
scaleFactor = 1.05,
|
||||
className,
|
||||
children,
|
||||
rotateDepth = 17.5,
|
||||
translateDepth = 20,
|
||||
glareOpacity = 0.4,
|
||||
scaleFactor = 1.05,
|
||||
className,
|
||||
children,
|
||||
}: Props) => {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
|
||||
const x = useMotionValue(0);
|
||||
const y = useMotionValue(0);
|
||||
const x = useMotionValue(0);
|
||||
const y = useMotionValue(0);
|
||||
|
||||
const mouseXSpring = useSpring(x);
|
||||
const mouseYSpring = useSpring(y);
|
||||
const mouseXSpring = useSpring(x);
|
||||
const mouseYSpring = useSpring(y);
|
||||
|
||||
const rotateX = useTransform(mouseYSpring, [-0.5, 0.5], [`-${rotateDepth}deg`, `${rotateDepth}deg`]);
|
||||
const rotateY = useTransform(mouseXSpring, [-0.5, 0.5], [`${rotateDepth}deg`, `-${rotateDepth}deg`]);
|
||||
const rotateX = useTransform(mouseYSpring, [-0.5, 0.5], [`-${rotateDepth}deg`, `${rotateDepth}deg`]);
|
||||
const rotateY = useTransform(mouseXSpring, [-0.5, 0.5], [`${rotateDepth}deg`, `-${rotateDepth}deg`]);
|
||||
|
||||
const translateX = useTransform(mouseXSpring, [-0.5, 0.5], [`-${translateDepth}px`, `${translateDepth}px`]);
|
||||
const translateY = useTransform(mouseYSpring, [-0.5, 0.5], [`${translateDepth}px`, `-${translateDepth}px`]);
|
||||
const translateX = useTransform(mouseXSpring, [-0.5, 0.5], [`-${translateDepth}px`, `${translateDepth}px`]);
|
||||
const translateY = useTransform(mouseYSpring, [-0.5, 0.5], [`${translateDepth}px`, `-${translateDepth}px`]);
|
||||
|
||||
const glareX = useTransform(mouseXSpring, [-0.5, 0.5], [0, 100]);
|
||||
const glareY = useTransform(mouseYSpring, [-0.5, 0.5], [0, 100]);
|
||||
const glareX = useTransform(mouseXSpring, [-0.5, 0.5], [0, 100]);
|
||||
const glareY = useTransform(mouseYSpring, [-0.5, 0.5], [0, 100]);
|
||||
|
||||
const glareBackground = useMotionTemplate`radial-gradient(circle at ${glareX}% ${glareY}%, rgba(255, 255, 255, 0.9) 10%, rgba(255, 255, 255, 0.75) 20%, rgba(255, 255, 255, 0) 80%)`;
|
||||
const glareBackground = useMotionTemplate`radial-gradient(circle at ${glareX}% ${glareY}%, rgba(255, 255, 255, 0.9) 10%, rgba(255, 255, 255, 0.75) 20%, rgba(255, 255, 255, 0) 80%)`;
|
||||
|
||||
const handleMouseMove = (e: React.MouseEvent<HTMLDivElement>) => {
|
||||
if (!ref.current) return;
|
||||
const handleMouseMove = (e: React.MouseEvent<HTMLDivElement>) => {
|
||||
if (!ref.current) return;
|
||||
|
||||
const rect = ref.current.getBoundingClientRect();
|
||||
const rect = ref.current.getBoundingClientRect();
|
||||
|
||||
const width = rect.width;
|
||||
const height = rect.height;
|
||||
const width = rect.width;
|
||||
const height = rect.height;
|
||||
|
||||
const mouseX = e.clientX - rect.left;
|
||||
const mouseY = e.clientY - rect.top;
|
||||
const mouseX = e.clientX - rect.left;
|
||||
const mouseY = e.clientY - rect.top;
|
||||
|
||||
const xPct = mouseX / width - 0.5;
|
||||
const yPct = mouseY / height - 0.5;
|
||||
const xPct = mouseX / width - 0.5;
|
||||
const yPct = mouseY / height - 0.5;
|
||||
|
||||
x.set(xPct);
|
||||
y.set(yPct);
|
||||
};
|
||||
x.set(xPct);
|
||||
y.set(yPct);
|
||||
};
|
||||
|
||||
const handleMouseLeave = () => {
|
||||
x.set(0);
|
||||
y.set(0);
|
||||
};
|
||||
const handleMouseLeave = () => {
|
||||
x.set(0);
|
||||
y.set(0);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={cn("perspective-distant transform-3d", className)}>
|
||||
<motion.div
|
||||
ref={ref}
|
||||
initial={{ scale: 1, z: 0 }}
|
||||
className="relative rounded-md"
|
||||
style={{ rotateX, rotateY, translateX, translateY }}
|
||||
whileHover={{ z: 50, scale: scaleFactor, transition: { duration: 0.2 } }}
|
||||
onMouseMove={handleMouseMove}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
>
|
||||
{children}
|
||||
return (
|
||||
<div className={cn("perspective-distant transform-3d", className)}>
|
||||
<motion.div
|
||||
ref={ref}
|
||||
initial={{ scale: 1, z: 0 }}
|
||||
className="relative rounded-md"
|
||||
style={{ rotateX, rotateY, translateX, translateY }}
|
||||
whileHover={{ z: 50, scale: scaleFactor, transition: { duration: 0.2 } }}
|
||||
onMouseMove={handleMouseMove}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
>
|
||||
{children}
|
||||
|
||||
<motion.div
|
||||
transition={{ duration: 0.2 }}
|
||||
style={{ background: glareBackground, opacity: glareOpacity }}
|
||||
className="pointer-events-none absolute inset-0 z-50 h-full w-full rounded-md mix-blend-overlay"
|
||||
/>
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
<motion.div
|
||||
transition={{ duration: 0.2 }}
|
||||
style={{ background: glareBackground, opacity: glareOpacity }}
|
||||
className="pointer-events-none absolute inset-0 z-50 h-full w-full rounded-md mix-blend-overlay"
|
||||
/>
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -2,128 +2,128 @@ import { useInView, useMotionValue, useSpring } from "motion/react";
|
||||
import { useCallback, useEffect, useRef } from "react";
|
||||
|
||||
type CountUpProps = {
|
||||
to: number;
|
||||
from?: number;
|
||||
direction?: "up" | "down";
|
||||
delay?: number;
|
||||
duration?: number;
|
||||
className?: string;
|
||||
startWhen?: boolean;
|
||||
separator?: string;
|
||||
onStart?: () => void;
|
||||
onEnd?: () => void;
|
||||
"aria-hidden"?: boolean | "true" | "false";
|
||||
"aria-live"?: "off" | "polite" | "assertive";
|
||||
"aria-atomic"?: boolean | "true" | "false";
|
||||
to: number;
|
||||
from?: number;
|
||||
direction?: "up" | "down";
|
||||
delay?: number;
|
||||
duration?: number;
|
||||
className?: string;
|
||||
startWhen?: boolean;
|
||||
separator?: string;
|
||||
onStart?: () => void;
|
||||
onEnd?: () => void;
|
||||
"aria-hidden"?: boolean | "true" | "false";
|
||||
"aria-live"?: "off" | "polite" | "assertive";
|
||||
"aria-atomic"?: boolean | "true" | "false";
|
||||
};
|
||||
|
||||
export function CountUp({
|
||||
to,
|
||||
from = 0,
|
||||
direction = "up",
|
||||
delay = 0,
|
||||
duration = 2,
|
||||
className = "",
|
||||
startWhen = true,
|
||||
separator = "",
|
||||
onStart,
|
||||
onEnd,
|
||||
"aria-hidden": ariaHidden,
|
||||
"aria-live": ariaLive = "polite",
|
||||
"aria-atomic": ariaAtomic = "true",
|
||||
to,
|
||||
from = 0,
|
||||
direction = "up",
|
||||
delay = 0,
|
||||
duration = 2,
|
||||
className = "",
|
||||
startWhen = true,
|
||||
separator = "",
|
||||
onStart,
|
||||
onEnd,
|
||||
"aria-hidden": ariaHidden,
|
||||
"aria-live": ariaLive = "polite",
|
||||
"aria-atomic": ariaAtomic = "true",
|
||||
}: CountUpProps) {
|
||||
const ref = useRef<HTMLSpanElement>(null);
|
||||
const motionValue = useMotionValue(direction === "down" ? to : from);
|
||||
const ref = useRef<HTMLSpanElement>(null);
|
||||
const motionValue = useMotionValue(direction === "down" ? to : from);
|
||||
|
||||
const damping = 20 + 40 * (1 / duration);
|
||||
const stiffness = 100 * (1 / duration);
|
||||
const damping = 20 + 40 * (1 / duration);
|
||||
const stiffness = 100 * (1 / duration);
|
||||
|
||||
const springValue = useSpring(motionValue, {
|
||||
damping,
|
||||
stiffness,
|
||||
});
|
||||
const springValue = useSpring(motionValue, {
|
||||
damping,
|
||||
stiffness,
|
||||
});
|
||||
|
||||
const isInView = useInView(ref, { once: true, margin: "0px" });
|
||||
const isInView = useInView(ref, { once: true, margin: "0px" });
|
||||
|
||||
const getDecimalPlaces = (num: number): number => {
|
||||
const str = num.toString();
|
||||
const getDecimalPlaces = (num: number): number => {
|
||||
const str = num.toString();
|
||||
|
||||
if (str.includes(".")) {
|
||||
const decimals = str.split(".")[1];
|
||||
if (Number.parseInt(decimals, 10) !== 0) return decimals.length;
|
||||
}
|
||||
if (str.includes(".")) {
|
||||
const decimals = str.split(".")[1];
|
||||
if (Number.parseInt(decimals, 10) !== 0) return decimals.length;
|
||||
}
|
||||
|
||||
return 0;
|
||||
};
|
||||
return 0;
|
||||
};
|
||||
|
||||
const maxDecimals = Math.max(getDecimalPlaces(from), getDecimalPlaces(to));
|
||||
const maxDecimals = Math.max(getDecimalPlaces(from), getDecimalPlaces(to));
|
||||
|
||||
const formatValue = useCallback(
|
||||
(latest: number) => {
|
||||
const hasDecimals = maxDecimals > 0;
|
||||
const formatValue = useCallback(
|
||||
(latest: number) => {
|
||||
const hasDecimals = maxDecimals > 0;
|
||||
|
||||
const options: Intl.NumberFormatOptions = {
|
||||
useGrouping: !!separator,
|
||||
minimumFractionDigits: hasDecimals ? maxDecimals : 0,
|
||||
maximumFractionDigits: hasDecimals ? maxDecimals : 0,
|
||||
};
|
||||
const options: Intl.NumberFormatOptions = {
|
||||
useGrouping: !!separator,
|
||||
minimumFractionDigits: hasDecimals ? maxDecimals : 0,
|
||||
maximumFractionDigits: hasDecimals ? maxDecimals : 0,
|
||||
};
|
||||
|
||||
const formattedNumber = Intl.NumberFormat("en-US", options).format(latest);
|
||||
const formattedNumber = Intl.NumberFormat("en-US", options).format(latest);
|
||||
|
||||
return separator ? formattedNumber.replace(/,/g, separator) : formattedNumber;
|
||||
},
|
||||
[maxDecimals, separator],
|
||||
);
|
||||
return separator ? formattedNumber.replace(/,/g, separator) : formattedNumber;
|
||||
},
|
||||
[maxDecimals, separator],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (ref.current) {
|
||||
ref.current.textContent = formatValue(direction === "down" ? to : from);
|
||||
}
|
||||
}, [from, to, direction, formatValue]);
|
||||
useEffect(() => {
|
||||
if (ref.current) {
|
||||
ref.current.textContent = formatValue(direction === "down" ? to : from);
|
||||
}
|
||||
}, [from, to, direction, formatValue]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isInView && startWhen) {
|
||||
if (typeof onStart === "function") {
|
||||
onStart();
|
||||
}
|
||||
useEffect(() => {
|
||||
if (isInView && startWhen) {
|
||||
if (typeof onStart === "function") {
|
||||
onStart();
|
||||
}
|
||||
|
||||
const timeoutId = setTimeout(() => {
|
||||
motionValue.set(direction === "down" ? from : to);
|
||||
}, delay * 1000);
|
||||
const timeoutId = setTimeout(() => {
|
||||
motionValue.set(direction === "down" ? from : to);
|
||||
}, delay * 1000);
|
||||
|
||||
const durationTimeoutId = setTimeout(
|
||||
() => {
|
||||
if (typeof onEnd === "function") {
|
||||
onEnd();
|
||||
}
|
||||
},
|
||||
delay * 1000 + duration * 1000,
|
||||
);
|
||||
const durationTimeoutId = setTimeout(
|
||||
() => {
|
||||
if (typeof onEnd === "function") {
|
||||
onEnd();
|
||||
}
|
||||
},
|
||||
delay * 1000 + duration * 1000,
|
||||
);
|
||||
|
||||
return () => {
|
||||
clearTimeout(timeoutId);
|
||||
clearTimeout(durationTimeoutId);
|
||||
};
|
||||
}
|
||||
}, [isInView, startWhen, motionValue, direction, from, to, delay, onStart, onEnd, duration]);
|
||||
return () => {
|
||||
clearTimeout(timeoutId);
|
||||
clearTimeout(durationTimeoutId);
|
||||
};
|
||||
}
|
||||
}, [isInView, startWhen, motionValue, direction, from, to, delay, onStart, onEnd, duration]);
|
||||
|
||||
useEffect(() => {
|
||||
const unsubscribe = springValue.on("change", (latest: number) => {
|
||||
if (ref.current) {
|
||||
ref.current.textContent = formatValue(latest);
|
||||
}
|
||||
});
|
||||
useEffect(() => {
|
||||
const unsubscribe = springValue.on("change", (latest: number) => {
|
||||
if (ref.current) {
|
||||
ref.current.textContent = formatValue(latest);
|
||||
}
|
||||
});
|
||||
|
||||
return () => unsubscribe();
|
||||
}, [springValue, formatValue]);
|
||||
return () => unsubscribe();
|
||||
}, [springValue, formatValue]);
|
||||
|
||||
return (
|
||||
<span
|
||||
ref={ref}
|
||||
className={className}
|
||||
aria-hidden={ariaHidden}
|
||||
aria-live={ariaHidden ? undefined : ariaLive}
|
||||
aria-atomic={ariaHidden ? undefined : ariaAtomic}
|
||||
/>
|
||||
);
|
||||
return (
|
||||
<span
|
||||
ref={ref}
|
||||
className={className}
|
||||
aria-hidden={ariaHidden}
|
||||
aria-live={ariaHidden ? undefined : ariaLive}
|
||||
aria-atomic={ariaHidden ? undefined : ariaAtomic}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,111 +1,111 @@
|
||||
import { motion } from "motion/react";
|
||||
|
||||
type SpotlightProps = {
|
||||
duration?: number;
|
||||
gradientFirst?: string;
|
||||
gradientSecond?: string;
|
||||
gradientThird?: string;
|
||||
width?: number;
|
||||
height?: number;
|
||||
smallWidth?: number;
|
||||
translateY?: number;
|
||||
xOffset?: number;
|
||||
duration?: number;
|
||||
gradientFirst?: string;
|
||||
gradientSecond?: string;
|
||||
gradientThird?: string;
|
||||
width?: number;
|
||||
height?: number;
|
||||
smallWidth?: number;
|
||||
translateY?: number;
|
||||
xOffset?: number;
|
||||
};
|
||||
|
||||
export const Spotlight = ({
|
||||
duration = 7,
|
||||
gradientFirst = "radial-gradient(68.54% 68.72% at 55.02% 31.46%, hsla(210, 100%, 85%, .08) 0, hsla(210, 100%, 55%, .02) 50%, hsla(210, 100%, 45%, 0) 80%)",
|
||||
gradientSecond = "radial-gradient(50% 50% at 50% 50%, hsla(210, 100%, 85%, .06) 0, hsla(210, 100%, 55%, .02) 80%, transparent 100%)",
|
||||
gradientThird = "radial-gradient(50% 50% at 50% 50%, hsla(210, 100%, 85%, .04) 0, hsla(210, 100%, 45%, .02) 80%, transparent 100%)",
|
||||
width = 560,
|
||||
height = 1380,
|
||||
smallWidth = 240,
|
||||
translateY = -350,
|
||||
xOffset = 100,
|
||||
duration = 7,
|
||||
gradientFirst = "radial-gradient(68.54% 68.72% at 55.02% 31.46%, hsla(210, 100%, 85%, .08) 0, hsla(210, 100%, 55%, .02) 50%, hsla(210, 100%, 45%, 0) 80%)",
|
||||
gradientSecond = "radial-gradient(50% 50% at 50% 50%, hsla(210, 100%, 85%, .06) 0, hsla(210, 100%, 55%, .02) 80%, transparent 100%)",
|
||||
gradientThird = "radial-gradient(50% 50% at 50% 50%, hsla(210, 100%, 85%, .04) 0, hsla(210, 100%, 45%, .02) 80%, transparent 100%)",
|
||||
width = 560,
|
||||
height = 1380,
|
||||
smallWidth = 240,
|
||||
translateY = -350,
|
||||
xOffset = 100,
|
||||
}: SpotlightProps) => {
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ duration: 1.5 }}
|
||||
className="pointer-events-none absolute inset-0 h-full w-full"
|
||||
>
|
||||
<motion.div
|
||||
animate={{ x: [0, xOffset, 0] }}
|
||||
transition={{ duration, repeat: Infinity, repeatType: "reverse", ease: "easeInOut" }}
|
||||
className="pointer-events-none absolute inset-s-0 top-0 z-40 h-svh w-svw"
|
||||
>
|
||||
<div
|
||||
className="absolute inset-s-0 top-0"
|
||||
style={{
|
||||
width: `${width}px`,
|
||||
height: `${height}px`,
|
||||
background: gradientFirst,
|
||||
transform: `translateY(${translateY}px) rotate(-45deg)`,
|
||||
}}
|
||||
/>
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ duration: 1.5 }}
|
||||
className="pointer-events-none absolute inset-0 h-full w-full"
|
||||
>
|
||||
<motion.div
|
||||
animate={{ x: [0, xOffset, 0] }}
|
||||
transition={{ duration, repeat: Infinity, repeatType: "reverse", ease: "easeInOut" }}
|
||||
className="pointer-events-none absolute inset-s-0 top-0 z-40 h-svh w-svw"
|
||||
>
|
||||
<div
|
||||
className="absolute inset-s-0 top-0"
|
||||
style={{
|
||||
width: `${width}px`,
|
||||
height: `${height}px`,
|
||||
background: gradientFirst,
|
||||
transform: `translateY(${translateY}px) rotate(-45deg)`,
|
||||
}}
|
||||
/>
|
||||
|
||||
<div
|
||||
className="absolute inset-s-0 top-0 origin-top-left"
|
||||
style={{
|
||||
height: `${height}px`,
|
||||
width: `${smallWidth}px`,
|
||||
background: gradientSecond,
|
||||
transform: "rotate(-45deg) translate(5%, -50%)",
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
className="absolute inset-s-0 top-0 origin-top-left"
|
||||
style={{
|
||||
height: `${height}px`,
|
||||
width: `${smallWidth}px`,
|
||||
background: gradientSecond,
|
||||
transform: "rotate(-45deg) translate(5%, -50%)",
|
||||
}}
|
||||
/>
|
||||
|
||||
<div
|
||||
className="absolute inset-s-0 top-0 origin-top-left"
|
||||
style={{
|
||||
height: `${height}px`,
|
||||
width: `${smallWidth}px`,
|
||||
background: gradientThird,
|
||||
transform: "rotate(-45deg) translate(-180%, -70%)",
|
||||
}}
|
||||
/>
|
||||
</motion.div>
|
||||
<div
|
||||
className="absolute inset-s-0 top-0 origin-top-left"
|
||||
style={{
|
||||
height: `${height}px`,
|
||||
width: `${smallWidth}px`,
|
||||
background: gradientThird,
|
||||
transform: "rotate(-45deg) translate(-180%, -70%)",
|
||||
}}
|
||||
/>
|
||||
</motion.div>
|
||||
|
||||
<motion.div
|
||||
animate={{ x: [0, -xOffset, 0] }}
|
||||
className="pointer-events-none absolute inset-e-0 top-0 z-40 h-svh w-svw"
|
||||
transition={{
|
||||
duration,
|
||||
repeat: Infinity,
|
||||
ease: "easeInOut",
|
||||
repeatType: "reverse",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="absolute inset-e-0 top-0"
|
||||
style={{
|
||||
width: `${width}px`,
|
||||
height: `${height}px`,
|
||||
background: gradientFirst,
|
||||
transform: `translateY(${translateY}px) rotate(45deg)`,
|
||||
}}
|
||||
/>
|
||||
<motion.div
|
||||
animate={{ x: [0, -xOffset, 0] }}
|
||||
className="pointer-events-none absolute inset-e-0 top-0 z-40 h-svh w-svw"
|
||||
transition={{
|
||||
duration,
|
||||
repeat: Infinity,
|
||||
ease: "easeInOut",
|
||||
repeatType: "reverse",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="absolute inset-e-0 top-0"
|
||||
style={{
|
||||
width: `${width}px`,
|
||||
height: `${height}px`,
|
||||
background: gradientFirst,
|
||||
transform: `translateY(${translateY}px) rotate(45deg)`,
|
||||
}}
|
||||
/>
|
||||
|
||||
<div
|
||||
className="absolute inset-e-0 top-0 origin-top-right"
|
||||
style={{
|
||||
height: `${height}px`,
|
||||
width: `${smallWidth}px`,
|
||||
background: gradientSecond,
|
||||
transform: "rotate(45deg) translate(-5%, -50%)",
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
className="absolute inset-e-0 top-0 origin-top-right"
|
||||
style={{
|
||||
height: `${height}px`,
|
||||
width: `${smallWidth}px`,
|
||||
background: gradientSecond,
|
||||
transform: "rotate(45deg) translate(-5%, -50%)",
|
||||
}}
|
||||
/>
|
||||
|
||||
<div
|
||||
className="absolute inset-e-0 top-0 origin-top-right"
|
||||
style={{
|
||||
height: `${height}px`,
|
||||
width: `${smallWidth}px`,
|
||||
background: gradientThird,
|
||||
transform: "rotate(45deg) translate(180%, -70%)",
|
||||
}}
|
||||
/>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
);
|
||||
<div
|
||||
className="absolute inset-e-0 top-0 origin-top-right"
|
||||
style={{
|
||||
height: `${height}px`,
|
||||
width: `${smallWidth}px`,
|
||||
background: gradientThird,
|
||||
transform: "rotate(45deg) translate(180%, -70%)",
|
||||
}}
|
||||
/>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -6,109 +6,109 @@ import { cn } from "@/utils/style";
|
||||
const textClassName = cn("fill-transparent text-3xl leading-none font-bold tracking-tight");
|
||||
|
||||
type TextMaskEffectProps = {
|
||||
text: string;
|
||||
duration?: number;
|
||||
className?: string;
|
||||
"aria-hidden"?: boolean | "true" | "false";
|
||||
text: string;
|
||||
duration?: number;
|
||||
className?: string;
|
||||
"aria-hidden"?: boolean | "true" | "false";
|
||||
};
|
||||
|
||||
export const TextMaskEffect = ({ text, duration = 6, className, "aria-hidden": ariaHidden }: TextMaskEffectProps) => {
|
||||
const svgRef = useRef<SVGSVGElement>(null);
|
||||
const [cursor, setCursor] = useState({ x: 0, y: 0 });
|
||||
const [hovered, setHovered] = useState(false);
|
||||
const [maskPosition, setMaskPosition] = useState({ cx: "50%", cy: "50%" });
|
||||
const svgRef = useRef<SVGSVGElement>(null);
|
||||
const [cursor, setCursor] = useState({ x: 0, y: 0 });
|
||||
const [hovered, setHovered] = useState(false);
|
||||
const [maskPosition, setMaskPosition] = useState({ cx: "50%", cy: "50%" });
|
||||
|
||||
useEffect(() => {
|
||||
if (svgRef.current && cursor.x !== null && cursor.y !== null) {
|
||||
const svgRect = svgRef.current.getBoundingClientRect();
|
||||
const cxPercentage = ((cursor.x - svgRect.left) / svgRect.width) * 100;
|
||||
const cyPercentage = ((cursor.y - svgRect.top) / svgRect.height) * 100;
|
||||
useEffect(() => {
|
||||
if (svgRef.current && cursor.x !== null && cursor.y !== null) {
|
||||
const svgRect = svgRef.current.getBoundingClientRect();
|
||||
const cxPercentage = ((cursor.x - svgRect.left) / svgRect.width) * 100;
|
||||
const cyPercentage = ((cursor.y - svgRect.top) / svgRect.height) * 100;
|
||||
|
||||
setMaskPosition({ cx: `${cxPercentage}%`, cy: `${cyPercentage}%` });
|
||||
}
|
||||
}, [cursor]);
|
||||
setMaskPosition({ cx: `${cxPercentage}%`, cy: `${cyPercentage}%` });
|
||||
}
|
||||
}, [cursor]);
|
||||
|
||||
return (
|
||||
<svg
|
||||
ref={svgRef}
|
||||
width="100%"
|
||||
height="100%"
|
||||
viewBox="0 0 300 40"
|
||||
aria-hidden={ariaHidden}
|
||||
className={cn("select-none", className)}
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
onMouseEnter={() => setHovered(true)}
|
||||
onMouseLeave={() => setHovered(false)}
|
||||
onMouseMove={(e) => setCursor({ x: e.clientX, y: e.clientY })}
|
||||
>
|
||||
<defs>
|
||||
<linearGradient id="textGradient" gradientUnits="userSpaceOnUse" cx="50%" cy="50%" r="25%">
|
||||
{hovered && (
|
||||
<>
|
||||
<stop offset="0%" stopColor="#eab308" />
|
||||
<stop offset="25%" stopColor="#ef4444" />
|
||||
<stop offset="50%" stopColor="#3b82f6" />
|
||||
<stop offset="75%" stopColor="#06b6d4" />
|
||||
<stop offset="100%" stopColor="#8b5cf6" />
|
||||
</>
|
||||
)}
|
||||
</linearGradient>
|
||||
return (
|
||||
<svg
|
||||
ref={svgRef}
|
||||
width="100%"
|
||||
height="100%"
|
||||
viewBox="0 0 300 40"
|
||||
aria-hidden={ariaHidden}
|
||||
className={cn("select-none", className)}
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
onMouseEnter={() => setHovered(true)}
|
||||
onMouseLeave={() => setHovered(false)}
|
||||
onMouseMove={(e) => setCursor({ x: e.clientX, y: e.clientY })}
|
||||
>
|
||||
<defs>
|
||||
<linearGradient id="textGradient" gradientUnits="userSpaceOnUse" cx="50%" cy="50%" r="25%">
|
||||
{hovered && (
|
||||
<>
|
||||
<stop offset="0%" stopColor="#eab308" />
|
||||
<stop offset="25%" stopColor="#ef4444" />
|
||||
<stop offset="50%" stopColor="#3b82f6" />
|
||||
<stop offset="75%" stopColor="#06b6d4" />
|
||||
<stop offset="100%" stopColor="#8b5cf6" />
|
||||
</>
|
||||
)}
|
||||
</linearGradient>
|
||||
|
||||
<motion.radialGradient
|
||||
r="20%"
|
||||
id="revealMask"
|
||||
animate={maskPosition}
|
||||
gradientUnits="userSpaceOnUse"
|
||||
initial={{ cx: "50%", cy: "50%" }}
|
||||
transition={{ duration: 0, ease: "easeOut" }}
|
||||
>
|
||||
<stop offset="0%" stopColor="white" />
|
||||
<stop offset="100%" stopColor="black" />
|
||||
</motion.radialGradient>
|
||||
<motion.radialGradient
|
||||
r="20%"
|
||||
id="revealMask"
|
||||
animate={maskPosition}
|
||||
gradientUnits="userSpaceOnUse"
|
||||
initial={{ cx: "50%", cy: "50%" }}
|
||||
transition={{ duration: 0, ease: "easeOut" }}
|
||||
>
|
||||
<stop offset="0%" stopColor="white" />
|
||||
<stop offset="100%" stopColor="black" />
|
||||
</motion.radialGradient>
|
||||
|
||||
<mask id="textMask">
|
||||
<rect x="0" y="0" width="100%" height="100%" fill="url(#revealMask)" />
|
||||
</mask>
|
||||
</defs>
|
||||
<mask id="textMask">
|
||||
<rect x="0" y="0" width="100%" height="100%" fill="url(#revealMask)" />
|
||||
</mask>
|
||||
</defs>
|
||||
|
||||
<text
|
||||
x="50%"
|
||||
y="50%"
|
||||
strokeWidth="0.3"
|
||||
textAnchor="middle"
|
||||
dominantBaseline="middle"
|
||||
style={{ opacity: hovered ? 0.7 : 0 }}
|
||||
className={cn(textClassName, "stroke-zinc-300 dark:stroke-zinc-700")}
|
||||
>
|
||||
{text}
|
||||
</text>
|
||||
<text
|
||||
x="50%"
|
||||
y="50%"
|
||||
strokeWidth="0.3"
|
||||
textAnchor="middle"
|
||||
dominantBaseline="middle"
|
||||
style={{ opacity: hovered ? 0.7 : 0 }}
|
||||
className={cn(textClassName, "stroke-zinc-300 dark:stroke-zinc-700")}
|
||||
>
|
||||
{text}
|
||||
</text>
|
||||
|
||||
<motion.text
|
||||
x="50%"
|
||||
y="50%"
|
||||
strokeWidth="0.3"
|
||||
textAnchor="middle"
|
||||
dominantBaseline="middle"
|
||||
transition={{ duration, ease: "easeInOut" }}
|
||||
initial={{ strokeDashoffset: 1000, strokeDasharray: 1000 }}
|
||||
whileInView={{ strokeDashoffset: 0, strokeDasharray: 1000 }}
|
||||
className={cn(textClassName, "stroke-zinc-300 dark:stroke-zinc-700")}
|
||||
>
|
||||
{text}
|
||||
</motion.text>
|
||||
<motion.text
|
||||
x="50%"
|
||||
y="50%"
|
||||
strokeWidth="0.3"
|
||||
textAnchor="middle"
|
||||
dominantBaseline="middle"
|
||||
transition={{ duration, ease: "easeInOut" }}
|
||||
initial={{ strokeDashoffset: 1000, strokeDasharray: 1000 }}
|
||||
whileInView={{ strokeDashoffset: 0, strokeDasharray: 1000 }}
|
||||
className={cn(textClassName, "stroke-zinc-300 dark:stroke-zinc-700")}
|
||||
>
|
||||
{text}
|
||||
</motion.text>
|
||||
|
||||
<text
|
||||
x="50%"
|
||||
y="50%"
|
||||
strokeWidth="0.3"
|
||||
textAnchor="middle"
|
||||
mask="url(#textMask)"
|
||||
dominantBaseline="middle"
|
||||
stroke="url(#textGradient)"
|
||||
className={cn(textClassName)}
|
||||
>
|
||||
{text}
|
||||
</text>
|
||||
</svg>
|
||||
);
|
||||
<text
|
||||
x="50%"
|
||||
y="50%"
|
||||
strokeWidth="0.3"
|
||||
textAnchor="middle"
|
||||
mask="url(#textMask)"
|
||||
dominantBaseline="middle"
|
||||
stroke="url(#textGradient)"
|
||||
className={cn(textClassName)}
|
||||
>
|
||||
{text}
|
||||
</text>
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -11,111 +11,111 @@ import { ResumesCommandGroup } from "./pages/resumes";
|
||||
import { useCommandPaletteStore } from "./store";
|
||||
|
||||
export function CommandPalette() {
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const { open, search, pages, setOpen, setSearch, goBack } = useCommandPaletteStore();
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const { open, search, pages, setOpen, setSearch, goBack } = useCommandPaletteStore();
|
||||
|
||||
const isFirstPage = pages.length === 0;
|
||||
const currentPage = pages[pages.length - 1];
|
||||
const isFirstPage = pages.length === 0;
|
||||
const currentPage = pages[pages.length - 1];
|
||||
|
||||
// Toggle command palette with Cmd+K / Ctrl+K
|
||||
useHotkeys(
|
||||
["meta+k", "ctrl+k"],
|
||||
(e) => {
|
||||
e.preventDefault();
|
||||
setOpen(!open);
|
||||
},
|
||||
{ preventDefault: true, enableOnFormTags: true },
|
||||
[open],
|
||||
);
|
||||
// Toggle command palette with Cmd+K / Ctrl+K
|
||||
useHotkeys(
|
||||
["meta+k", "ctrl+k"],
|
||||
(e) => {
|
||||
e.preventDefault();
|
||||
setOpen(!open);
|
||||
},
|
||||
{ preventDefault: true, enableOnFormTags: true },
|
||||
[open],
|
||||
);
|
||||
|
||||
// Handle backspace: delete text if input has text, go back if empty, close if first page
|
||||
useHotkeys(
|
||||
"backspace",
|
||||
(e) => {
|
||||
// Only handle if the command palette is open
|
||||
if (!open) return;
|
||||
// Handle backspace: delete text if input has text, go back if empty, close if first page
|
||||
useHotkeys(
|
||||
"backspace",
|
||||
(e) => {
|
||||
// Only handle if the command palette is open
|
||||
if (!open) return;
|
||||
|
||||
const input = inputRef.current;
|
||||
if (!input) return;
|
||||
const input = inputRef.current;
|
||||
if (!input) return;
|
||||
|
||||
// Only handle if input is focused
|
||||
if (document.activeElement !== input) return;
|
||||
// Only handle if input is focused
|
||||
if (document.activeElement !== input) return;
|
||||
|
||||
// If input has text, let the default behavior handle it (delete character)
|
||||
if (search.length > 0) return;
|
||||
// If input has text, let the default behavior handle it (delete character)
|
||||
if (search.length > 0) return;
|
||||
|
||||
// If input is empty, prevent default and go back
|
||||
e.preventDefault();
|
||||
goBack();
|
||||
},
|
||||
{
|
||||
preventDefault: false, // We'll prevent it conditionally
|
||||
enableOnFormTags: true,
|
||||
},
|
||||
[open, search],
|
||||
);
|
||||
// If input is empty, prevent default and go back
|
||||
e.preventDefault();
|
||||
goBack();
|
||||
},
|
||||
{
|
||||
preventDefault: false, // We'll prevent it conditionally
|
||||
enableOnFormTags: true,
|
||||
},
|
||||
[open, search],
|
||||
);
|
||||
|
||||
// Close with Escape
|
||||
useHotkeys(
|
||||
"escape",
|
||||
() => {
|
||||
if (!open) return;
|
||||
setOpen(false);
|
||||
},
|
||||
{
|
||||
preventDefault: true,
|
||||
enableOnFormTags: true,
|
||||
},
|
||||
[open],
|
||||
);
|
||||
// Close with Escape
|
||||
useHotkeys(
|
||||
"escape",
|
||||
() => {
|
||||
if (!open) return;
|
||||
setOpen(false);
|
||||
},
|
||||
{
|
||||
preventDefault: true,
|
||||
enableOnFormTags: true,
|
||||
},
|
||||
[open],
|
||||
);
|
||||
|
||||
const handleOpenChange = (newOpen: boolean) => {
|
||||
setOpen(newOpen);
|
||||
};
|
||||
const handleOpenChange = (newOpen: boolean) => {
|
||||
setOpen(newOpen);
|
||||
};
|
||||
|
||||
const handleSearchChange = (value: string) => {
|
||||
setSearch(value);
|
||||
};
|
||||
const handleSearchChange = (value: string) => {
|
||||
setSearch(value);
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={handleOpenChange}>
|
||||
<DialogHeader className="sr-only print:hidden">
|
||||
<DialogTitle>
|
||||
<Trans>Builder Command Palette</Trans>
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
<Trans>Type a command or search...</Trans>
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={handleOpenChange}>
|
||||
<DialogHeader className="sr-only print:hidden">
|
||||
<DialogTitle>
|
||||
<Trans>Builder Command Palette</Trans>
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
<Trans>Type a command or search...</Trans>
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<DialogContent
|
||||
className="overflow-hidden p-0"
|
||||
aria-label={isFirstPage ? "Command Palette" : `Command Palette - ${currentPage}`}
|
||||
>
|
||||
<Command
|
||||
loop
|
||||
aria-label="Command Palette"
|
||||
className="[&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5 **:[[cmdk-group-heading]]:px-2 **:[[cmdk-group-heading]]:font-medium **:[[cmdk-group-heading]]:text-muted-foreground **:[[cmdk-group]]:px-2 **:[[cmdk-input]]:h-12 **:[[cmdk-item]]:px-2 **:[[cmdk-item]]:py-3"
|
||||
>
|
||||
<CommandInput
|
||||
ref={inputRef}
|
||||
value={search}
|
||||
onValueChange={handleSearchChange}
|
||||
placeholder={isFirstPage ? "Type a command or search..." : "Search..."}
|
||||
aria-label="Search commands"
|
||||
/>
|
||||
<DialogContent
|
||||
className="overflow-hidden p-0"
|
||||
aria-label={isFirstPage ? "Command Palette" : `Command Palette - ${currentPage}`}
|
||||
>
|
||||
<Command
|
||||
loop
|
||||
aria-label="Command Palette"
|
||||
className="[&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5 **:[[cmdk-group-heading]]:px-2 **:[[cmdk-group-heading]]:font-medium **:[[cmdk-group-heading]]:text-muted-foreground **:[[cmdk-group]]:px-2 **:[[cmdk-input]]:h-12 **:[[cmdk-item]]:px-2 **:[[cmdk-item]]:py-3"
|
||||
>
|
||||
<CommandInput
|
||||
ref={inputRef}
|
||||
value={search}
|
||||
onValueChange={handleSearchChange}
|
||||
placeholder={isFirstPage ? "Type a command or search..." : "Search..."}
|
||||
aria-label="Search commands"
|
||||
/>
|
||||
|
||||
<CommandList>
|
||||
<CommandEmpty>
|
||||
<Trans>The command you're looking for doesn't exist.</Trans>
|
||||
</CommandEmpty>
|
||||
<CommandList>
|
||||
<CommandEmpty>
|
||||
<Trans>The command you're looking for doesn't exist.</Trans>
|
||||
</CommandEmpty>
|
||||
|
||||
<ResumesCommandGroup />
|
||||
<PreferencesCommandGroup />
|
||||
<NavigationCommandGroup />
|
||||
</CommandList>
|
||||
</Command>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
<ResumesCommandGroup />
|
||||
<PreferencesCommandGroup />
|
||||
<NavigationCommandGroup />
|
||||
</CommandList>
|
||||
</Command>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -5,20 +5,20 @@ import { CommandGroup } from "@/components/ui/command";
|
||||
import { useCommandPaletteStore } from "../store";
|
||||
|
||||
type Props = {
|
||||
page?: string;
|
||||
heading: React.ReactNode;
|
||||
children: React.ReactNode;
|
||||
page?: string;
|
||||
heading: React.ReactNode;
|
||||
children: React.ReactNode;
|
||||
};
|
||||
|
||||
export const BaseCommandGroup = ({ page, heading, children }: Props) => {
|
||||
const pages = useCommandPaletteStore((state) => state.pages);
|
||||
const currentPage = pages[pages.length - 1];
|
||||
const pages = useCommandPaletteStore((state) => state.pages);
|
||||
const currentPage = pages[pages.length - 1];
|
||||
|
||||
const isEnabled = useMemo(() => {
|
||||
return currentPage === page;
|
||||
}, [currentPage, page]);
|
||||
const isEnabled = useMemo(() => {
|
||||
return currentPage === page;
|
||||
}, [currentPage, page]);
|
||||
|
||||
if (!isEnabled) return null;
|
||||
if (!isEnabled) return null;
|
||||
|
||||
return <CommandGroup heading={heading}>{children}</CommandGroup>;
|
||||
return <CommandGroup heading={heading}>{children}</CommandGroup>;
|
||||
};
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import {
|
||||
GearIcon,
|
||||
HouseSimpleIcon,
|
||||
KeyIcon,
|
||||
OpenAiLogoIcon,
|
||||
ReadCvLogoIcon,
|
||||
ShieldCheckIcon,
|
||||
UserCircleIcon,
|
||||
WarningIcon,
|
||||
GearIcon,
|
||||
HouseSimpleIcon,
|
||||
KeyIcon,
|
||||
OpenAiLogoIcon,
|
||||
ReadCvLogoIcon,
|
||||
ShieldCheckIcon,
|
||||
UserCircleIcon,
|
||||
WarningIcon,
|
||||
} from "@phosphor-icons/react";
|
||||
import { useNavigate, useRouteContext } from "@tanstack/react-router";
|
||||
|
||||
@@ -18,100 +18,100 @@ import { useCommandPaletteStore } from "../store";
|
||||
import { BaseCommandGroup } from "./base";
|
||||
|
||||
export function NavigationCommandGroup() {
|
||||
const navigate = useNavigate();
|
||||
const { session } = useRouteContext({ strict: false });
|
||||
const reset = useCommandPaletteStore((state) => state.reset);
|
||||
const pushPage = useCommandPaletteStore((state) => state.pushPage);
|
||||
const navigate = useNavigate();
|
||||
const { session } = useRouteContext({ strict: false });
|
||||
const reset = useCommandPaletteStore((state) => state.reset);
|
||||
const pushPage = useCommandPaletteStore((state) => state.pushPage);
|
||||
|
||||
const onNavigate = async (path: string) => {
|
||||
await navigate({ to: path });
|
||||
reset();
|
||||
};
|
||||
const onNavigate = async (path: string) => {
|
||||
await navigate({ to: path });
|
||||
reset();
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<BaseCommandGroup heading={<Trans>Go to...</Trans>}>
|
||||
<CommandItem keywords={[t`Home`]} value="navigation.home" onSelect={() => onNavigate("/")}>
|
||||
<HouseSimpleIcon />
|
||||
<Trans>Home</Trans>
|
||||
</CommandItem>
|
||||
return (
|
||||
<>
|
||||
<BaseCommandGroup heading={<Trans>Go to...</Trans>}>
|
||||
<CommandItem keywords={[t`Home`]} value="navigation.home" onSelect={() => onNavigate("/")}>
|
||||
<HouseSimpleIcon />
|
||||
<Trans>Home</Trans>
|
||||
</CommandItem>
|
||||
|
||||
<CommandItem
|
||||
disabled={!session}
|
||||
keywords={[t`Resumes`]}
|
||||
value="navigation.resumes"
|
||||
onSelect={() => onNavigate("/dashboard/resumes")}
|
||||
>
|
||||
<ReadCvLogoIcon />
|
||||
<Trans>Resumes</Trans>
|
||||
</CommandItem>
|
||||
<CommandItem
|
||||
disabled={!session}
|
||||
keywords={[t`Resumes`]}
|
||||
value="navigation.resumes"
|
||||
onSelect={() => onNavigate("/dashboard/resumes")}
|
||||
>
|
||||
<ReadCvLogoIcon />
|
||||
<Trans>Resumes</Trans>
|
||||
</CommandItem>
|
||||
|
||||
<CommandItem
|
||||
disabled={!session}
|
||||
keywords={[t`Settings`]}
|
||||
value="navigation.settings"
|
||||
onSelect={() => pushPage("settings")}
|
||||
>
|
||||
<GearIcon />
|
||||
<Trans>Settings</Trans>
|
||||
</CommandItem>
|
||||
</BaseCommandGroup>
|
||||
<CommandItem
|
||||
disabled={!session}
|
||||
keywords={[t`Settings`]}
|
||||
value="navigation.settings"
|
||||
onSelect={() => pushPage("settings")}
|
||||
>
|
||||
<GearIcon />
|
||||
<Trans>Settings</Trans>
|
||||
</CommandItem>
|
||||
</BaseCommandGroup>
|
||||
|
||||
<BaseCommandGroup page="settings" heading={<Trans>Settings</Trans>}>
|
||||
<CommandItem
|
||||
keywords={[t`Profile`]}
|
||||
value="navigation.settings.profile"
|
||||
onSelect={() => onNavigate("/dashboard/settings/profile")}
|
||||
>
|
||||
<UserCircleIcon />
|
||||
<Trans>Profile</Trans>
|
||||
</CommandItem>
|
||||
<BaseCommandGroup page="settings" heading={<Trans>Settings</Trans>}>
|
||||
<CommandItem
|
||||
keywords={[t`Profile`]}
|
||||
value="navigation.settings.profile"
|
||||
onSelect={() => onNavigate("/dashboard/settings/profile")}
|
||||
>
|
||||
<UserCircleIcon />
|
||||
<Trans>Profile</Trans>
|
||||
</CommandItem>
|
||||
|
||||
<CommandItem
|
||||
keywords={[t`Preferences`]}
|
||||
value="navigation.settings.preferences"
|
||||
onSelect={() => onNavigate("/dashboard/settings/preferences")}
|
||||
>
|
||||
<GearIcon />
|
||||
<Trans>Preferences</Trans>
|
||||
</CommandItem>
|
||||
<CommandItem
|
||||
keywords={[t`Preferences`]}
|
||||
value="navigation.settings.preferences"
|
||||
onSelect={() => onNavigate("/dashboard/settings/preferences")}
|
||||
>
|
||||
<GearIcon />
|
||||
<Trans>Preferences</Trans>
|
||||
</CommandItem>
|
||||
|
||||
<CommandItem
|
||||
keywords={[t`Authentication`]}
|
||||
value="navigation.settings.authentication"
|
||||
onSelect={() => onNavigate("/dashboard/settings/authentication")}
|
||||
>
|
||||
<ShieldCheckIcon />
|
||||
<Trans>Authentication</Trans>
|
||||
</CommandItem>
|
||||
<CommandItem
|
||||
keywords={[t`Authentication`]}
|
||||
value="navigation.settings.authentication"
|
||||
onSelect={() => onNavigate("/dashboard/settings/authentication")}
|
||||
>
|
||||
<ShieldCheckIcon />
|
||||
<Trans>Authentication</Trans>
|
||||
</CommandItem>
|
||||
|
||||
<CommandItem
|
||||
keywords={[t`API Keys`]}
|
||||
value="navigation.settings.api-keys"
|
||||
onSelect={() => onNavigate("/dashboard/settings/api-keys")}
|
||||
>
|
||||
<KeyIcon />
|
||||
<Trans>API Keys</Trans>
|
||||
</CommandItem>
|
||||
<CommandItem
|
||||
keywords={[t`API Keys`]}
|
||||
value="navigation.settings.api-keys"
|
||||
onSelect={() => onNavigate("/dashboard/settings/api-keys")}
|
||||
>
|
||||
<KeyIcon />
|
||||
<Trans>API Keys</Trans>
|
||||
</CommandItem>
|
||||
|
||||
<CommandItem
|
||||
keywords={[t`Artificial Intelligence`]}
|
||||
value="navigation.settings.ai"
|
||||
onSelect={() => onNavigate("/dashboard/settings/ai")}
|
||||
>
|
||||
<OpenAiLogoIcon />
|
||||
<Trans>Artificial Intelligence</Trans>
|
||||
</CommandItem>
|
||||
<CommandItem
|
||||
keywords={[t`Artificial Intelligence`]}
|
||||
value="navigation.settings.ai"
|
||||
onSelect={() => onNavigate("/dashboard/settings/ai")}
|
||||
>
|
||||
<OpenAiLogoIcon />
|
||||
<Trans>Artificial Intelligence</Trans>
|
||||
</CommandItem>
|
||||
|
||||
<CommandItem
|
||||
keywords={[t`Danger Zone`]}
|
||||
value="navigation.settings.danger-zone"
|
||||
onSelect={() => onNavigate("/dashboard/settings/danger-zone")}
|
||||
>
|
||||
<WarningIcon />
|
||||
<Trans>Danger Zone</Trans>
|
||||
</CommandItem>
|
||||
</BaseCommandGroup>
|
||||
</>
|
||||
);
|
||||
<CommandItem
|
||||
keywords={[t`Danger Zone`]}
|
||||
value="navigation.settings.danger-zone"
|
||||
onSelect={() => onNavigate("/dashboard/settings/danger-zone")}
|
||||
>
|
||||
<WarningIcon />
|
||||
<Trans>Danger Zone</Trans>
|
||||
</CommandItem>
|
||||
</BaseCommandGroup>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -9,24 +9,24 @@ import { LanguageCommandPage } from "./language";
|
||||
import { ThemeCommandPage } from "./theme";
|
||||
|
||||
export function PreferencesCommandGroup() {
|
||||
const pushPage = useCommandPaletteStore((state) => state.pushPage);
|
||||
const pushPage = useCommandPaletteStore((state) => state.pushPage);
|
||||
|
||||
return (
|
||||
<>
|
||||
<BaseCommandGroup heading={<Trans>Preferences</Trans>}>
|
||||
<CommandItem onSelect={() => pushPage("theme")}>
|
||||
<PaletteIcon />
|
||||
<Trans>Change theme to...</Trans>
|
||||
</CommandItem>
|
||||
return (
|
||||
<>
|
||||
<BaseCommandGroup heading={<Trans>Preferences</Trans>}>
|
||||
<CommandItem onSelect={() => pushPage("theme")}>
|
||||
<PaletteIcon />
|
||||
<Trans>Change theme to...</Trans>
|
||||
</CommandItem>
|
||||
|
||||
<CommandItem onSelect={() => pushPage("language")}>
|
||||
<TranslateIcon />
|
||||
<Trans>Change language to...</Trans>
|
||||
</CommandItem>
|
||||
</BaseCommandGroup>
|
||||
<CommandItem onSelect={() => pushPage("language")}>
|
||||
<TranslateIcon />
|
||||
<Trans>Change language to...</Trans>
|
||||
</CommandItem>
|
||||
</BaseCommandGroup>
|
||||
|
||||
<ThemeCommandPage />
|
||||
<LanguageCommandPage />
|
||||
</>
|
||||
);
|
||||
<ThemeCommandPage />
|
||||
<LanguageCommandPage />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -7,22 +7,22 @@ import { isLocale, loadLocale, localeMap, setLocaleServerFn } from "@/utils/loca
|
||||
import { BaseCommandGroup } from "../base";
|
||||
|
||||
export function LanguageCommandPage() {
|
||||
const { i18n } = useLingui();
|
||||
const { i18n } = useLingui();
|
||||
|
||||
const handleLocaleChange = async (value: string) => {
|
||||
if (!value || !isLocale(value)) return;
|
||||
await Promise.all([loadLocale(value), setLocaleServerFn({ data: value })]);
|
||||
window.location.reload();
|
||||
};
|
||||
const handleLocaleChange = async (value: string) => {
|
||||
if (!value || !isLocale(value)) return;
|
||||
await Promise.all([loadLocale(value), setLocaleServerFn({ data: value })]);
|
||||
window.location.reload();
|
||||
};
|
||||
|
||||
return (
|
||||
<BaseCommandGroup page="language" heading={<Trans>Language</Trans>}>
|
||||
{Object.entries(localeMap).map(([value, label]) => (
|
||||
<CommandItem key={value} onSelect={() => handleLocaleChange(value)}>
|
||||
<span className="font-mono text-xs text-muted-foreground">{value}</span>
|
||||
{i18n.t(label)}
|
||||
</CommandItem>
|
||||
))}
|
||||
</BaseCommandGroup>
|
||||
);
|
||||
return (
|
||||
<BaseCommandGroup page="language" heading={<Trans>Language</Trans>}>
|
||||
{Object.entries(localeMap).map(([value, label]) => (
|
||||
<CommandItem key={value} onSelect={() => handleLocaleChange(value)}>
|
||||
<span className="font-mono text-xs text-muted-foreground">{value}</span>
|
||||
{i18n.t(label)}
|
||||
</CommandItem>
|
||||
))}
|
||||
</BaseCommandGroup>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -8,25 +8,25 @@ import { useCommandPaletteStore } from "../../store";
|
||||
import { BaseCommandGroup } from "../base";
|
||||
|
||||
export function ThemeCommandPage() {
|
||||
const { setTheme } = useTheme();
|
||||
const setOpen = useCommandPaletteStore((state) => state.setOpen);
|
||||
const { setTheme } = useTheme();
|
||||
const setOpen = useCommandPaletteStore((state) => state.setOpen);
|
||||
|
||||
const handleThemeChange = (theme: "light" | "dark") => {
|
||||
setTheme(theme, { playSound: false });
|
||||
setOpen(false);
|
||||
};
|
||||
const handleThemeChange = (theme: "light" | "dark") => {
|
||||
setTheme(theme, { playSound: false });
|
||||
setOpen(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<BaseCommandGroup page="theme" heading={<Trans>Theme</Trans>}>
|
||||
<CommandItem value="light" onSelect={() => handleThemeChange("light")}>
|
||||
<SunIcon />
|
||||
<Trans>Light theme</Trans>
|
||||
</CommandItem>
|
||||
return (
|
||||
<BaseCommandGroup page="theme" heading={<Trans>Theme</Trans>}>
|
||||
<CommandItem value="light" onSelect={() => handleThemeChange("light")}>
|
||||
<SunIcon />
|
||||
<Trans>Light theme</Trans>
|
||||
</CommandItem>
|
||||
|
||||
<CommandItem value="dark" onSelect={() => handleThemeChange("dark")}>
|
||||
<MoonIcon />
|
||||
<Trans>Dark theme</Trans>
|
||||
</CommandItem>
|
||||
</BaseCommandGroup>
|
||||
);
|
||||
<CommandItem value="dark" onSelect={() => handleThemeChange("dark")}>
|
||||
<MoonIcon />
|
||||
<Trans>Dark theme</Trans>
|
||||
</CommandItem>
|
||||
</BaseCommandGroup>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -14,71 +14,71 @@ import { useCommandPaletteStore } from "../store";
|
||||
import { BaseCommandGroup } from "./base";
|
||||
|
||||
export function ResumesCommandGroup() {
|
||||
const navigate = useNavigate();
|
||||
const { openDialog } = useDialogStore();
|
||||
const { session } = useRouteContext({ strict: false });
|
||||
const reset = useCommandPaletteStore((state) => state.reset);
|
||||
const peekPage = useCommandPaletteStore((state) => state.peekPage);
|
||||
const pushPage = useCommandPaletteStore((state) => state.pushPage);
|
||||
const navigate = useNavigate();
|
||||
const { openDialog } = useDialogStore();
|
||||
const { session } = useRouteContext({ strict: false });
|
||||
const reset = useCommandPaletteStore((state) => state.reset);
|
||||
const peekPage = useCommandPaletteStore((state) => state.peekPage);
|
||||
const pushPage = useCommandPaletteStore((state) => state.pushPage);
|
||||
|
||||
const isResumesPage = peekPage() === "resumes";
|
||||
const isResumesPage = peekPage() === "resumes";
|
||||
|
||||
const { data: resumes, isLoading } = useQuery(
|
||||
orpc.resume.list.queryOptions({
|
||||
enabled: !!session && isResumesPage,
|
||||
}),
|
||||
);
|
||||
const { data: resumes, isLoading } = useQuery(
|
||||
orpc.resume.list.queryOptions({
|
||||
enabled: !!session && isResumesPage,
|
||||
}),
|
||||
);
|
||||
|
||||
const onCreate = async () => {
|
||||
await navigate({ to: "/dashboard/resumes" });
|
||||
openDialog("resume.create", undefined);
|
||||
reset();
|
||||
};
|
||||
const onCreate = async () => {
|
||||
await navigate({ to: "/dashboard/resumes" });
|
||||
openDialog("resume.create", undefined);
|
||||
reset();
|
||||
};
|
||||
|
||||
const onNavigate = async (path: string) => {
|
||||
await navigate({ to: path });
|
||||
reset();
|
||||
};
|
||||
const onNavigate = async (path: string) => {
|
||||
await navigate({ to: path });
|
||||
reset();
|
||||
};
|
||||
|
||||
if (!session) return null;
|
||||
if (!session) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
<BaseCommandGroup heading={<Trans>Search for...</Trans>}>
|
||||
<CommandItem keywords={[t`Resumes`]} value="search.resumes" onSelect={() => pushPage("resumes")}>
|
||||
<ReadCvLogoIcon />
|
||||
<Trans>Resumes</Trans>
|
||||
</CommandItem>
|
||||
</BaseCommandGroup>
|
||||
return (
|
||||
<>
|
||||
<BaseCommandGroup heading={<Trans>Search for...</Trans>}>
|
||||
<CommandItem keywords={[t`Resumes`]} value="search.resumes" onSelect={() => pushPage("resumes")}>
|
||||
<ReadCvLogoIcon />
|
||||
<Trans>Resumes</Trans>
|
||||
</CommandItem>
|
||||
</BaseCommandGroup>
|
||||
|
||||
<BaseCommandGroup page="resumes" heading={<Trans>Resumes</Trans>}>
|
||||
<CommandItem onSelect={onCreate}>
|
||||
<PlusIcon />
|
||||
<Trans>Create a new resume</Trans>
|
||||
</CommandItem>
|
||||
<BaseCommandGroup page="resumes" heading={<Trans>Resumes</Trans>}>
|
||||
<CommandItem onSelect={onCreate}>
|
||||
<PlusIcon />
|
||||
<Trans>Create a new resume</Trans>
|
||||
</CommandItem>
|
||||
|
||||
{isLoading ? (
|
||||
<CommandLoading>
|
||||
<Trans>Loading resumes...</Trans>
|
||||
</CommandLoading>
|
||||
) : (
|
||||
resumes?.map((resume) => (
|
||||
<CommandItem
|
||||
key={resume.id}
|
||||
value={resume.id}
|
||||
keywords={[resume.name]}
|
||||
onSelect={() => onNavigate(`/builder/${resume.id}`)}
|
||||
>
|
||||
<ReadCvLogoIcon />
|
||||
{resume.name}
|
||||
{isLoading ? (
|
||||
<CommandLoading>
|
||||
<Trans>Loading resumes...</Trans>
|
||||
</CommandLoading>
|
||||
) : (
|
||||
resumes?.map((resume) => (
|
||||
<CommandItem
|
||||
key={resume.id}
|
||||
value={resume.id}
|
||||
keywords={[resume.name]}
|
||||
onSelect={() => onNavigate(`/builder/${resume.id}`)}
|
||||
>
|
||||
<ReadCvLogoIcon />
|
||||
{resume.name}
|
||||
|
||||
<CommandShortcut className="opacity-0 transition-opacity group-data-[selected=true]/command-item:opacity-100">
|
||||
Press <Kbd>Enter</Kbd> to open
|
||||
</CommandShortcut>
|
||||
</CommandItem>
|
||||
))
|
||||
)}
|
||||
</BaseCommandGroup>
|
||||
</>
|
||||
);
|
||||
<CommandShortcut className="opacity-0 transition-opacity group-data-[selected=true]/command-item:opacity-100">
|
||||
Press <Kbd>Enter</Kbd> to open
|
||||
</CommandShortcut>
|
||||
</CommandItem>
|
||||
))
|
||||
)}
|
||||
</BaseCommandGroup>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,59 +1,59 @@
|
||||
import { create } from "zustand/react";
|
||||
|
||||
type CommandPaletteState = {
|
||||
open: boolean;
|
||||
search: string;
|
||||
pages: string[];
|
||||
open: boolean;
|
||||
search: string;
|
||||
pages: string[];
|
||||
};
|
||||
|
||||
type CommandPaletteActions = {
|
||||
setOpen: (open: boolean) => void;
|
||||
setSearch: (search: string) => void;
|
||||
pushPage: (page: string) => void;
|
||||
peekPage: () => string | undefined;
|
||||
popPage: () => void;
|
||||
reset: () => void;
|
||||
goBack: () => void;
|
||||
setOpen: (open: boolean) => void;
|
||||
setSearch: (search: string) => void;
|
||||
pushPage: (page: string) => void;
|
||||
peekPage: () => string | undefined;
|
||||
popPage: () => void;
|
||||
reset: () => void;
|
||||
goBack: () => void;
|
||||
};
|
||||
|
||||
type CommandPaletteStore = CommandPaletteState & CommandPaletteActions;
|
||||
|
||||
const initialState: CommandPaletteState = {
|
||||
open: false,
|
||||
search: "",
|
||||
pages: [],
|
||||
open: false,
|
||||
search: "",
|
||||
pages: [],
|
||||
};
|
||||
|
||||
export const useCommandPaletteStore = create<CommandPaletteStore>((set, get) => ({
|
||||
...initialState,
|
||||
...initialState,
|
||||
|
||||
setOpen: (open) => {
|
||||
set({ open });
|
||||
if (!open) set(initialState);
|
||||
},
|
||||
setOpen: (open) => {
|
||||
set({ open });
|
||||
if (!open) set(initialState);
|
||||
},
|
||||
|
||||
setSearch: (search) => set({ search }),
|
||||
setSearch: (search) => set({ search }),
|
||||
|
||||
peekPage: () => get().pages[get().pages.length - 1],
|
||||
peekPage: () => get().pages[get().pages.length - 1],
|
||||
|
||||
pushPage: (page) => set((state) => ({ pages: [...state.pages, page], search: "" })),
|
||||
pushPage: (page) => set((state) => ({ pages: [...state.pages, page], search: "" })),
|
||||
|
||||
popPage: () => set((state) => ({ pages: state.pages.slice(0, -1), search: "" })),
|
||||
popPage: () => set((state) => ({ pages: state.pages.slice(0, -1), search: "" })),
|
||||
|
||||
reset: () => set(initialState),
|
||||
reset: () => set(initialState),
|
||||
|
||||
goBack: () => {
|
||||
set((state) => {
|
||||
if (state.search.length > 0) {
|
||||
// If there's text, just clear the search
|
||||
return { search: "" };
|
||||
}
|
||||
if (state.pages.length > 0) {
|
||||
// If on a sub-page, go back
|
||||
return { pages: state.pages.slice(0, -1), search: "" };
|
||||
}
|
||||
// If on first page, close
|
||||
return { open: false, search: "", pages: [] };
|
||||
});
|
||||
},
|
||||
goBack: () => {
|
||||
set((state) => {
|
||||
if (state.search.length > 0) {
|
||||
// If there's text, just clear the search
|
||||
return { search: "" };
|
||||
}
|
||||
if (state.pages.length > 0) {
|
||||
// If on a sub-page, go back
|
||||
return { pages: state.pages.slice(0, -1), search: "" };
|
||||
}
|
||||
// If on first page, close
|
||||
return { open: false, search: "", pages: [] };
|
||||
});
|
||||
},
|
||||
}));
|
||||
|
||||
+252
-252
@@ -1,17 +1,17 @@
|
||||
import {
|
||||
closestCenter,
|
||||
DndContext,
|
||||
type DragEndEvent,
|
||||
KeyboardSensor,
|
||||
PointerSensor,
|
||||
useSensor,
|
||||
useSensors,
|
||||
closestCenter,
|
||||
DndContext,
|
||||
type DragEndEvent,
|
||||
KeyboardSensor,
|
||||
PointerSensor,
|
||||
useSensor,
|
||||
useSensors,
|
||||
} from "@dnd-kit/core";
|
||||
import {
|
||||
horizontalListSortingStrategy,
|
||||
SortableContext,
|
||||
sortableKeyboardCoordinates,
|
||||
useSortable,
|
||||
horizontalListSortingStrategy,
|
||||
SortableContext,
|
||||
sortableKeyboardCoordinates,
|
||||
useSortable,
|
||||
} from "@dnd-kit/sortable";
|
||||
import { CSS } from "@dnd-kit/utilities";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
@@ -30,275 +30,275 @@ const RETURN_KEY = "Enter";
|
||||
const COMMA_KEY = ",";
|
||||
|
||||
type ChipItemProps = {
|
||||
id: string;
|
||||
chip: string;
|
||||
index: number;
|
||||
isEditing: boolean;
|
||||
onEdit: (index: number) => void;
|
||||
onRemove: (index: number) => void;
|
||||
id: string;
|
||||
chip: string;
|
||||
index: number;
|
||||
isEditing: boolean;
|
||||
onEdit: (index: number) => void;
|
||||
onRemove: (index: number) => void;
|
||||
};
|
||||
|
||||
function ChipItem({ id, chip, index, isEditing, onEdit, onRemove }: ChipItemProps) {
|
||||
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id });
|
||||
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id });
|
||||
|
||||
const [isHovered, setIsHovered] = React.useState(false);
|
||||
const [isHovered, setIsHovered] = React.useState(false);
|
||||
|
||||
const style = {
|
||||
transform: CSS.Transform.toString(transform),
|
||||
transition,
|
||||
zIndex: isDragging ? 10 : undefined,
|
||||
opacity: isDragging ? 0.6 : 1,
|
||||
};
|
||||
const style = {
|
||||
transform: CSS.Transform.toString(transform),
|
||||
transition,
|
||||
zIndex: isDragging ? 10 : undefined,
|
||||
opacity: isDragging ? 0.6 : 1,
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
style={style}
|
||||
ref={setNodeRef}
|
||||
className="relative touch-none"
|
||||
onMouseEnter={() => setIsHovered(true)}
|
||||
onMouseLeave={() => setIsHovered(false)}
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
>
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={cn(
|
||||
"flex h-7 cursor-grab items-center gap-0 overflow-hidden px-3 select-none active:cursor-grabbing",
|
||||
isEditing && "border-primary ring-1 ring-primary",
|
||||
isDragging && "opacity-80",
|
||||
)}
|
||||
>
|
||||
<span className="max-w-32 truncate">{chip}</span>
|
||||
<motion.div
|
||||
initial={false}
|
||||
animate={{ width: isHovered ? 40 : 0, marginInlineStart: isHovered ? 8 : 0, opacity: isHovered ? 1 : 0 }}
|
||||
transition={{ duration: 0.15, ease: "linear" }}
|
||||
className="flex shrink-0 items-center gap-x-1 overflow-hidden"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
tabIndex={-1}
|
||||
aria-label={`Edit ${chip}`}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onEdit(index);
|
||||
}}
|
||||
className="rounded-sm p-0.5 hover:bg-secondary hover:text-foreground focus:outline-none"
|
||||
>
|
||||
<PencilSimpleIcon className="size-3.5" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
tabIndex={-1}
|
||||
aria-label={`Remove ${chip}`}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onRemove(index);
|
||||
}}
|
||||
className="rounded-sm p-0.5 hover:bg-destructive/10 hover:text-destructive focus:outline-none"
|
||||
>
|
||||
<XIcon className="size-3.5" />
|
||||
</button>
|
||||
</motion.div>
|
||||
</Badge>
|
||||
</div>
|
||||
);
|
||||
return (
|
||||
<div
|
||||
style={style}
|
||||
ref={setNodeRef}
|
||||
className="relative touch-none"
|
||||
onMouseEnter={() => setIsHovered(true)}
|
||||
onMouseLeave={() => setIsHovered(false)}
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
>
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={cn(
|
||||
"flex h-7 cursor-grab items-center gap-0 overflow-hidden px-3 select-none active:cursor-grabbing",
|
||||
isEditing && "border-primary ring-1 ring-primary",
|
||||
isDragging && "opacity-80",
|
||||
)}
|
||||
>
|
||||
<span className="max-w-32 truncate">{chip}</span>
|
||||
<motion.div
|
||||
initial={false}
|
||||
animate={{ width: isHovered ? 40 : 0, marginInlineStart: isHovered ? 8 : 0, opacity: isHovered ? 1 : 0 }}
|
||||
transition={{ duration: 0.15, ease: "linear" }}
|
||||
className="flex shrink-0 items-center gap-x-1 overflow-hidden"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
tabIndex={-1}
|
||||
aria-label={`Edit ${chip}`}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onEdit(index);
|
||||
}}
|
||||
className="rounded-sm p-0.5 hover:bg-secondary hover:text-foreground focus:outline-none"
|
||||
>
|
||||
<PencilSimpleIcon className="size-3.5" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
tabIndex={-1}
|
||||
aria-label={`Remove ${chip}`}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onRemove(index);
|
||||
}}
|
||||
className="rounded-sm p-0.5 hover:bg-destructive/10 hover:text-destructive focus:outline-none"
|
||||
>
|
||||
<XIcon className="size-3.5" />
|
||||
</button>
|
||||
</motion.div>
|
||||
</Badge>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type Props = Omit<React.ComponentProps<"div">, "value" | "onChange"> & {
|
||||
value?: string[];
|
||||
defaultValue?: string[];
|
||||
onChange?: (value: string[]) => void;
|
||||
value?: string[];
|
||||
defaultValue?: string[];
|
||||
onChange?: (value: string[]) => void;
|
||||
};
|
||||
|
||||
export function ChipInput({ value, defaultValue = [], onChange, className, ...props }: Props) {
|
||||
const [chips, setChips] = useControlledState<string[]>({
|
||||
value,
|
||||
defaultValue,
|
||||
onChange,
|
||||
});
|
||||
const [chips, setChips] = useControlledState<string[]>({
|
||||
value,
|
||||
defaultValue,
|
||||
onChange,
|
||||
});
|
||||
|
||||
const [input, setInput] = React.useState("");
|
||||
const [editingIndex, setEditingIndex] = React.useState<number | null>(null);
|
||||
const inputRef = React.useRef<HTMLInputElement>(null);
|
||||
const [input, setInput] = React.useState("");
|
||||
const [editingIndex, setEditingIndex] = React.useState<number | null>(null);
|
||||
const inputRef = React.useRef<HTMLInputElement>(null);
|
||||
|
||||
const addChip = React.useCallback(
|
||||
(chip: string) => {
|
||||
const trimmed = chip.trim();
|
||||
if (!trimmed) return;
|
||||
const newChips = Array.from(new Set([...chips, trimmed]));
|
||||
setChips(newChips);
|
||||
},
|
||||
[chips, setChips],
|
||||
);
|
||||
const addChip = React.useCallback(
|
||||
(chip: string) => {
|
||||
const trimmed = chip.trim();
|
||||
if (!trimmed) return;
|
||||
const newChips = Array.from(new Set([...chips, trimmed]));
|
||||
setChips(newChips);
|
||||
},
|
||||
[chips, setChips],
|
||||
);
|
||||
|
||||
const updateChip = React.useCallback(
|
||||
(index: number, newValue: string) => {
|
||||
const trimmed = newValue.trim();
|
||||
if (!trimmed || index < 0 || index >= chips.length) return;
|
||||
const updateChip = React.useCallback(
|
||||
(index: number, newValue: string) => {
|
||||
const trimmed = newValue.trim();
|
||||
if (!trimmed || index < 0 || index >= chips.length) return;
|
||||
|
||||
const existingIndex = chips.findIndex((c, i) => c === trimmed && i !== index);
|
||||
if (existingIndex !== -1) return;
|
||||
const existingIndex = chips.findIndex((c, i) => c === trimmed && i !== index);
|
||||
if (existingIndex !== -1) return;
|
||||
|
||||
const newChips = [...chips];
|
||||
newChips[index] = trimmed;
|
||||
setChips(newChips);
|
||||
},
|
||||
[chips, setChips],
|
||||
);
|
||||
const newChips = [...chips];
|
||||
newChips[index] = trimmed;
|
||||
setChips(newChips);
|
||||
},
|
||||
[chips, setChips],
|
||||
);
|
||||
|
||||
const removeChip = React.useCallback(
|
||||
(index: number) => {
|
||||
if (index < 0 || index >= chips.length) return;
|
||||
const newChips = chips.slice(0, index).concat(chips.slice(index + 1));
|
||||
setChips(newChips);
|
||||
const removeChip = React.useCallback(
|
||||
(index: number) => {
|
||||
if (index < 0 || index >= chips.length) return;
|
||||
const newChips = chips.slice(0, index).concat(chips.slice(index + 1));
|
||||
setChips(newChips);
|
||||
|
||||
if (editingIndex === index) {
|
||||
setEditingIndex(null);
|
||||
setInput("");
|
||||
} else if (editingIndex !== null && editingIndex > index) {
|
||||
setEditingIndex(editingIndex - 1);
|
||||
}
|
||||
},
|
||||
[chips, setChips, editingIndex],
|
||||
);
|
||||
if (editingIndex === index) {
|
||||
setEditingIndex(null);
|
||||
setInput("");
|
||||
} else if (editingIndex !== null && editingIndex > index) {
|
||||
setEditingIndex(editingIndex - 1);
|
||||
}
|
||||
},
|
||||
[chips, setChips, editingIndex],
|
||||
);
|
||||
|
||||
const handleEdit = React.useCallback(
|
||||
(index: number) => {
|
||||
setEditingIndex(index);
|
||||
setInput(chips[index]);
|
||||
inputRef.current?.focus();
|
||||
},
|
||||
[chips],
|
||||
);
|
||||
const handleEdit = React.useCallback(
|
||||
(index: number) => {
|
||||
setEditingIndex(index);
|
||||
setInput(chips[index]);
|
||||
inputRef.current?.focus();
|
||||
},
|
||||
[chips],
|
||||
);
|
||||
|
||||
const handleReorder = React.useCallback(
|
||||
(newOrder: string[]) => {
|
||||
if (editingIndex !== null) {
|
||||
const editingChip = chips[editingIndex];
|
||||
const newIndex = newOrder.indexOf(editingChip);
|
||||
if (newIndex !== -1 && newIndex !== editingIndex) {
|
||||
setEditingIndex(newIndex);
|
||||
}
|
||||
}
|
||||
setChips(newOrder);
|
||||
},
|
||||
[chips, editingIndex, setChips],
|
||||
);
|
||||
const handleReorder = React.useCallback(
|
||||
(newOrder: string[]) => {
|
||||
if (editingIndex !== null) {
|
||||
const editingChip = chips[editingIndex];
|
||||
const newIndex = newOrder.indexOf(editingChip);
|
||||
if (newIndex !== -1 && newIndex !== editingIndex) {
|
||||
setEditingIndex(newIndex);
|
||||
}
|
||||
}
|
||||
setChips(newOrder);
|
||||
},
|
||||
[chips, editingIndex, setChips],
|
||||
);
|
||||
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor, {
|
||||
activationConstraint: { distance: 3 },
|
||||
}),
|
||||
useSensor(KeyboardSensor, {
|
||||
coordinateGetter: sortableKeyboardCoordinates,
|
||||
}),
|
||||
);
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor, {
|
||||
activationConstraint: { distance: 3 },
|
||||
}),
|
||||
useSensor(KeyboardSensor, {
|
||||
coordinateGetter: sortableKeyboardCoordinates,
|
||||
}),
|
||||
);
|
||||
|
||||
const handleDragEnd = React.useCallback(
|
||||
(event: DragEndEvent) => {
|
||||
const { active, over } = event;
|
||||
if (!over || active.id === over.id) return;
|
||||
const oldIndex = chips.indexOf(active.id as string);
|
||||
const newIndex = chips.indexOf(over.id as string);
|
||||
if (oldIndex !== -1 && newIndex !== -1 && oldIndex !== newIndex) {
|
||||
const newOrder = Array.from(chips);
|
||||
const [removed] = newOrder.splice(oldIndex, 1);
|
||||
newOrder.splice(newIndex, 0, removed);
|
||||
handleReorder(newOrder);
|
||||
}
|
||||
},
|
||||
[chips, handleReorder],
|
||||
);
|
||||
const handleDragEnd = React.useCallback(
|
||||
(event: DragEndEvent) => {
|
||||
const { active, over } = event;
|
||||
if (!over || active.id === over.id) return;
|
||||
const oldIndex = chips.indexOf(active.id as string);
|
||||
const newIndex = chips.indexOf(over.id as string);
|
||||
if (oldIndex !== -1 && newIndex !== -1 && oldIndex !== newIndex) {
|
||||
const newOrder = Array.from(chips);
|
||||
const [removed] = newOrder.splice(oldIndex, 1);
|
||||
newOrder.splice(newIndex, 0, removed);
|
||||
handleReorder(newOrder);
|
||||
}
|
||||
},
|
||||
[chips, handleReorder],
|
||||
);
|
||||
|
||||
const handleInputChange = React.useCallback(
|
||||
(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const newValue = e.target.value;
|
||||
const handleInputChange = React.useCallback(
|
||||
(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const newValue = e.target.value;
|
||||
|
||||
if (editingIndex !== null) {
|
||||
if (newValue.includes(",")) {
|
||||
updateChip(editingIndex, newValue.replace(",", ""));
|
||||
setEditingIndex(null);
|
||||
setInput("");
|
||||
} else {
|
||||
setInput(newValue);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (editingIndex !== null) {
|
||||
if (newValue.includes(",")) {
|
||||
updateChip(editingIndex, newValue.replace(",", ""));
|
||||
setEditingIndex(null);
|
||||
setInput("");
|
||||
} else {
|
||||
setInput(newValue);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (newValue.includes(",")) {
|
||||
const parts = newValue.split(",");
|
||||
parts.slice(0, -1).forEach(addChip);
|
||||
setInput(parts[parts.length - 1]);
|
||||
} else {
|
||||
setInput(newValue);
|
||||
}
|
||||
},
|
||||
[addChip, editingIndex, updateChip],
|
||||
);
|
||||
if (newValue.includes(",")) {
|
||||
const parts = newValue.split(",");
|
||||
parts.slice(0, -1).forEach(addChip);
|
||||
setInput(parts[parts.length - 1]);
|
||||
} else {
|
||||
setInput(newValue);
|
||||
}
|
||||
},
|
||||
[addChip, editingIndex, updateChip],
|
||||
);
|
||||
|
||||
const handleKeyDown = React.useCallback(
|
||||
(e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (e.key === "Enter" || e.key === ",") {
|
||||
e.preventDefault();
|
||||
const handleKeyDown = React.useCallback(
|
||||
(e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (e.key === "Enter" || e.key === ",") {
|
||||
e.preventDefault();
|
||||
|
||||
if (editingIndex !== null) {
|
||||
if (input.trim()) {
|
||||
updateChip(editingIndex, input);
|
||||
}
|
||||
setEditingIndex(null);
|
||||
setInput("");
|
||||
} else if (input.trim()) {
|
||||
addChip(input);
|
||||
setInput("");
|
||||
}
|
||||
} else if (e.key === "Escape" && editingIndex !== null) {
|
||||
setEditingIndex(null);
|
||||
setInput("");
|
||||
}
|
||||
},
|
||||
[input, addChip, editingIndex, updateChip],
|
||||
);
|
||||
if (editingIndex !== null) {
|
||||
if (input.trim()) {
|
||||
updateChip(editingIndex, input);
|
||||
}
|
||||
setEditingIndex(null);
|
||||
setInput("");
|
||||
} else if (input.trim()) {
|
||||
addChip(input);
|
||||
setInput("");
|
||||
}
|
||||
} else if (e.key === "Escape" && editingIndex !== null) {
|
||||
setEditingIndex(null);
|
||||
setInput("");
|
||||
}
|
||||
},
|
||||
[input, addChip, editingIndex, updateChip],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={cn("flex flex-col gap-2", className)} {...props}>
|
||||
{chips.length > 0 && (
|
||||
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
|
||||
<SortableContext items={chips} strategy={horizontalListSortingStrategy}>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{chips.map((chip, idx) => (
|
||||
<ChipItem
|
||||
key={`${chip}-${idx}`}
|
||||
id={chip}
|
||||
chip={chip}
|
||||
index={idx}
|
||||
isEditing={editingIndex === idx}
|
||||
onEdit={handleEdit}
|
||||
onRemove={removeChip}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</SortableContext>
|
||||
</DndContext>
|
||||
)}
|
||||
return (
|
||||
<div className={cn("flex flex-col gap-2", className)} {...props}>
|
||||
{chips.length > 0 && (
|
||||
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
|
||||
<SortableContext items={chips} strategy={horizontalListSortingStrategy}>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{chips.map((chip, idx) => (
|
||||
<ChipItem
|
||||
key={`${chip}-${idx}`}
|
||||
id={chip}
|
||||
chip={chip}
|
||||
index={idx}
|
||||
isEditing={editingIndex === idx}
|
||||
onEdit={handleEdit}
|
||||
onRemove={removeChip}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</SortableContext>
|
||||
</DndContext>
|
||||
)}
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<Input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
value={input}
|
||||
autoComplete="off"
|
||||
aria-label={editingIndex !== null ? "Edit keyword" : "Add keyword"}
|
||||
placeholder={editingIndex !== null ? "Editing keyword..." : "Add a keyword..."}
|
||||
onKeyDown={handleKeyDown}
|
||||
onChange={handleInputChange}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
<Trans>
|
||||
Press <Kbd>{RETURN_KEY}</Kbd> or <Kbd>{COMMA_KEY}</Kbd> to add or save the current keyword.
|
||||
</Trans>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
<div className="flex flex-col gap-2">
|
||||
<Input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
value={input}
|
||||
autoComplete="off"
|
||||
aria-label={editingIndex !== null ? "Edit keyword" : "Add keyword"}
|
||||
placeholder={editingIndex !== null ? "Editing keyword..." : "Add a keyword..."}
|
||||
onKeyDown={handleKeyDown}
|
||||
onChange={handleInputChange}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
<Trans>
|
||||
Press <Kbd>{RETURN_KEY}</Kbd> or <Kbd>{COMMA_KEY}</Kbd> to add or save the current keyword.
|
||||
</Trans>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -7,37 +7,37 @@ import { useControlledState } from "@/hooks/use-controlled-state";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "../ui/popover";
|
||||
|
||||
type ColorPickerProps = {
|
||||
value?: string;
|
||||
defaultValue?: string;
|
||||
onChange?: (value: string) => void;
|
||||
value?: string;
|
||||
defaultValue?: string;
|
||||
onChange?: (value: string) => void;
|
||||
};
|
||||
|
||||
export function ColorPicker({ value, defaultValue, onChange }: ColorPickerProps) {
|
||||
const [currentValue, setCurrentValue] = useControlledState<string>({
|
||||
value,
|
||||
defaultValue,
|
||||
onChange,
|
||||
});
|
||||
const [currentValue, setCurrentValue] = useControlledState<string>({
|
||||
value,
|
||||
defaultValue,
|
||||
onChange,
|
||||
});
|
||||
|
||||
const color = useMemo(() => rgbaStringToHsva(currentValue), [currentValue]);
|
||||
const color = useMemo(() => rgbaStringToHsva(currentValue), [currentValue]);
|
||||
|
||||
function onColorChange(color: ColorResult) {
|
||||
const rgbaString = hsvaToRgbaString(color.hsva);
|
||||
setCurrentValue(rgbaString);
|
||||
}
|
||||
function onColorChange(color: ColorResult) {
|
||||
const rgbaString = hsvaToRgbaString(color.hsva);
|
||||
setCurrentValue(rgbaString);
|
||||
}
|
||||
|
||||
return (
|
||||
<Popover>
|
||||
<PopoverTrigger>
|
||||
<div
|
||||
className="size-6 shrink-0 cursor-pointer rounded-full border border-foreground transition-opacity hover:opacity-60"
|
||||
style={{ backgroundColor: currentValue }}
|
||||
/>
|
||||
</PopoverTrigger>
|
||||
return (
|
||||
<Popover>
|
||||
<PopoverTrigger>
|
||||
<div
|
||||
className="size-6 shrink-0 cursor-pointer rounded-full border border-foreground transition-opacity hover:opacity-60"
|
||||
style={{ backgroundColor: currentValue }}
|
||||
/>
|
||||
</PopoverTrigger>
|
||||
|
||||
<PopoverContent className="max-w-fit rounded-xl p-2">
|
||||
<ReactColorColorful color={color} onChange={onColorChange} />
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
<PopoverContent className="max-w-fit rounded-xl p-2">
|
||||
<ReactColorColorful color={color} onChange={onColorChange} />
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -8,26 +8,26 @@ import { orpc } from "@/integrations/orpc/client";
|
||||
import { CountUp } from "../animation/count-up";
|
||||
|
||||
export function GithubStarsButton() {
|
||||
const { data: starCount } = useQuery(orpc.statistics.github.getStarCount.queryOptions());
|
||||
const { data: starCount } = useQuery(orpc.statistics.github.getStarCount.queryOptions());
|
||||
|
||||
const ariaLabel =
|
||||
starCount != null
|
||||
? t`Star us on GitHub, currently ${starCount.toLocaleString()} stars (opens in new tab)`
|
||||
: t`Star us on GitHub (opens in new tab)`;
|
||||
const ariaLabel =
|
||||
starCount != null
|
||||
? t`Star us on GitHub, currently ${starCount.toLocaleString()} stars (opens in new tab)`
|
||||
: t`Star us on GitHub (opens in new tab)`;
|
||||
|
||||
return (
|
||||
<Button
|
||||
variant="outline"
|
||||
nativeButton={false}
|
||||
render={
|
||||
<a target="_blank" href="https://github.com/amruthpillai/reactive-resume" aria-label={ariaLabel} rel="noopener">
|
||||
<GithubLogoIcon aria-hidden="true" />
|
||||
{starCount != null ? (
|
||||
<CountUp to={starCount} duration={0.5} separator="," className="font-bold" aria-hidden="true" />
|
||||
) : null}
|
||||
<StarIcon aria-hidden="true" />
|
||||
</a>
|
||||
}
|
||||
/>
|
||||
);
|
||||
return (
|
||||
<Button
|
||||
variant="outline"
|
||||
nativeButton={false}
|
||||
render={
|
||||
<a target="_blank" href="https://github.com/amruthpillai/reactive-resume" aria-label={ariaLabel} rel="noopener">
|
||||
<GithubLogoIcon aria-hidden="true" />
|
||||
{starCount != null ? (
|
||||
<CountUp to={starCount} duration={0.5} separator="," className="font-bold" aria-hidden="true" />
|
||||
) : null}
|
||||
<StarIcon aria-hidden="true" />
|
||||
</a>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -16,24 +16,24 @@ const columnWidth = 36;
|
||||
const rowHeight = 36;
|
||||
|
||||
type IconSearchInputProps = {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
className?: string;
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
function _IconSearchInput(props: IconSearchInputProps) {
|
||||
return (
|
||||
<Input
|
||||
autoFocus
|
||||
spellCheck={false}
|
||||
inputMode="search"
|
||||
value={props.value}
|
||||
aria-label={t`Search for an icon`}
|
||||
placeholder={t`Search for an icon`}
|
||||
onChange={(e) => props.onChange(e.currentTarget.value)}
|
||||
className={cn("rounded-none border-0 focus-visible:ring-0", props.className)}
|
||||
/>
|
||||
);
|
||||
return (
|
||||
<Input
|
||||
autoFocus
|
||||
spellCheck={false}
|
||||
inputMode="search"
|
||||
value={props.value}
|
||||
aria-label={t`Search for an icon`}
|
||||
placeholder={t`Search for an icon`}
|
||||
onChange={(e) => props.onChange(e.currentTarget.value)}
|
||||
className={cn("rounded-none border-0 focus-visible:ring-0", props.className)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const IconSearchInput = memo(_IconSearchInput);
|
||||
@@ -41,81 +41,81 @@ const IconSearchInput = memo(_IconSearchInput);
|
||||
IconSearchInput.displayName = "IconSearchInput";
|
||||
|
||||
type IconCellComponentProps = CellComponentProps & {
|
||||
icons: IconName[];
|
||||
onChange: (icon: IconName) => void;
|
||||
icons: IconName[];
|
||||
onChange: (icon: IconName) => void;
|
||||
};
|
||||
|
||||
function IconCellComponent({ columnIndex, rowIndex, style, icons, onChange }: IconCellComponentProps) {
|
||||
const index = rowIndex * columnCount + columnIndex;
|
||||
const icon = icons[index];
|
||||
const index = rowIndex * columnCount + columnIndex;
|
||||
const icon = icons[index];
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
title={icon}
|
||||
style={style}
|
||||
tabIndex={-1}
|
||||
onClick={() => onChange(icon)}
|
||||
className="flex size-full items-center justify-center hover:bg-accent"
|
||||
>
|
||||
{icon ? <i className={cn("ph text-base", `ph-${icon}`)} /> : <ProhibitIcon />}
|
||||
</button>
|
||||
);
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
title={icon}
|
||||
style={style}
|
||||
tabIndex={-1}
|
||||
onClick={() => onChange(icon)}
|
||||
className="flex size-full items-center justify-center hover:bg-accent"
|
||||
>
|
||||
{icon ? <i className={cn("ph text-base", `ph-${icon}`)} /> : <ProhibitIcon />}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function useIconSearch() {
|
||||
const fuse = useMemo(() => new Fuse(icons, { threshold: 0.35 }), []);
|
||||
const fuse = useMemo(() => new Fuse(icons, { threshold: 0.35 }), []);
|
||||
|
||||
const search = useCallback(
|
||||
(query: string): IconName[] => {
|
||||
if (!query.trim()) return Array.from(icons);
|
||||
return fuse.search(query).map((result) => result.item);
|
||||
},
|
||||
[fuse],
|
||||
);
|
||||
const search = useCallback(
|
||||
(query: string): IconName[] => {
|
||||
if (!query.trim()) return Array.from(icons);
|
||||
return fuse.search(query).map((result) => result.item);
|
||||
},
|
||||
[fuse],
|
||||
);
|
||||
|
||||
return search;
|
||||
return search;
|
||||
}
|
||||
|
||||
type IconPickerProps = Omit<React.ComponentProps<typeof Button>, "value" | "onChange"> & {
|
||||
value: string;
|
||||
onChange: (icon: string) => void;
|
||||
popoverProps?: React.ComponentProps<typeof Popover>;
|
||||
value: string;
|
||||
onChange: (icon: string) => void;
|
||||
popoverProps?: React.ComponentProps<typeof Popover>;
|
||||
};
|
||||
|
||||
export function IconPicker({ value, onChange, popoverProps, ...props }: IconPickerProps) {
|
||||
const searchIcons = useIconSearch();
|
||||
const searchIcons = useIconSearch();
|
||||
|
||||
const [search, setSearch] = useState("");
|
||||
const [search, setSearch] = useState("");
|
||||
|
||||
const searchedIcons = useMemo(() => searchIcons(search), [search, searchIcons]);
|
||||
const rowCount = useMemo(() => Math.ceil(searchedIcons.length / columnCount), [searchedIcons]);
|
||||
const searchedIcons = useMemo(() => searchIcons(search), [search, searchIcons]);
|
||||
const rowCount = useMemo(() => Math.ceil(searchedIcons.length / columnCount), [searchedIcons]);
|
||||
|
||||
return (
|
||||
<Popover {...popoverProps}>
|
||||
<PopoverTrigger
|
||||
render={
|
||||
<Button size="icon" variant="outline" {...props}>
|
||||
<i className={cn("ph size-4 text-base", `ph-${value}`)} />
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
return (
|
||||
<Popover {...popoverProps}>
|
||||
<PopoverTrigger
|
||||
render={
|
||||
<Button size="icon" variant="outline" {...props}>
|
||||
<i className={cn("ph size-4 text-base", `ph-${value}`)} />
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
<PopoverContent align="start" className="h-[326px] w-[290px] gap-0 p-0">
|
||||
<IconSearchInput value={search} onChange={setSearch} />
|
||||
<PopoverContent align="start" className="h-[326px] w-[290px] gap-0 p-0">
|
||||
<IconSearchInput value={search} onChange={setSearch} />
|
||||
|
||||
<div className="size-[290px]">
|
||||
<Grid
|
||||
key={search}
|
||||
rowCount={rowCount}
|
||||
rowHeight={rowHeight}
|
||||
columnCount={columnCount}
|
||||
columnWidth={columnWidth}
|
||||
cellComponent={IconCellComponent}
|
||||
cellProps={{ icons: searchedIcons, onChange }}
|
||||
/>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
<div className="size-[290px]">
|
||||
<Grid
|
||||
key={search}
|
||||
rowCount={rowCount}
|
||||
rowHeight={rowHeight}
|
||||
columnCount={columnCount}
|
||||
columnWidth={columnWidth}
|
||||
cellComponent={IconCellComponent}
|
||||
cellProps={{ icons: searchedIcons, onChange }}
|
||||
/>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,178 +1,178 @@
|
||||
.tiptap_content {
|
||||
--list-margin: 0.25rem;
|
||||
--table-border-color: var(--page-text-color);
|
||||
word-wrap: break-word;
|
||||
overflow-wrap: break-word;
|
||||
--list-margin: 0.25rem;
|
||||
--table-border-color: var(--page-text-color);
|
||||
word-wrap: break-word;
|
||||
overflow-wrap: break-word;
|
||||
|
||||
:where(p) {
|
||||
margin-top: 0.5rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
:where(p) {
|
||||
margin-top: 0.5rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
:where(p):first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
:where(p):first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
:where(p):last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
:where(p):last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
:where(ul, ol) {
|
||||
margin-top: var(--list-margin);
|
||||
margin-bottom: var(--list-margin);
|
||||
list-style-position: inside;
|
||||
}
|
||||
:where(ul, ol) {
|
||||
margin-top: var(--list-margin);
|
||||
margin-bottom: var(--list-margin);
|
||||
list-style-position: inside;
|
||||
}
|
||||
|
||||
:where(ul) {
|
||||
list-style-type: disc;
|
||||
}
|
||||
:where(ul) {
|
||||
list-style-type: disc;
|
||||
}
|
||||
|
||||
:where(ol) {
|
||||
list-style-type: decimal;
|
||||
}
|
||||
:where(ol) {
|
||||
list-style-type: decimal;
|
||||
}
|
||||
|
||||
:where(li) {
|
||||
margin-inline-start: 1rem;
|
||||
margin-top: var(--list-margin);
|
||||
margin-bottom: var(--list-margin);
|
||||
list-style-position: outside;
|
||||
}
|
||||
:where(li) {
|
||||
margin-inline-start: 1rem;
|
||||
margin-top: var(--list-margin);
|
||||
margin-bottom: var(--list-margin);
|
||||
list-style-position: outside;
|
||||
}
|
||||
|
||||
:where(li ul li, li ol li) {
|
||||
margin-block: var(--list-margin);
|
||||
margin-inline: 1rem;
|
||||
}
|
||||
:where(li ul li, li ol li) {
|
||||
margin-block: var(--list-margin);
|
||||
margin-inline: 1rem;
|
||||
}
|
||||
|
||||
:where(mark) {
|
||||
color: var(--page-background-color);
|
||||
background-color: var(--page-primary-color);
|
||||
}
|
||||
:where(mark) {
|
||||
color: var(--page-background-color);
|
||||
background-color: var(--page-primary-color);
|
||||
}
|
||||
|
||||
:where(table) {
|
||||
width: 100%;
|
||||
:where(table) {
|
||||
width: 100%;
|
||||
|
||||
border-top: 1px solid var(--table-border-color);
|
||||
border-left: 1px solid var(--table-border-color);
|
||||
}
|
||||
border-top: 1px solid var(--table-border-color);
|
||||
border-left: 1px solid var(--table-border-color);
|
||||
}
|
||||
|
||||
:where(th) {
|
||||
font-weight: inherit;
|
||||
text-align: start;
|
||||
}
|
||||
:where(th) {
|
||||
font-weight: inherit;
|
||||
text-align: start;
|
||||
}
|
||||
|
||||
:where(th, td) {
|
||||
padding: 0.5rem;
|
||||
border-right: 1px solid var(--table-border-color);
|
||||
border-bottom: 1px solid var(--table-border-color);
|
||||
}
|
||||
:where(th, td) {
|
||||
padding: 0.5rem;
|
||||
border-right: 1px solid var(--table-border-color);
|
||||
border-bottom: 1px solid var(--table-border-color);
|
||||
}
|
||||
|
||||
/* RTL Support: Apply RTL direction when text is right-aligned */
|
||||
p[style*="text-align: right"],
|
||||
h1[style*="text-align: right"],
|
||||
h2[style*="text-align: right"],
|
||||
h3[style*="text-align: right"],
|
||||
h4[style*="text-align: right"],
|
||||
h5[style*="text-align: right"],
|
||||
h6[style*="text-align: right"],
|
||||
li[style*="text-align: right"],
|
||||
ul[style*="text-align: right"],
|
||||
ol[style*="text-align: right"] {
|
||||
direction: rtl;
|
||||
word-wrap: break-word;
|
||||
overflow-wrap: break-word;
|
||||
white-space: normal;
|
||||
}
|
||||
/* RTL Support: Apply RTL direction when text is right-aligned */
|
||||
p[style*="text-align: right"],
|
||||
h1[style*="text-align: right"],
|
||||
h2[style*="text-align: right"],
|
||||
h3[style*="text-align: right"],
|
||||
h4[style*="text-align: right"],
|
||||
h5[style*="text-align: right"],
|
||||
h6[style*="text-align: right"],
|
||||
li[style*="text-align: right"],
|
||||
ul[style*="text-align: right"],
|
||||
ol[style*="text-align: right"] {
|
||||
direction: rtl;
|
||||
word-wrap: break-word;
|
||||
overflow-wrap: break-word;
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
/* LTR Support: Apply LTR direction when text is left-aligned */
|
||||
p[style*="text-align: left"],
|
||||
h1[style*="text-align: left"],
|
||||
h2[style*="text-align: left"],
|
||||
h3[style*="text-align: left"],
|
||||
h4[style*="text-align: left"],
|
||||
h5[style*="text-align: left"],
|
||||
h6[style*="text-align: left"],
|
||||
li[style*="text-align: left"],
|
||||
ul[style*="text-align: left"],
|
||||
ol[style*="text-align: left"] {
|
||||
direction: ltr;
|
||||
word-wrap: break-word;
|
||||
overflow-wrap: break-word;
|
||||
white-space: normal;
|
||||
}
|
||||
/* LTR Support: Apply LTR direction when text is left-aligned */
|
||||
p[style*="text-align: left"],
|
||||
h1[style*="text-align: left"],
|
||||
h2[style*="text-align: left"],
|
||||
h3[style*="text-align: left"],
|
||||
h4[style*="text-align: left"],
|
||||
h5[style*="text-align: left"],
|
||||
h6[style*="text-align: left"],
|
||||
li[style*="text-align: left"],
|
||||
ul[style*="text-align: left"],
|
||||
ol[style*="text-align: left"] {
|
||||
direction: ltr;
|
||||
word-wrap: break-word;
|
||||
overflow-wrap: break-word;
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
/* When list items are RTL, make bullets/numbers appear on the right */
|
||||
li[style*="text-align: right"] {
|
||||
text-align: right;
|
||||
}
|
||||
/* When list items are RTL, make bullets/numbers appear on the right */
|
||||
li[style*="text-align: right"] {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
/* Parent list inherits direction from its items */
|
||||
ul:has(li[style*="text-align: right"]),
|
||||
ol:has(li[style*="text-align: right"]) {
|
||||
direction: rtl;
|
||||
}
|
||||
/* Parent list inherits direction from its items */
|
||||
ul:has(li[style*="text-align: right"]),
|
||||
ol:has(li[style*="text-align: right"]) {
|
||||
direction: rtl;
|
||||
}
|
||||
|
||||
ul:has(li[style*="text-align: left"]),
|
||||
ol:has(li[style*="text-align: left"]) {
|
||||
direction: ltr;
|
||||
}
|
||||
ul:has(li[style*="text-align: left"]),
|
||||
ol:has(li[style*="text-align: left"]) {
|
||||
direction: ltr;
|
||||
}
|
||||
}
|
||||
|
||||
.editor_content {
|
||||
font-family: var(--page-body-font-family);
|
||||
font-size: calc(var(--page-body-font-size) * 1pt);
|
||||
font-weight: var(--page-body-font-weight);
|
||||
line-height: var(--page-body-line-height);
|
||||
color: var(--page-text-color);
|
||||
word-wrap: break-word;
|
||||
overflow-wrap: break-word;
|
||||
font-family: var(--page-body-font-family);
|
||||
font-size: calc(var(--page-body-font-size) * 1pt);
|
||||
font-weight: var(--page-body-font-weight);
|
||||
line-height: var(--page-body-line-height);
|
||||
color: var(--page-text-color);
|
||||
word-wrap: break-word;
|
||||
overflow-wrap: break-word;
|
||||
|
||||
[class*="dark"] & {
|
||||
--table-border-color: var(--color-foreground);
|
||||
color: var(--color-foreground);
|
||||
}
|
||||
[class*="dark"] & {
|
||||
--table-border-color: var(--color-foreground);
|
||||
color: var(--color-foreground);
|
||||
}
|
||||
|
||||
h1,
|
||||
h2,
|
||||
h3,
|
||||
h4,
|
||||
h5,
|
||||
h6 {
|
||||
font-family: var(--page-heading-font-family);
|
||||
font-weight: var(--page-heading-font-weight);
|
||||
line-height: var(--page-heading-line-height);
|
||||
}
|
||||
h1,
|
||||
h2,
|
||||
h3,
|
||||
h4,
|
||||
h5,
|
||||
h6 {
|
||||
font-family: var(--page-heading-font-family);
|
||||
font-weight: var(--page-heading-font-weight);
|
||||
line-height: var(--page-heading-line-height);
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: calc(var(--page-heading-font-size) * 1.5pt);
|
||||
}
|
||||
h1 {
|
||||
font-size: calc(var(--page-heading-font-size) * 1.5pt);
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-size: calc(var(--page-heading-font-size) * 1.25pt);
|
||||
}
|
||||
h2 {
|
||||
font-size: calc(var(--page-heading-font-size) * 1.25pt);
|
||||
}
|
||||
|
||||
h3 {
|
||||
font-size: calc(var(--page-heading-font-size) * 1.125pt);
|
||||
}
|
||||
h3 {
|
||||
font-size: calc(var(--page-heading-font-size) * 1.125pt);
|
||||
}
|
||||
|
||||
h4 {
|
||||
font-size: calc(var(--page-heading-font-size) * 1pt);
|
||||
}
|
||||
h4 {
|
||||
font-size: calc(var(--page-heading-font-size) * 1pt);
|
||||
}
|
||||
|
||||
h5 {
|
||||
font-size: calc(var(--page-heading-font-size) * 0.875pt);
|
||||
}
|
||||
h5 {
|
||||
font-size: calc(var(--page-heading-font-size) * 0.875pt);
|
||||
}
|
||||
|
||||
h6 {
|
||||
font-size: calc(var(--page-heading-font-size) * 0.75pt);
|
||||
}
|
||||
h6 {
|
||||
font-size: calc(var(--page-heading-font-size) * 0.75pt);
|
||||
}
|
||||
|
||||
strong {
|
||||
font-weight: var(--page-body-font-weight-bold, bold);
|
||||
}
|
||||
strong {
|
||||
font-weight: var(--page-body-font-weight-bold, bold);
|
||||
}
|
||||
|
||||
a {
|
||||
font-weight: var(--page-body-font-weight-bold, bold);
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 0.15rem;
|
||||
}
|
||||
a {
|
||||
font-weight: var(--page-body-font-weight-bold, bold);
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 0.15rem;
|
||||
}
|
||||
}
|
||||
|
||||
+627
-627
File diff suppressed because it is too large
Load Diff
@@ -17,75 +17,75 @@ import { Popover, PopoverContent, PopoverTrigger } from "../ui/popover";
|
||||
const PREFIX = "https://";
|
||||
|
||||
function stripPrefix(url: string) {
|
||||
return url.startsWith(PREFIX) ? url.slice(PREFIX.length) : url;
|
||||
return url.startsWith(PREFIX) ? url.slice(PREFIX.length) : url;
|
||||
}
|
||||
|
||||
function ensurePrefix(url: string) {
|
||||
if (url === "") return "";
|
||||
return url.startsWith(PREFIX) ? url : PREFIX + url;
|
||||
if (url === "") return "";
|
||||
return url.startsWith(PREFIX) ? url : PREFIX + url;
|
||||
}
|
||||
|
||||
type Props = Omit<React.ComponentProps<"input">, "value" | "onChange"> & {
|
||||
value: z.infer<typeof urlSchema>;
|
||||
onChange: (value: z.infer<typeof urlSchema>) => void;
|
||||
hideLabelButton?: boolean;
|
||||
value: z.infer<typeof urlSchema>;
|
||||
onChange: (value: z.infer<typeof urlSchema>) => void;
|
||||
hideLabelButton?: boolean;
|
||||
};
|
||||
|
||||
export function URLInput({ value, onChange, hideLabelButton, ...props }: Props) {
|
||||
const handleUrlChange = useCallback(
|
||||
(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
onChange({
|
||||
url: ensurePrefix(e.target.value),
|
||||
label: value.label,
|
||||
});
|
||||
},
|
||||
[onChange, value.label],
|
||||
);
|
||||
const handleUrlChange = useCallback(
|
||||
(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
onChange({
|
||||
url: ensurePrefix(e.target.value),
|
||||
label: value.label,
|
||||
});
|
||||
},
|
||||
[onChange, value.label],
|
||||
);
|
||||
|
||||
const handleLabelChange = useCallback(
|
||||
(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
onChange({ url: value.url, label: e.target.value });
|
||||
},
|
||||
[onChange, value.url],
|
||||
);
|
||||
const handleLabelChange = useCallback(
|
||||
(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
onChange({ url: value.url, label: e.target.value });
|
||||
},
|
||||
[onChange, value.url],
|
||||
);
|
||||
|
||||
const urlValue = useMemo(() => stripPrefix(value.url), [value.url]);
|
||||
const urlValue = useMemo(() => stripPrefix(value.url), [value.url]);
|
||||
|
||||
return (
|
||||
<InputGroup>
|
||||
<InputGroupAddon align="inline-start">
|
||||
<InputGroupText>{PREFIX}</InputGroupText>
|
||||
</InputGroupAddon>
|
||||
return (
|
||||
<InputGroup>
|
||||
<InputGroupAddon align="inline-start">
|
||||
<InputGroupText>{PREFIX}</InputGroupText>
|
||||
</InputGroupAddon>
|
||||
|
||||
<InputGroupInput
|
||||
value={urlValue}
|
||||
className={cn(props.className, "ps-0!")}
|
||||
onChange={handleUrlChange}
|
||||
{...props}
|
||||
/>
|
||||
<InputGroupInput
|
||||
value={urlValue}
|
||||
className={cn(props.className, "ps-0!")}
|
||||
onChange={handleUrlChange}
|
||||
{...props}
|
||||
/>
|
||||
|
||||
{!hideLabelButton && (
|
||||
<InputGroupAddon align="inline-end">
|
||||
<Popover>
|
||||
<PopoverTrigger
|
||||
render={
|
||||
<InputGroupButton size="icon-sm" title={t`Add a label to the URL`}>
|
||||
<TagIcon />
|
||||
</InputGroupButton>
|
||||
}
|
||||
/>
|
||||
{!hideLabelButton && (
|
||||
<InputGroupAddon align="inline-end">
|
||||
<Popover>
|
||||
<PopoverTrigger
|
||||
render={
|
||||
<InputGroupButton size="icon-sm" title={t`Add a label to the URL`}>
|
||||
<TagIcon />
|
||||
</InputGroupButton>
|
||||
}
|
||||
/>
|
||||
|
||||
<PopoverContent className="pt-3">
|
||||
<div className="grid gap-2" onClick={(e) => e.stopPropagation()}>
|
||||
<Label htmlFor="url-label">
|
||||
<Trans>Label</Trans>
|
||||
</Label>
|
||||
<Input id="url-label" name="url-label" value={value.label} onChange={handleLabelChange} />
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</InputGroupAddon>
|
||||
)}
|
||||
</InputGroup>
|
||||
);
|
||||
<PopoverContent className="pt-3">
|
||||
<div className="grid gap-2" onClick={(e) => e.stopPropagation()}>
|
||||
<Label htmlFor="url-label">
|
||||
<Trans>Label</Trans>
|
||||
</Label>
|
||||
<Input id="url-label" name="url-label" value={value.label} onChange={handleLabelChange} />
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</InputGroupAddon>
|
||||
)}
|
||||
</InputGroup>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,136 +3,136 @@ import * as React from "react";
|
||||
type InputValue = string[] | string;
|
||||
|
||||
interface VisuallyHiddenInputProps<T = InputValue> extends Omit<
|
||||
React.InputHTMLAttributes<HTMLInputElement>,
|
||||
"value" | "checked" | "onReset"
|
||||
React.InputHTMLAttributes<HTMLInputElement>,
|
||||
"value" | "checked" | "onReset"
|
||||
> {
|
||||
value?: T;
|
||||
checked?: boolean;
|
||||
control: HTMLElement | null;
|
||||
bubbles?: boolean;
|
||||
value?: T;
|
||||
checked?: boolean;
|
||||
control: HTMLElement | null;
|
||||
bubbles?: boolean;
|
||||
}
|
||||
|
||||
export function VisuallyHiddenInput<T = InputValue>(props: VisuallyHiddenInputProps<T>) {
|
||||
const { control, value, checked, bubbles = true, type = "hidden", style, ...inputProps } = props;
|
||||
const { control, value, checked, bubbles = true, type = "hidden", style, ...inputProps } = props;
|
||||
|
||||
const isCheckInput = React.useMemo(() => type === "checkbox" || type === "radio" || type === "switch", [type]);
|
||||
const inputRef = React.useRef<HTMLInputElement>(null);
|
||||
const isCheckInput = React.useMemo(() => type === "checkbox" || type === "radio" || type === "switch", [type]);
|
||||
const inputRef = React.useRef<HTMLInputElement>(null);
|
||||
|
||||
const prevValueRef = React.useRef<{
|
||||
value: T | boolean | undefined;
|
||||
previous: T | boolean | undefined;
|
||||
}>({
|
||||
value: isCheckInput ? checked : value,
|
||||
previous: isCheckInput ? checked : value,
|
||||
});
|
||||
const prevValueRef = React.useRef<{
|
||||
value: T | boolean | undefined;
|
||||
previous: T | boolean | undefined;
|
||||
}>({
|
||||
value: isCheckInput ? checked : value,
|
||||
previous: isCheckInput ? checked : value,
|
||||
});
|
||||
|
||||
const prevValue = React.useMemo(() => {
|
||||
const currentValue = isCheckInput ? checked : value;
|
||||
if (prevValueRef.current.value !== currentValue) {
|
||||
prevValueRef.current.previous = prevValueRef.current.value;
|
||||
prevValueRef.current.value = currentValue;
|
||||
}
|
||||
return prevValueRef.current.previous;
|
||||
}, [isCheckInput, value, checked]);
|
||||
const prevValue = React.useMemo(() => {
|
||||
const currentValue = isCheckInput ? checked : value;
|
||||
if (prevValueRef.current.value !== currentValue) {
|
||||
prevValueRef.current.previous = prevValueRef.current.value;
|
||||
prevValueRef.current.value = currentValue;
|
||||
}
|
||||
return prevValueRef.current.previous;
|
||||
}, [isCheckInput, value, checked]);
|
||||
|
||||
const [controlSize, setControlSize] = React.useState<{
|
||||
width?: number;
|
||||
height?: number;
|
||||
}>({});
|
||||
const [controlSize, setControlSize] = React.useState<{
|
||||
width?: number;
|
||||
height?: number;
|
||||
}>({});
|
||||
|
||||
React.useLayoutEffect(() => {
|
||||
if (!control) {
|
||||
setControlSize({});
|
||||
return;
|
||||
}
|
||||
React.useLayoutEffect(() => {
|
||||
if (!control) {
|
||||
setControlSize({});
|
||||
return;
|
||||
}
|
||||
|
||||
setControlSize({
|
||||
width: control.offsetWidth,
|
||||
height: control.offsetHeight,
|
||||
});
|
||||
setControlSize({
|
||||
width: control.offsetWidth,
|
||||
height: control.offsetHeight,
|
||||
});
|
||||
|
||||
if (typeof window === "undefined") return;
|
||||
if (typeof window === "undefined") return;
|
||||
|
||||
const resizeObserver = new ResizeObserver((entries) => {
|
||||
if (!Array.isArray(entries) || !entries.length) return;
|
||||
const resizeObserver = new ResizeObserver((entries) => {
|
||||
if (!Array.isArray(entries) || !entries.length) return;
|
||||
|
||||
const entry = entries[0];
|
||||
if (!entry) return;
|
||||
const entry = entries[0];
|
||||
if (!entry) return;
|
||||
|
||||
let width: number;
|
||||
let height: number;
|
||||
let width: number;
|
||||
let height: number;
|
||||
|
||||
if ("borderBoxSize" in entry) {
|
||||
const borderSizeEntry = entry.borderBoxSize;
|
||||
const borderSize = Array.isArray(borderSizeEntry) ? borderSizeEntry[0] : borderSizeEntry;
|
||||
width = borderSize.inlineSize;
|
||||
height = borderSize.blockSize;
|
||||
} else {
|
||||
width = control.offsetWidth;
|
||||
height = control.offsetHeight;
|
||||
}
|
||||
if ("borderBoxSize" in entry) {
|
||||
const borderSizeEntry = entry.borderBoxSize;
|
||||
const borderSize = Array.isArray(borderSizeEntry) ? borderSizeEntry[0] : borderSizeEntry;
|
||||
width = borderSize.inlineSize;
|
||||
height = borderSize.blockSize;
|
||||
} else {
|
||||
width = control.offsetWidth;
|
||||
height = control.offsetHeight;
|
||||
}
|
||||
|
||||
setControlSize({ width, height });
|
||||
});
|
||||
setControlSize({ width, height });
|
||||
});
|
||||
|
||||
resizeObserver.observe(control, { box: "border-box" });
|
||||
resizeObserver.observe(control, { box: "border-box" });
|
||||
|
||||
return () => {
|
||||
resizeObserver.disconnect();
|
||||
};
|
||||
}, [control]);
|
||||
return () => {
|
||||
resizeObserver.disconnect();
|
||||
};
|
||||
}, [control]);
|
||||
|
||||
React.useEffect(() => {
|
||||
const input = inputRef.current;
|
||||
if (!input) return;
|
||||
React.useEffect(() => {
|
||||
const input = inputRef.current;
|
||||
if (!input) return;
|
||||
|
||||
const inputProto = window.HTMLInputElement.prototype;
|
||||
const propertyKey = isCheckInput ? "checked" : "value";
|
||||
const eventType = isCheckInput ? "click" : "input";
|
||||
const currentValue = isCheckInput ? checked : value;
|
||||
const inputProto = window.HTMLInputElement.prototype;
|
||||
const propertyKey = isCheckInput ? "checked" : "value";
|
||||
const eventType = isCheckInput ? "click" : "input";
|
||||
const currentValue = isCheckInput ? checked : value;
|
||||
|
||||
const serializedCurrentValue = isCheckInput
|
||||
? checked
|
||||
: typeof value === "object" && value !== null
|
||||
? JSON.stringify(value)
|
||||
: value;
|
||||
const serializedCurrentValue = isCheckInput
|
||||
? checked
|
||||
: typeof value === "object" && value !== null
|
||||
? JSON.stringify(value)
|
||||
: value;
|
||||
|
||||
const descriptor = Object.getOwnPropertyDescriptor(inputProto, propertyKey);
|
||||
const setter = descriptor?.set?.bind(input);
|
||||
const descriptor = Object.getOwnPropertyDescriptor(inputProto, propertyKey);
|
||||
const setter = descriptor?.set?.bind(input);
|
||||
|
||||
if (prevValue !== currentValue && setter) {
|
||||
const event = new Event(eventType, { bubbles });
|
||||
setter.call(input, serializedCurrentValue);
|
||||
input.dispatchEvent(event);
|
||||
}
|
||||
}, [prevValue, value, checked, bubbles, isCheckInput]);
|
||||
if (prevValue !== currentValue && setter) {
|
||||
const event = new Event(eventType, { bubbles });
|
||||
setter.call(input, serializedCurrentValue);
|
||||
input.dispatchEvent(event);
|
||||
}
|
||||
}, [prevValue, value, checked, bubbles, isCheckInput]);
|
||||
|
||||
const composedStyle = React.useMemo<React.CSSProperties>(() => {
|
||||
return {
|
||||
...style,
|
||||
...(controlSize.width !== undefined && controlSize.height !== undefined ? controlSize : {}),
|
||||
border: 0,
|
||||
clip: "rect(0 0 0 0)",
|
||||
clipPath: "inset(50%)",
|
||||
height: "1px",
|
||||
margin: "-1px",
|
||||
overflow: "hidden",
|
||||
padding: 0,
|
||||
position: "absolute",
|
||||
whiteSpace: "nowrap",
|
||||
width: "1px",
|
||||
};
|
||||
}, [style, controlSize]);
|
||||
const composedStyle = React.useMemo<React.CSSProperties>(() => {
|
||||
return {
|
||||
...style,
|
||||
...(controlSize.width !== undefined && controlSize.height !== undefined ? controlSize : {}),
|
||||
border: 0,
|
||||
clip: "rect(0 0 0 0)",
|
||||
clipPath: "inset(50%)",
|
||||
height: "1px",
|
||||
margin: "-1px",
|
||||
overflow: "hidden",
|
||||
padding: 0,
|
||||
position: "absolute",
|
||||
whiteSpace: "nowrap",
|
||||
width: "1px",
|
||||
};
|
||||
}, [style, controlSize]);
|
||||
|
||||
return (
|
||||
<input
|
||||
type={type}
|
||||
{...inputProps}
|
||||
ref={inputRef}
|
||||
aria-hidden={isCheckInput}
|
||||
tabIndex={-1}
|
||||
defaultChecked={isCheckInput ? checked : undefined}
|
||||
style={composedStyle}
|
||||
/>
|
||||
);
|
||||
return (
|
||||
<input
|
||||
type={type}
|
||||
{...inputProps}
|
||||
ref={inputRef}
|
||||
aria-hidden={isCheckInput}
|
||||
tabIndex={-1}
|
||||
defaultChecked={isCheckInput ? checked : undefined}
|
||||
style={composedStyle}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,38 +1,38 @@
|
||||
import { cn } from "@/utils/style";
|
||||
|
||||
type BreakpointIndicatorProps = {
|
||||
position?: "top-right" | "bottom-right" | "top-left" | "bottom-left";
|
||||
position?: "top-right" | "bottom-right" | "top-left" | "bottom-left";
|
||||
};
|
||||
|
||||
export function BreakpointIndicator({ position = "bottom-right" }: BreakpointIndicatorProps) {
|
||||
const isTop = position.includes("top");
|
||||
const isRight = position.includes("right");
|
||||
const isLeft = position.includes("left");
|
||||
const isBottom = position.includes("bottom");
|
||||
const isTop = position.includes("top");
|
||||
const isRight = position.includes("right");
|
||||
const isLeft = position.includes("left");
|
||||
const isBottom = position.includes("bottom");
|
||||
|
||||
const top = isTop ? "top-0" : "bottom-0";
|
||||
const right = isRight ? "inset-e-0" : "inset-s-0";
|
||||
const left = isLeft ? "inset-s-0" : "inset-e-0";
|
||||
const bottom = isBottom ? "bottom-0" : "top-0";
|
||||
const top = isTop ? "top-0" : "bottom-0";
|
||||
const right = isRight ? "inset-e-0" : "inset-s-0";
|
||||
const left = isLeft ? "inset-s-0" : "inset-e-0";
|
||||
const bottom = isBottom ? "bottom-0" : "top-0";
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"fixed z-50 flex size-10 items-center justify-center bg-blue-900 p-2 font-mono text-xs font-bold text-white opacity-80 transition-opacity hover:opacity-40 print:hidden",
|
||||
top,
|
||||
right,
|
||||
left,
|
||||
bottom,
|
||||
)}
|
||||
>
|
||||
<span className="inline sm:hidden">XS</span>
|
||||
<span className="hidden sm:inline md:hidden">SM</span>
|
||||
<span className="hidden md:inline lg:hidden">MD</span>
|
||||
<span className="hidden lg:inline xl:hidden">LG</span>
|
||||
<span className="hidden xl:inline 2xl:hidden">XL</span>
|
||||
<span className="3xl:hidden hidden 2xl:inline">2XL</span>
|
||||
<span className="3xl:inline 4xl:hidden hidden">3XL</span>
|
||||
<span className="4xl:inline hidden">4XL</span>
|
||||
</div>
|
||||
);
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"fixed z-50 flex size-10 items-center justify-center bg-blue-900 p-2 font-mono text-xs font-bold text-white opacity-80 transition-opacity hover:opacity-40 print:hidden",
|
||||
top,
|
||||
right,
|
||||
left,
|
||||
bottom,
|
||||
)}
|
||||
>
|
||||
<span className="inline sm:hidden">XS</span>
|
||||
<span className="hidden sm:inline md:hidden">SM</span>
|
||||
<span className="hidden md:inline lg:hidden">MD</span>
|
||||
<span className="hidden lg:inline xl:hidden">LG</span>
|
||||
<span className="hidden xl:inline 2xl:hidden">XL</span>
|
||||
<span className="3xl:hidden hidden 2xl:inline">2XL</span>
|
||||
<span className="3xl:inline 4xl:hidden hidden">3XL</span>
|
||||
<span className="4xl:inline hidden">4XL</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -9,22 +9,22 @@ import { Alert, AlertDescription, AlertTitle } from "../ui/alert";
|
||||
import { BrandIcon } from "../ui/brand-icon";
|
||||
|
||||
export function ErrorScreen({ error, reset }: ErrorComponentProps) {
|
||||
return (
|
||||
<div className="mx-auto flex h-svh max-w-md flex-col items-center justify-center gap-y-4">
|
||||
<BrandIcon variant="logo" className="size-12" />
|
||||
return (
|
||||
<div className="mx-auto flex h-svh max-w-md flex-col items-center justify-center gap-y-4">
|
||||
<BrandIcon variant="logo" className="size-12" />
|
||||
|
||||
<Alert>
|
||||
<WarningIcon />
|
||||
<AlertTitle>
|
||||
<Trans>An error occurred while loading the page.</Trans>
|
||||
</AlertTitle>
|
||||
<AlertDescription>{error.message}</AlertDescription>
|
||||
</Alert>
|
||||
<Alert>
|
||||
<WarningIcon />
|
||||
<AlertTitle>
|
||||
<Trans>An error occurred while loading the page.</Trans>
|
||||
</AlertTitle>
|
||||
<AlertDescription>{error.message}</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
<Button onClick={reset}>
|
||||
<ArrowClockwiseIcon />
|
||||
<Trans>Refresh</Trans>
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
<Button onClick={reset}>
|
||||
<ArrowClockwiseIcon />
|
||||
<Trans>Refresh</Trans>
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,12 +3,12 @@ import { Trans } from "@lingui/react/macro";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
|
||||
export function LoadingScreen() {
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex h-svh w-svw items-center justify-center gap-x-3 bg-background">
|
||||
<Spinner className="size-6" />
|
||||
<p className="text-muted-foreground">
|
||||
<Trans>Loading...</Trans>
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex h-svh w-svw items-center justify-center gap-x-3 bg-background">
|
||||
<Spinner className="size-6" />
|
||||
<p className="text-muted-foreground">
|
||||
<Trans>Loading...</Trans>
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -8,24 +8,24 @@ import { Alert, AlertDescription, AlertTitle } from "../ui/alert";
|
||||
import { BrandIcon } from "../ui/brand-icon";
|
||||
|
||||
export function NotFoundScreen({ routeId }: NotFoundRouteProps) {
|
||||
return (
|
||||
<div className="mx-auto flex h-svh max-w-md flex-col items-center justify-center gap-y-4">
|
||||
<BrandIcon variant="logo" className="size-12" />
|
||||
return (
|
||||
<div className="mx-auto flex h-svh max-w-md flex-col items-center justify-center gap-y-4">
|
||||
<BrandIcon variant="logo" className="size-12" />
|
||||
|
||||
<Alert>
|
||||
<WarningIcon />
|
||||
<AlertTitle>
|
||||
<Trans>An error occurred while loading the page.</Trans>
|
||||
</AlertTitle>
|
||||
<AlertDescription>{routeId}</AlertDescription>
|
||||
</Alert>
|
||||
<Alert>
|
||||
<WarningIcon />
|
||||
<AlertTitle>
|
||||
<Trans>An error occurred while loading the page.</Trans>
|
||||
</AlertTitle>
|
||||
<AlertDescription>{routeId}</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
<Button>
|
||||
<Link to="..">
|
||||
<ArrowLeftIcon />
|
||||
<Trans>Go Back</Trans>
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
<Button>
|
||||
<Link to="..">
|
||||
<ArrowLeftIcon />
|
||||
<Trans>Go Back</Trans>
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -13,24 +13,24 @@ type LevelType = z.infer<typeof levelDesignSchema>["type"];
|
||||
type LevelTypeComboboxProps = Omit<SingleComboboxProps, "options">;
|
||||
|
||||
export const getLevelTypeName = (type: LevelType) => {
|
||||
return match(type)
|
||||
.with("hidden", () => t`Hidden`)
|
||||
.with("circle", () => t`Circle`)
|
||||
.with("square", () => t`Square`)
|
||||
.with("rectangle", () => t`Rectangle`)
|
||||
.with("rectangle-full", () => t`Rectangle (Full Width)`)
|
||||
.with("progress-bar", () => t`Progress Bar`)
|
||||
.with("icon", () => t`Icon`)
|
||||
.exhaustive();
|
||||
return match(type)
|
||||
.with("hidden", () => t`Hidden`)
|
||||
.with("circle", () => t`Circle`)
|
||||
.with("square", () => t`Square`)
|
||||
.with("rectangle", () => t`Rectangle`)
|
||||
.with("rectangle-full", () => t`Rectangle (Full Width)`)
|
||||
.with("progress-bar", () => t`Progress Bar`)
|
||||
.with("icon", () => t`Icon`)
|
||||
.exhaustive();
|
||||
};
|
||||
|
||||
export function LevelTypeCombobox({ ...props }: LevelTypeComboboxProps) {
|
||||
const options = useMemo(() => {
|
||||
return levelDesignSchema.shape.type.options.map((option) => ({
|
||||
value: option,
|
||||
label: getLevelTypeName(option),
|
||||
}));
|
||||
}, []);
|
||||
const options = useMemo(() => {
|
||||
return levelDesignSchema.shape.type.options.map((option) => ({
|
||||
value: option,
|
||||
label: getLevelTypeName(option),
|
||||
}));
|
||||
}, []);
|
||||
|
||||
return <Combobox options={options} {...props} />;
|
||||
return <Combobox options={options} {...props} />;
|
||||
}
|
||||
|
||||
@@ -11,61 +11,61 @@ import { PageIcon } from "../resume/shared/page-icon";
|
||||
type Props = z.infer<typeof levelDesignSchema> & React.ComponentProps<"div"> & { level: number };
|
||||
|
||||
export function LevelDisplay({ icon, type, level, className, ...props }: Props) {
|
||||
if (level === 0) return null;
|
||||
if (type === "hidden" || icon === "") return null;
|
||||
if (level === 0) return null;
|
||||
if (type === "hidden" || icon === "") return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
role="presentation"
|
||||
aria-label={t`Level ${level} of 5`}
|
||||
className={cn(
|
||||
"flex items-center gap-x-1.5",
|
||||
type === "progress-bar" && "gap-x-0",
|
||||
type === "rectangle-full" && "gap-x-2",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{Array.from({ length: 5 }).map((_, index) => {
|
||||
const isActive = index < level;
|
||||
return (
|
||||
<div
|
||||
role="presentation"
|
||||
aria-label={t`Level ${level} of 5`}
|
||||
className={cn(
|
||||
"flex items-center gap-x-1.5",
|
||||
type === "progress-bar" && "gap-x-0",
|
||||
type === "rectangle-full" && "gap-x-2",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{Array.from({ length: 5 }).map((_, index) => {
|
||||
const isActive = index < level;
|
||||
|
||||
if (type === "progress-bar") {
|
||||
return (
|
||||
<div
|
||||
key={index}
|
||||
data-active={isActive}
|
||||
className={cn(
|
||||
"h-2.5 flex-1 border border-x-0 border-(--page-primary-color) first:border-l last:border-r",
|
||||
isActive && "bg-(--page-primary-color)",
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (type === "progress-bar") {
|
||||
return (
|
||||
<div
|
||||
key={index}
|
||||
data-active={isActive}
|
||||
className={cn(
|
||||
"h-2.5 flex-1 border border-x-0 border-(--page-primary-color) first:border-l last:border-r",
|
||||
isActive && "bg-(--page-primary-color)",
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (type === "icon") {
|
||||
return (
|
||||
<PageIcon
|
||||
key={index}
|
||||
icon={icon}
|
||||
className={cn("h-3 overflow-visible text-(--page-primary-color)", !isActive && "opacity-40")}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (type === "icon") {
|
||||
return (
|
||||
<PageIcon
|
||||
key={index}
|
||||
icon={icon}
|
||||
className={cn("h-3 overflow-visible text-(--page-primary-color)", !isActive && "opacity-40")}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
key={index}
|
||||
data-active={isActive}
|
||||
className={cn(
|
||||
"size-2.5 border border-(--page-primary-color)",
|
||||
isActive && "bg-(--page-primary-color)",
|
||||
type === "circle" && "rounded-full",
|
||||
type === "rectangle" && "w-7",
|
||||
type === "rectangle-full" && "w-auto flex-1",
|
||||
)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
return (
|
||||
<div
|
||||
key={index}
|
||||
data-active={isActive}
|
||||
className={cn(
|
||||
"size-2.5 border border-(--page-primary-color)",
|
||||
isActive && "bg-(--page-primary-color)",
|
||||
type === "circle" && "rounded-full",
|
||||
type === "rectangle" && "w-7",
|
||||
type === "rectangle-full" && "w-auto flex-1",
|
||||
)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -8,29 +8,29 @@ import { Combobox, type SingleComboboxProps } from "../ui/combobox";
|
||||
type Props = Omit<SingleComboboxProps, "options" | "value" | "onValueChange">;
|
||||
|
||||
export const getLocaleOptions = () => {
|
||||
return Object.entries(localeMap).map(([value, label]) => ({
|
||||
value: value as Locale,
|
||||
label: i18n.t(label),
|
||||
keywords: [i18n.t(label)],
|
||||
}));
|
||||
return Object.entries(localeMap).map(([value, label]) => ({
|
||||
value: value as Locale,
|
||||
label: i18n.t(label),
|
||||
keywords: [i18n.t(label)],
|
||||
}));
|
||||
};
|
||||
|
||||
export function LocaleCombobox(props: Props) {
|
||||
const { i18n } = useLingui();
|
||||
const { i18n } = useLingui();
|
||||
|
||||
const onLocaleChange = async (value: string | null) => {
|
||||
if (!value || !isLocale(value)) return;
|
||||
await Promise.all([loadLocale(value), setLocaleServerFn({ data: value })]);
|
||||
window.location.reload();
|
||||
};
|
||||
const onLocaleChange = async (value: string | null) => {
|
||||
if (!value || !isLocale(value)) return;
|
||||
await Promise.all([loadLocale(value), setLocaleServerFn({ data: value })]);
|
||||
window.location.reload();
|
||||
};
|
||||
|
||||
return (
|
||||
<Combobox
|
||||
showClear={false}
|
||||
defaultValue={i18n.locale}
|
||||
options={getLocaleOptions()}
|
||||
onValueChange={onLocaleChange}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
return (
|
||||
<Combobox
|
||||
showClear={false}
|
||||
defaultValue={i18n.locale}
|
||||
options={getLocaleOptions()}
|
||||
onValueChange={onLocaleChange}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -9,37 +9,37 @@ import { pageDimensionsAsMillimeters } from "@/schema/page";
|
||||
type UseCssVariablesProps = Pick<z.infer<typeof resumeDataSchema>, "picture" | "metadata">;
|
||||
|
||||
export const useCSSVariables = ({ picture, metadata }: UseCssVariablesProps) => {
|
||||
const fontWeightStyles = useMemo(() => {
|
||||
const lowestBodyFontWeight = Math.min(...metadata.typography.body.fontWeights.map(Number));
|
||||
const lowestHeadingFontWeight = Math.min(...metadata.typography.heading.fontWeights.map(Number));
|
||||
const fontWeightStyles = useMemo(() => {
|
||||
const lowestBodyFontWeight = Math.min(...metadata.typography.body.fontWeights.map(Number));
|
||||
const lowestHeadingFontWeight = Math.min(...metadata.typography.heading.fontWeights.map(Number));
|
||||
|
||||
const highestBodyFontWeight = Math.max(...metadata.typography.body.fontWeights.map(Number));
|
||||
const highestHeadingFontWeight = Math.max(...metadata.typography.heading.fontWeights.map(Number));
|
||||
const highestBodyFontWeight = Math.max(...metadata.typography.body.fontWeights.map(Number));
|
||||
const highestHeadingFontWeight = Math.max(...metadata.typography.heading.fontWeights.map(Number));
|
||||
|
||||
return { lowestBodyFontWeight, lowestHeadingFontWeight, highestBodyFontWeight, highestHeadingFontWeight };
|
||||
}, [metadata.typography.body.fontWeights, metadata.typography.heading.fontWeights]);
|
||||
return { lowestBodyFontWeight, lowestHeadingFontWeight, highestBodyFontWeight, highestHeadingFontWeight };
|
||||
}, [metadata.typography.body.fontWeights, metadata.typography.heading.fontWeights]);
|
||||
|
||||
return {
|
||||
"--picture-border-radius": `${picture.borderRadius}pt`,
|
||||
"--page-width": pageDimensionsAsMillimeters[metadata.page.format].width,
|
||||
"--page-height": pageDimensionsAsMillimeters[metadata.page.format].height,
|
||||
"--page-sidebar-width": `${metadata.layout.sidebarWidth}%`,
|
||||
"--page-text-color": metadata.design.colors.text,
|
||||
"--page-primary-color": metadata.design.colors.primary,
|
||||
"--page-background-color": metadata.design.colors.background,
|
||||
"--page-body-font-family": `'${metadata.typography.body.fontFamily}', system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif`,
|
||||
"--page-body-font-weight": fontWeightStyles.lowestBodyFontWeight,
|
||||
"--page-body-font-weight-bold": fontWeightStyles.highestBodyFontWeight,
|
||||
"--page-body-font-size": metadata.typography.body.fontSize,
|
||||
"--page-body-line-height": metadata.typography.body.lineHeight,
|
||||
"--page-heading-font-family": `'${metadata.typography.heading.fontFamily}', system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif`,
|
||||
"--page-heading-font-weight": fontWeightStyles.lowestHeadingFontWeight,
|
||||
"--page-heading-font-weight-bold": fontWeightStyles.highestHeadingFontWeight,
|
||||
"--page-heading-font-size": metadata.typography.heading.fontSize,
|
||||
"--page-heading-line-height": metadata.typography.heading.lineHeight,
|
||||
"--page-margin-x": `${metadata.page.marginX}pt`,
|
||||
"--page-margin-y": `${metadata.page.marginY}pt`,
|
||||
"--page-gap-x": `${metadata.page.gapX}pt`,
|
||||
"--page-gap-y": `${metadata.page.gapY}pt`,
|
||||
} as React.CSSProperties;
|
||||
return {
|
||||
"--picture-border-radius": `${picture.borderRadius}pt`,
|
||||
"--page-width": pageDimensionsAsMillimeters[metadata.page.format].width,
|
||||
"--page-height": pageDimensionsAsMillimeters[metadata.page.format].height,
|
||||
"--page-sidebar-width": `${metadata.layout.sidebarWidth}%`,
|
||||
"--page-text-color": metadata.design.colors.text,
|
||||
"--page-primary-color": metadata.design.colors.primary,
|
||||
"--page-background-color": metadata.design.colors.background,
|
||||
"--page-body-font-family": `'${metadata.typography.body.fontFamily}', system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif`,
|
||||
"--page-body-font-weight": fontWeightStyles.lowestBodyFontWeight,
|
||||
"--page-body-font-weight-bold": fontWeightStyles.highestBodyFontWeight,
|
||||
"--page-body-font-size": metadata.typography.body.fontSize,
|
||||
"--page-body-line-height": metadata.typography.body.lineHeight,
|
||||
"--page-heading-font-family": `'${metadata.typography.heading.fontFamily}', system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif`,
|
||||
"--page-heading-font-weight": fontWeightStyles.lowestHeadingFontWeight,
|
||||
"--page-heading-font-weight-bold": fontWeightStyles.highestHeadingFontWeight,
|
||||
"--page-heading-font-size": metadata.typography.heading.fontSize,
|
||||
"--page-heading-line-height": metadata.typography.heading.lineHeight,
|
||||
"--page-margin-x": `${metadata.page.marginX}pt`,
|
||||
"--page-margin-y": `${metadata.page.marginY}pt`,
|
||||
"--page-gap-x": `${metadata.page.gapX}pt`,
|
||||
"--page-gap-y": `${metadata.page.gapY}pt`,
|
||||
} as React.CSSProperties;
|
||||
};
|
||||
|
||||
@@ -8,53 +8,53 @@ import type { typographySchema } from "@/schema/resume/data";
|
||||
import webfontlist from "@/components/typography/webfontlist.json";
|
||||
|
||||
export function useWebfonts(typography: z.infer<typeof typographySchema>) {
|
||||
const isMounted = useIsMounted();
|
||||
const isMounted = useIsMounted();
|
||||
|
||||
useEffect(() => {
|
||||
if (!isMounted()) return;
|
||||
useEffect(() => {
|
||||
if (!isMounted()) return;
|
||||
|
||||
const body = document.body;
|
||||
if (body) body.setAttribute("data-wf-loaded", "false");
|
||||
const body = document.body;
|
||||
if (body) body.setAttribute("data-wf-loaded", "false");
|
||||
|
||||
async function loadFont(family: string, weights: string[]) {
|
||||
const font = webfontlist.find((font) => font.family === family);
|
||||
if (!font) return;
|
||||
async function loadFont(family: string, weights: string[]) {
|
||||
const font = webfontlist.find((font) => font.family === family);
|
||||
if (!font) return;
|
||||
|
||||
type FontUrl = { url: string; weight: string; style: "italic" | "normal" };
|
||||
type FontUrl = { url: string; weight: string; style: "italic" | "normal" };
|
||||
|
||||
const fontUrls: FontUrl[] = [];
|
||||
const fontUrls: FontUrl[] = [];
|
||||
|
||||
for (const weight of weights) {
|
||||
for (const [fileWeight, url] of Object.entries(font.files)) {
|
||||
if (weight === fileWeight) {
|
||||
fontUrls.push({ url, weight, style: "normal" });
|
||||
}
|
||||
if (fileWeight === `${weight}italic`) {
|
||||
fontUrls.push({ url, weight, style: "italic" });
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const weight of weights) {
|
||||
for (const [fileWeight, url] of Object.entries(font.files)) {
|
||||
if (weight === fileWeight) {
|
||||
fontUrls.push({ url, weight, style: "normal" });
|
||||
}
|
||||
if (fileWeight === `${weight}italic`) {
|
||||
fontUrls.push({ url, weight, style: "italic" });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const { url, weight, style } of fontUrls) {
|
||||
const fontFace = new FontFace(family, `url("${url}")`, { style, weight, display: "swap" });
|
||||
if (!document.fonts.has(fontFace)) document.fonts.add(await fontFace.load());
|
||||
}
|
||||
}
|
||||
for (const { url, weight, style } of fontUrls) {
|
||||
const fontFace = new FontFace(family, `url("${url}")`, { style, weight, display: "swap" });
|
||||
if (!document.fonts.has(fontFace)) document.fonts.add(await fontFace.load());
|
||||
}
|
||||
}
|
||||
|
||||
const bodyTypography = typography.body;
|
||||
const headingTypography = typography.heading;
|
||||
const bodyTypography = typography.body;
|
||||
const headingTypography = typography.heading;
|
||||
|
||||
void Promise.all([
|
||||
loadFont(bodyTypography.fontFamily, bodyTypography.fontWeights),
|
||||
loadFont(headingTypography.fontFamily, headingTypography.fontWeights),
|
||||
]).then(() => {
|
||||
if (isMounted() && body) body.setAttribute("data-wf-loaded", "true");
|
||||
});
|
||||
void Promise.all([
|
||||
loadFont(bodyTypography.fontFamily, bodyTypography.fontWeights),
|
||||
loadFont(headingTypography.fontFamily, headingTypography.fontWeights),
|
||||
]).then(() => {
|
||||
if (isMounted() && body) body.setAttribute("data-wf-loaded", "true");
|
||||
});
|
||||
|
||||
return () => {
|
||||
if (isMounted()) {
|
||||
if (body) body.removeAttribute("data-wf-loaded");
|
||||
}
|
||||
};
|
||||
}, [isMounted, typography]);
|
||||
return () => {
|
||||
if (isMounted()) {
|
||||
if (body) body.removeAttribute("data-wf-loaded");
|
||||
}
|
||||
};
|
||||
}, [isMounted, typography]);
|
||||
}
|
||||
|
||||
@@ -1,118 +1,118 @@
|
||||
.page {
|
||||
width: var(--page-width);
|
||||
min-height: var(--page-height);
|
||||
font-family: var(--page-body-font-family);
|
||||
font-size: calc(var(--page-body-font-size) * 1pt);
|
||||
font-weight: var(--page-body-font-weight);
|
||||
line-height: var(--page-body-line-height);
|
||||
color: var(--page-text-color);
|
||||
background-color: var(--page-background-color);
|
||||
width: var(--page-width);
|
||||
min-height: var(--page-height);
|
||||
font-family: var(--page-body-font-family);
|
||||
font-size: calc(var(--page-body-font-size) * 1pt);
|
||||
font-weight: var(--page-body-font-weight);
|
||||
line-height: var(--page-body-line-height);
|
||||
color: var(--page-text-color);
|
||||
background-color: var(--page-background-color);
|
||||
|
||||
&:not(:first-child) {
|
||||
content-visibility: auto;
|
||||
contain-intrinsic-size: var(--page-width) var(--page-height);
|
||||
}
|
||||
&:not(:first-child) {
|
||||
content-visibility: auto;
|
||||
contain-intrinsic-size: var(--page-width) var(--page-height);
|
||||
}
|
||||
|
||||
h1,
|
||||
h2,
|
||||
h3,
|
||||
h4,
|
||||
h5,
|
||||
h6 {
|
||||
font-family: var(--page-heading-font-family);
|
||||
font-weight: var(--page-heading-font-weight);
|
||||
line-height: var(--page-heading-line-height);
|
||||
}
|
||||
h1,
|
||||
h2,
|
||||
h3,
|
||||
h4,
|
||||
h5,
|
||||
h6 {
|
||||
font-family: var(--page-heading-font-family);
|
||||
font-weight: var(--page-heading-font-weight);
|
||||
line-height: var(--page-heading-line-height);
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: calc(var(--page-heading-font-size) * 1.5pt);
|
||||
}
|
||||
h1 {
|
||||
font-size: calc(var(--page-heading-font-size) * 1.5pt);
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-size: calc(var(--page-heading-font-size) * 1.25pt);
|
||||
}
|
||||
h2 {
|
||||
font-size: calc(var(--page-heading-font-size) * 1.25pt);
|
||||
}
|
||||
|
||||
h3 {
|
||||
font-size: calc(var(--page-heading-font-size) * 1.125pt);
|
||||
}
|
||||
h3 {
|
||||
font-size: calc(var(--page-heading-font-size) * 1.125pt);
|
||||
}
|
||||
|
||||
h4 {
|
||||
font-size: calc(var(--page-heading-font-size) * 1pt);
|
||||
}
|
||||
h4 {
|
||||
font-size: calc(var(--page-heading-font-size) * 1pt);
|
||||
}
|
||||
|
||||
h5 {
|
||||
font-size: calc(var(--page-heading-font-size) * 0.875pt);
|
||||
}
|
||||
h5 {
|
||||
font-size: calc(var(--page-heading-font-size) * 0.875pt);
|
||||
}
|
||||
|
||||
h6 {
|
||||
font-size: calc(var(--page-heading-font-size) * 0.75pt);
|
||||
}
|
||||
h6 {
|
||||
font-size: calc(var(--page-heading-font-size) * 0.75pt);
|
||||
}
|
||||
|
||||
a {
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 0.15rem;
|
||||
}
|
||||
a {
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 0.15rem;
|
||||
}
|
||||
|
||||
hr {
|
||||
margin: 1rem 0;
|
||||
border-color: var(--page-text-color);
|
||||
}
|
||||
hr {
|
||||
margin: 1rem 0;
|
||||
border-color: var(--page-text-color);
|
||||
}
|
||||
|
||||
strong {
|
||||
font-weight: var(--page-body-font-weight-bold);
|
||||
}
|
||||
strong {
|
||||
font-weight: var(--page-body-font-weight-bold);
|
||||
}
|
||||
|
||||
small {
|
||||
font-size: calc(var(--page-body-font-size) * 0.9pt);
|
||||
}
|
||||
small {
|
||||
font-size: calc(var(--page-body-font-size) * 0.9pt);
|
||||
}
|
||||
|
||||
/* RTL Support: Apply RTL direction when text is right-aligned */
|
||||
p[style*="text-align: right"],
|
||||
h1[style*="text-align: right"],
|
||||
h2[style*="text-align: right"],
|
||||
h3[style*="text-align: right"],
|
||||
h4[style*="text-align: right"],
|
||||
h5[style*="text-align: right"],
|
||||
h6[style*="text-align: right"],
|
||||
li[style*="text-align: right"],
|
||||
ul[style*="text-align: right"],
|
||||
ol[style*="text-align: right"] {
|
||||
direction: rtl;
|
||||
word-wrap: break-word;
|
||||
overflow-wrap: break-word;
|
||||
white-space: normal;
|
||||
}
|
||||
/* RTL Support: Apply RTL direction when text is right-aligned */
|
||||
p[style*="text-align: right"],
|
||||
h1[style*="text-align: right"],
|
||||
h2[style*="text-align: right"],
|
||||
h3[style*="text-align: right"],
|
||||
h4[style*="text-align: right"],
|
||||
h5[style*="text-align: right"],
|
||||
h6[style*="text-align: right"],
|
||||
li[style*="text-align: right"],
|
||||
ul[style*="text-align: right"],
|
||||
ol[style*="text-align: right"] {
|
||||
direction: rtl;
|
||||
word-wrap: break-word;
|
||||
overflow-wrap: break-word;
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
/* LTR Support: Apply LTR direction when text is left-aligned */
|
||||
p[style*="text-align: left"],
|
||||
h1[style*="text-align: left"],
|
||||
h2[style*="text-align: left"],
|
||||
h3[style*="text-align: left"],
|
||||
h4[style*="text-align: left"],
|
||||
h5[style*="text-align: left"],
|
||||
h6[style*="text-align: left"],
|
||||
li[style*="text-align: left"],
|
||||
ul[style*="text-align: left"],
|
||||
ol[style*="text-align: left"] {
|
||||
direction: ltr;
|
||||
word-wrap: break-word;
|
||||
overflow-wrap: break-word;
|
||||
white-space: normal;
|
||||
}
|
||||
/* LTR Support: Apply LTR direction when text is left-aligned */
|
||||
p[style*="text-align: left"],
|
||||
h1[style*="text-align: left"],
|
||||
h2[style*="text-align: left"],
|
||||
h3[style*="text-align: left"],
|
||||
h4[style*="text-align: left"],
|
||||
h5[style*="text-align: left"],
|
||||
h6[style*="text-align: left"],
|
||||
li[style*="text-align: left"],
|
||||
ul[style*="text-align: left"],
|
||||
ol[style*="text-align: left"] {
|
||||
direction: ltr;
|
||||
word-wrap: break-word;
|
||||
overflow-wrap: break-word;
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
/* When list items are RTL, make bullets/numbers appear on the right */
|
||||
li[style*="text-align: right"] {
|
||||
text-align: right;
|
||||
}
|
||||
/* When list items are RTL, make bullets/numbers appear on the right */
|
||||
li[style*="text-align: right"] {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
/* Parent list inherits direction from its items */
|
||||
ul:has(li[style*="text-align: right"]),
|
||||
ol:has(li[style*="text-align: right"]) {
|
||||
direction: rtl;
|
||||
}
|
||||
/* Parent list inherits direction from its items */
|
||||
ul:has(li[style*="text-align: right"]),
|
||||
ol:has(li[style*="text-align: right"]) {
|
||||
direction: rtl;
|
||||
}
|
||||
|
||||
ul:has(li[style*="text-align: left"]),
|
||||
ol:has(li[style*="text-align: left"]) {
|
||||
direction: ltr;
|
||||
}
|
||||
ul:has(li[style*="text-align: left"]),
|
||||
ol:has(li[style*="text-align: left"]) {
|
||||
direction: ltr;
|
||||
}
|
||||
}
|
||||
|
||||
+122
-122
@@ -33,159 +33,159 @@ import { PikachuTemplate } from "./templates/pikachu";
|
||||
import { RhyhornTemplate } from "./templates/rhyhorn";
|
||||
|
||||
export type ExtendedIconProps = IconProps & {
|
||||
hidden?: boolean;
|
||||
hidden?: boolean;
|
||||
};
|
||||
|
||||
const CSS_RULE_SPLIT_PATTERN = /\n(?=\s*[.#a-zA-Z])/;
|
||||
const CSS_SELECTOR_PATTERN = /^([^{]+)(\{)/;
|
||||
|
||||
function getTemplateComponent(template: Template) {
|
||||
return match(template)
|
||||
.with("azurill", () => AzurillTemplate)
|
||||
.with("bronzor", () => BronzorTemplate)
|
||||
.with("chikorita", () => ChikoritaTemplate)
|
||||
.with("ditto", () => DittoTemplate)
|
||||
.with("ditgar", () => DitgarTemplate)
|
||||
.with("gengar", () => GengarTemplate)
|
||||
.with("glalie", () => GlalieTemplate)
|
||||
.with("kakuna", () => KakunaTemplate)
|
||||
.with("lapras", () => LaprasTemplate)
|
||||
.with("leafish", () => LeafishTemplate)
|
||||
.with("onyx", () => OnyxTemplate)
|
||||
.with("pikachu", () => PikachuTemplate)
|
||||
.with("rhyhorn", () => RhyhornTemplate)
|
||||
.exhaustive();
|
||||
return match(template)
|
||||
.with("azurill", () => AzurillTemplate)
|
||||
.with("bronzor", () => BronzorTemplate)
|
||||
.with("chikorita", () => ChikoritaTemplate)
|
||||
.with("ditto", () => DittoTemplate)
|
||||
.with("ditgar", () => DitgarTemplate)
|
||||
.with("gengar", () => GengarTemplate)
|
||||
.with("glalie", () => GlalieTemplate)
|
||||
.with("kakuna", () => KakunaTemplate)
|
||||
.with("lapras", () => LaprasTemplate)
|
||||
.with("leafish", () => LeafishTemplate)
|
||||
.with("onyx", () => OnyxTemplate)
|
||||
.with("pikachu", () => PikachuTemplate)
|
||||
.with("rhyhorn", () => RhyhornTemplate)
|
||||
.exhaustive();
|
||||
}
|
||||
|
||||
type Props = React.ComponentProps<"div"> & {
|
||||
pageClassName?: string;
|
||||
showPageNumbers?: boolean;
|
||||
pageClassName?: string;
|
||||
showPageNumbers?: boolean;
|
||||
};
|
||||
|
||||
export const ResumePreview = ({ showPageNumbers = false, pageClassName, className, ...props }: Props) => {
|
||||
const picture = useResumeStore((state) => state.resume.data.picture);
|
||||
const metadata = useResumeStore((state) => state.resume.data.metadata);
|
||||
const picture = useResumeStore((state) => state.resume.data.picture);
|
||||
const metadata = useResumeStore((state) => state.resume.data.metadata);
|
||||
|
||||
useWebfonts(metadata.typography);
|
||||
const style = useCSSVariables({ picture, metadata });
|
||||
useWebfonts(metadata.typography);
|
||||
const style = useCSSVariables({ picture, metadata });
|
||||
|
||||
const iconProps = useMemo<ExtendedIconProps>(() => {
|
||||
return {
|
||||
weight: "regular",
|
||||
hidden: metadata.page.hideIcons,
|
||||
color: "var(--page-primary-color)",
|
||||
size: metadata.typography.body.fontSize * 1.5,
|
||||
} satisfies ExtendedIconProps;
|
||||
}, [metadata.typography.body.fontSize, metadata.page.hideIcons]);
|
||||
const iconProps = useMemo<ExtendedIconProps>(() => {
|
||||
return {
|
||||
weight: "regular",
|
||||
hidden: metadata.page.hideIcons,
|
||||
color: "var(--page-primary-color)",
|
||||
size: metadata.typography.body.fontSize * 1.5,
|
||||
} satisfies ExtendedIconProps;
|
||||
}, [metadata.typography.body.fontSize, metadata.page.hideIcons]);
|
||||
|
||||
const scopedCSS = useMemo(() => {
|
||||
if (!metadata.css.enabled || !metadata.css.value.trim()) return null;
|
||||
const scopedCSS = useMemo(() => {
|
||||
if (!metadata.css.enabled || !metadata.css.value.trim()) return null;
|
||||
|
||||
const sanitizedCss = sanitizeCss(metadata.css.value);
|
||||
const sanitizedCss = sanitizeCss(metadata.css.value);
|
||||
|
||||
const scoped = sanitizedCss
|
||||
.split(CSS_RULE_SPLIT_PATTERN)
|
||||
.map((rule) => {
|
||||
const trimmed = rule.trim();
|
||||
if (!trimmed || trimmed.startsWith("@")) return trimmed;
|
||||
const scoped = sanitizedCss
|
||||
.split(CSS_RULE_SPLIT_PATTERN)
|
||||
.map((rule) => {
|
||||
const trimmed = rule.trim();
|
||||
if (!trimmed || trimmed.startsWith("@")) return trimmed;
|
||||
|
||||
return trimmed.replace(CSS_SELECTOR_PATTERN, (_match, selectors, brace) => {
|
||||
const prefixed = selectors
|
||||
.split(",")
|
||||
.map((selector: string) => `.resume-preview-container ${selector.trim()} `)
|
||||
.join(", ");
|
||||
return `${prefixed}${brace}`;
|
||||
});
|
||||
})
|
||||
.join("\n");
|
||||
return trimmed.replace(CSS_SELECTOR_PATTERN, (_match, selectors, brace) => {
|
||||
const prefixed = selectors
|
||||
.split(",")
|
||||
.map((selector: string) => `.resume-preview-container ${selector.trim()} `)
|
||||
.join(", ");
|
||||
return `${prefixed}${brace}`;
|
||||
});
|
||||
})
|
||||
.join("\n");
|
||||
|
||||
return scoped;
|
||||
}, [metadata.css.enabled, metadata.css.value]);
|
||||
return scoped;
|
||||
}, [metadata.css.enabled, metadata.css.value]);
|
||||
|
||||
return (
|
||||
<IconContext.Provider value={iconProps}>
|
||||
{scopedCSS && <style dangerouslySetInnerHTML={{ __html: scopedCSS }} />}
|
||||
return (
|
||||
<IconContext.Provider value={iconProps}>
|
||||
{scopedCSS && <style dangerouslySetInnerHTML={{ __html: scopedCSS }} />}
|
||||
|
||||
<div style={style} className={cn("resume-preview-container", className)} {...props}>
|
||||
{metadata.layout.pages.map((pageLayout, pageIndex) => (
|
||||
<PageContainer
|
||||
key={pageIndex}
|
||||
pageIndex={pageIndex}
|
||||
pageLayout={pageLayout}
|
||||
pageClassName={pageClassName}
|
||||
showPageNumbers={showPageNumbers}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</IconContext.Provider>
|
||||
);
|
||||
<div style={style} className={cn("resume-preview-container", className)} {...props}>
|
||||
{metadata.layout.pages.map((pageLayout, pageIndex) => (
|
||||
<PageContainer
|
||||
key={pageIndex}
|
||||
pageIndex={pageIndex}
|
||||
pageLayout={pageLayout}
|
||||
pageClassName={pageClassName}
|
||||
showPageNumbers={showPageNumbers}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</IconContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
type PageContainerProps = {
|
||||
pageIndex: number;
|
||||
pageLayout: z.infer<typeof pageLayoutSchema>;
|
||||
pageClassName?: string;
|
||||
showPageNumbers?: boolean;
|
||||
pageIndex: number;
|
||||
pageLayout: z.infer<typeof pageLayoutSchema>;
|
||||
pageClassName?: string;
|
||||
showPageNumbers?: boolean;
|
||||
};
|
||||
|
||||
function PageContainer({ pageIndex, pageLayout, pageClassName, showPageNumbers = false }: PageContainerProps) {
|
||||
const pageRef = useRef<HTMLDivElement>(null);
|
||||
const [pageHeight, setPageHeight] = useState<number>(0);
|
||||
const pageRef = useRef<HTMLDivElement>(null);
|
||||
const [pageHeight, setPageHeight] = useState<number>(0);
|
||||
|
||||
const metadata = useResumeStore((state) => state.resume.data.metadata);
|
||||
const metadata = useResumeStore((state) => state.resume.data.metadata);
|
||||
|
||||
const pageNumber = useMemo(() => pageIndex + 1, [pageIndex]);
|
||||
const maxPageHeight = useMemo(() => pageDimensionsAsPixels[metadata.page.format].height, [metadata.page.format]);
|
||||
const totalNumberOfPages = useMemo(() => metadata.layout.pages.length, [metadata.layout.pages]);
|
||||
const TemplateComponent = useMemo(() => getTemplateComponent(metadata.template), [metadata.template]);
|
||||
const pageNumber = useMemo(() => pageIndex + 1, [pageIndex]);
|
||||
const maxPageHeight = useMemo(() => pageDimensionsAsPixels[metadata.page.format].height, [metadata.page.format]);
|
||||
const totalNumberOfPages = useMemo(() => metadata.layout.pages.length, [metadata.layout.pages]);
|
||||
const TemplateComponent = useMemo(() => getTemplateComponent(metadata.template), [metadata.template]);
|
||||
|
||||
useResizeObserver({
|
||||
ref: pageRef as RefObject<HTMLDivElement>,
|
||||
onResize: (size) => {
|
||||
if (!size.height) return;
|
||||
setPageHeight(size.height);
|
||||
},
|
||||
});
|
||||
useResizeObserver({
|
||||
ref: pageRef as RefObject<HTMLDivElement>,
|
||||
onResize: (size) => {
|
||||
if (!size.height) return;
|
||||
setPageHeight(size.height);
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<div data-page-index={pageIndex} className="relative">
|
||||
{showPageNumbers && totalNumberOfPages > 1 && (
|
||||
<div className="absolute inset-s-0 -top-6 print:hidden">
|
||||
<span className="text-xs font-medium text-foreground">
|
||||
<Trans>
|
||||
Page {pageNumber} of {totalNumberOfPages}
|
||||
</Trans>
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
return (
|
||||
<div data-page-index={pageIndex} className="relative">
|
||||
{showPageNumbers && totalNumberOfPages > 1 && (
|
||||
<div className="absolute inset-s-0 -top-6 print:hidden">
|
||||
<span className="text-xs font-medium text-foreground">
|
||||
<Trans>
|
||||
Page {pageNumber} of {totalNumberOfPages}
|
||||
</Trans>
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div ref={pageRef} className={cn(`page page-${pageIndex}`, styles.page, pageClassName)}>
|
||||
<TemplateComponent pageIndex={pageIndex} pageLayout={pageLayout} />
|
||||
</div>
|
||||
<div ref={pageRef} className={cn(`page page-${pageIndex}`, styles.page, pageClassName)}>
|
||||
<TemplateComponent pageIndex={pageIndex} pageLayout={pageLayout} />
|
||||
</div>
|
||||
|
||||
{metadata.page.format !== "free-form" && pageHeight > maxPageHeight && (
|
||||
<div className="absolute inset-s-0 top-full mt-4 print:hidden">
|
||||
<a
|
||||
rel="noopener"
|
||||
target="_blank"
|
||||
className="group/link"
|
||||
href="https://docs.rxresu.me/guides/fitting-content-on-a-page"
|
||||
>
|
||||
<Alert className="max-w-sm text-yellow-600">
|
||||
<WarningIcon color="currentColor" />
|
||||
<AlertTitle>
|
||||
<Trans>
|
||||
The content is too tall for this page, this may cause undesirable results when exporting to PDF.
|
||||
</Trans>
|
||||
</AlertTitle>
|
||||
<AlertDescription className="text-xs underline-offset-2 group-hover/link:underline">
|
||||
<Trans>Learn more about how to fit content on a page</Trans>
|
||||
<ArrowRightIcon color="currentColor" className="ms-1 inline size-3" />
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
{metadata.page.format !== "free-form" && pageHeight > maxPageHeight && (
|
||||
<div className="absolute inset-s-0 top-full mt-4 print:hidden">
|
||||
<a
|
||||
rel="noopener"
|
||||
target="_blank"
|
||||
className="group/link"
|
||||
href="https://docs.rxresu.me/guides/fitting-content-on-a-page"
|
||||
>
|
||||
<Alert className="max-w-sm text-yellow-600">
|
||||
<WarningIcon color="currentColor" />
|
||||
<AlertTitle>
|
||||
<Trans>
|
||||
The content is too tall for this page, this may cause undesirable results when exporting to PDF.
|
||||
</Trans>
|
||||
</AlertTitle>
|
||||
<AlertDescription className="text-xs underline-offset-2 group-hover/link:underline">
|
||||
<Trans>Learn more about how to fit content on a page</Trans>
|
||||
<ArrowRightIcon color="currentColor" className="ms-1 inline size-3" />
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import { match } from "ts-pattern";
|
||||
|
||||
import type {
|
||||
CoverLetterItem as CoverLetterItemType,
|
||||
CustomSectionItem,
|
||||
CustomSectionType,
|
||||
SectionItem,
|
||||
SectionType,
|
||||
SummaryItem as SummaryItemType,
|
||||
CoverLetterItem as CoverLetterItemType,
|
||||
CustomSectionItem,
|
||||
CustomSectionType,
|
||||
SectionItem,
|
||||
SectionType,
|
||||
SummaryItem as SummaryItemType,
|
||||
} from "@/schema/resume/data";
|
||||
|
||||
import { cn } from "@/utils/style";
|
||||
@@ -30,189 +30,189 @@ import { PageSection } from "./page-section";
|
||||
import { PageSummary } from "./page-summary";
|
||||
|
||||
type SectionComponentProps = {
|
||||
sectionClassName?: string;
|
||||
itemClassName?: string;
|
||||
sectionClassName?: string;
|
||||
itemClassName?: string;
|
||||
};
|
||||
|
||||
// Helper to render item component based on type
|
||||
function renderItemByType(type: CustomSectionType, item: CustomSectionItem, itemClassName?: string) {
|
||||
return match(type)
|
||||
.with("summary", () => <SummaryItem {...(item as SummaryItemType)} className={itemClassName} />)
|
||||
.with("profiles", () => <ProfilesItem {...(item as SectionItem<"profiles">)} className={itemClassName} />)
|
||||
.with("experience", () => <ExperienceItem {...(item as SectionItem<"experience">)} className={itemClassName} />)
|
||||
.with("education", () => <EducationItem {...(item as SectionItem<"education">)} className={itemClassName} />)
|
||||
.with("projects", () => <ProjectsItem {...(item as SectionItem<"projects">)} className={itemClassName} />)
|
||||
.with("skills", () => <SkillsItem {...(item as SectionItem<"skills">)} className={itemClassName} />)
|
||||
.with("languages", () => <LanguagesItem {...(item as SectionItem<"languages">)} className={itemClassName} />)
|
||||
.with("interests", () => <InterestsItem {...(item as SectionItem<"interests">)} className={itemClassName} />)
|
||||
.with("awards", () => <AwardsItem {...(item as SectionItem<"awards">)} className={itemClassName} />)
|
||||
.with("certifications", () => (
|
||||
<CertificationsItem {...(item as SectionItem<"certifications">)} className={itemClassName} />
|
||||
))
|
||||
.with("publications", () => (
|
||||
<PublicationsItem {...(item as SectionItem<"publications">)} className={itemClassName} />
|
||||
))
|
||||
.with("volunteer", () => <VolunteerItem {...(item as SectionItem<"volunteer">)} className={itemClassName} />)
|
||||
.with("references", () => <ReferencesItem {...(item as SectionItem<"references">)} className={itemClassName} />)
|
||||
.with("cover-letter", () => <CoverLetterItem {...(item as CoverLetterItemType)} className={itemClassName} />)
|
||||
.exhaustive();
|
||||
return match(type)
|
||||
.with("summary", () => <SummaryItem {...(item as SummaryItemType)} className={itemClassName} />)
|
||||
.with("profiles", () => <ProfilesItem {...(item as SectionItem<"profiles">)} className={itemClassName} />)
|
||||
.with("experience", () => <ExperienceItem {...(item as SectionItem<"experience">)} className={itemClassName} />)
|
||||
.with("education", () => <EducationItem {...(item as SectionItem<"education">)} className={itemClassName} />)
|
||||
.with("projects", () => <ProjectsItem {...(item as SectionItem<"projects">)} className={itemClassName} />)
|
||||
.with("skills", () => <SkillsItem {...(item as SectionItem<"skills">)} className={itemClassName} />)
|
||||
.with("languages", () => <LanguagesItem {...(item as SectionItem<"languages">)} className={itemClassName} />)
|
||||
.with("interests", () => <InterestsItem {...(item as SectionItem<"interests">)} className={itemClassName} />)
|
||||
.with("awards", () => <AwardsItem {...(item as SectionItem<"awards">)} className={itemClassName} />)
|
||||
.with("certifications", () => (
|
||||
<CertificationsItem {...(item as SectionItem<"certifications">)} className={itemClassName} />
|
||||
))
|
||||
.with("publications", () => (
|
||||
<PublicationsItem {...(item as SectionItem<"publications">)} className={itemClassName} />
|
||||
))
|
||||
.with("volunteer", () => <VolunteerItem {...(item as SectionItem<"volunteer">)} className={itemClassName} />)
|
||||
.with("references", () => <ReferencesItem {...(item as SectionItem<"references">)} className={itemClassName} />)
|
||||
.with("cover-letter", () => <CoverLetterItem {...(item as CoverLetterItemType)} className={itemClassName} />)
|
||||
.exhaustive();
|
||||
}
|
||||
|
||||
type SectionProps = { id: string };
|
||||
|
||||
export function getSectionComponent(
|
||||
section: "summary" | SectionType | (string & {}),
|
||||
{ sectionClassName, itemClassName }: SectionComponentProps = {},
|
||||
section: "summary" | SectionType | (string & {}),
|
||||
{ sectionClassName, itemClassName }: SectionComponentProps = {},
|
||||
) {
|
||||
return match(section)
|
||||
.with("summary", () => {
|
||||
const SummarySection = (_: SectionProps) => <PageSummary className={sectionClassName} />;
|
||||
return match(section)
|
||||
.with("summary", () => {
|
||||
const SummarySection = (_: SectionProps) => <PageSummary className={sectionClassName} />;
|
||||
|
||||
return SummarySection;
|
||||
})
|
||||
.with("profiles", () => {
|
||||
const ProfilesSection = (_: SectionProps) => (
|
||||
<PageSection type="profiles" className={sectionClassName}>
|
||||
{(item) => <ProfilesItem {...item} className={itemClassName} />}
|
||||
</PageSection>
|
||||
);
|
||||
return SummarySection;
|
||||
})
|
||||
.with("profiles", () => {
|
||||
const ProfilesSection = (_: SectionProps) => (
|
||||
<PageSection type="profiles" className={sectionClassName}>
|
||||
{(item) => <ProfilesItem {...item} className={itemClassName} />}
|
||||
</PageSection>
|
||||
);
|
||||
|
||||
return ProfilesSection;
|
||||
})
|
||||
.with("experience", () => {
|
||||
const ExperienceSection = (_: SectionProps) => (
|
||||
<PageSection type="experience" className={sectionClassName}>
|
||||
{(item) => <ExperienceItem {...item} className={itemClassName} />}
|
||||
</PageSection>
|
||||
);
|
||||
return ProfilesSection;
|
||||
})
|
||||
.with("experience", () => {
|
||||
const ExperienceSection = (_: SectionProps) => (
|
||||
<PageSection type="experience" className={sectionClassName}>
|
||||
{(item) => <ExperienceItem {...item} className={itemClassName} />}
|
||||
</PageSection>
|
||||
);
|
||||
|
||||
return ExperienceSection;
|
||||
})
|
||||
.with("education", () => {
|
||||
const EducationSection = (_: SectionProps) => (
|
||||
<PageSection type="education" className={sectionClassName}>
|
||||
{(item) => <EducationItem {...item} className={itemClassName} />}
|
||||
</PageSection>
|
||||
);
|
||||
return ExperienceSection;
|
||||
})
|
||||
.with("education", () => {
|
||||
const EducationSection = (_: SectionProps) => (
|
||||
<PageSection type="education" className={sectionClassName}>
|
||||
{(item) => <EducationItem {...item} className={itemClassName} />}
|
||||
</PageSection>
|
||||
);
|
||||
|
||||
return EducationSection;
|
||||
})
|
||||
.with("projects", () => {
|
||||
const ProjectsSection = (_: SectionProps) => (
|
||||
<PageSection type="projects" className={sectionClassName}>
|
||||
{(item) => <ProjectsItem {...item} className={itemClassName} />}
|
||||
</PageSection>
|
||||
);
|
||||
return EducationSection;
|
||||
})
|
||||
.with("projects", () => {
|
||||
const ProjectsSection = (_: SectionProps) => (
|
||||
<PageSection type="projects" className={sectionClassName}>
|
||||
{(item) => <ProjectsItem {...item} className={itemClassName} />}
|
||||
</PageSection>
|
||||
);
|
||||
|
||||
return ProjectsSection;
|
||||
})
|
||||
.with("skills", () => {
|
||||
const SkillsSection = (_: SectionProps) => (
|
||||
<PageSection type="skills" className={sectionClassName}>
|
||||
{(item) => <SkillsItem {...item} className={itemClassName} />}
|
||||
</PageSection>
|
||||
);
|
||||
return ProjectsSection;
|
||||
})
|
||||
.with("skills", () => {
|
||||
const SkillsSection = (_: SectionProps) => (
|
||||
<PageSection type="skills" className={sectionClassName}>
|
||||
{(item) => <SkillsItem {...item} className={itemClassName} />}
|
||||
</PageSection>
|
||||
);
|
||||
|
||||
return SkillsSection;
|
||||
})
|
||||
.with("languages", () => {
|
||||
const LanguagesSection = (_: SectionProps) => (
|
||||
<PageSection type="languages" className={sectionClassName}>
|
||||
{(item) => <LanguagesItem {...item} className={itemClassName} />}
|
||||
</PageSection>
|
||||
);
|
||||
return SkillsSection;
|
||||
})
|
||||
.with("languages", () => {
|
||||
const LanguagesSection = (_: SectionProps) => (
|
||||
<PageSection type="languages" className={sectionClassName}>
|
||||
{(item) => <LanguagesItem {...item} className={itemClassName} />}
|
||||
</PageSection>
|
||||
);
|
||||
|
||||
return LanguagesSection;
|
||||
})
|
||||
.with("interests", () => {
|
||||
const InterestsSection = (_: SectionProps) => (
|
||||
<PageSection type="interests" className={sectionClassName}>
|
||||
{(item) => <InterestsItem {...item} className={itemClassName} />}
|
||||
</PageSection>
|
||||
);
|
||||
return LanguagesSection;
|
||||
})
|
||||
.with("interests", () => {
|
||||
const InterestsSection = (_: SectionProps) => (
|
||||
<PageSection type="interests" className={sectionClassName}>
|
||||
{(item) => <InterestsItem {...item} className={itemClassName} />}
|
||||
</PageSection>
|
||||
);
|
||||
|
||||
return InterestsSection;
|
||||
})
|
||||
.with("awards", () => {
|
||||
const AwardsSection = (_: SectionProps) => (
|
||||
<PageSection type="awards" className={sectionClassName}>
|
||||
{(item) => <AwardsItem {...item} className={itemClassName} />}
|
||||
</PageSection>
|
||||
);
|
||||
return InterestsSection;
|
||||
})
|
||||
.with("awards", () => {
|
||||
const AwardsSection = (_: SectionProps) => (
|
||||
<PageSection type="awards" className={sectionClassName}>
|
||||
{(item) => <AwardsItem {...item} className={itemClassName} />}
|
||||
</PageSection>
|
||||
);
|
||||
|
||||
return AwardsSection;
|
||||
})
|
||||
.with("certifications", () => {
|
||||
const CertificationsSection = (_: SectionProps) => (
|
||||
<PageSection type="certifications" className={sectionClassName}>
|
||||
{(item) => <CertificationsItem {...item} className={itemClassName} />}
|
||||
</PageSection>
|
||||
);
|
||||
return AwardsSection;
|
||||
})
|
||||
.with("certifications", () => {
|
||||
const CertificationsSection = (_: SectionProps) => (
|
||||
<PageSection type="certifications" className={sectionClassName}>
|
||||
{(item) => <CertificationsItem {...item} className={itemClassName} />}
|
||||
</PageSection>
|
||||
);
|
||||
|
||||
return CertificationsSection;
|
||||
})
|
||||
.with("publications", () => {
|
||||
const PublicationsSection = (_: SectionProps) => (
|
||||
<PageSection type="publications" className={sectionClassName}>
|
||||
{(item) => <PublicationsItem {...item} className={itemClassName} />}
|
||||
</PageSection>
|
||||
);
|
||||
return CertificationsSection;
|
||||
})
|
||||
.with("publications", () => {
|
||||
const PublicationsSection = (_: SectionProps) => (
|
||||
<PageSection type="publications" className={sectionClassName}>
|
||||
{(item) => <PublicationsItem {...item} className={itemClassName} />}
|
||||
</PageSection>
|
||||
);
|
||||
|
||||
return PublicationsSection;
|
||||
})
|
||||
.with("volunteer", () => {
|
||||
const VolunteerSection = (_: SectionProps) => (
|
||||
<PageSection type="volunteer" className={sectionClassName}>
|
||||
{(item) => <VolunteerItem {...item} className={itemClassName} />}
|
||||
</PageSection>
|
||||
);
|
||||
return PublicationsSection;
|
||||
})
|
||||
.with("volunteer", () => {
|
||||
const VolunteerSection = (_: SectionProps) => (
|
||||
<PageSection type="volunteer" className={sectionClassName}>
|
||||
{(item) => <VolunteerItem {...item} className={itemClassName} />}
|
||||
</PageSection>
|
||||
);
|
||||
|
||||
return VolunteerSection;
|
||||
})
|
||||
.with("references", () => {
|
||||
const ReferencesSection = (_: SectionProps) => (
|
||||
<PageSection type="references" className={sectionClassName}>
|
||||
{(item) => <ReferencesItem {...item} className={itemClassName} />}
|
||||
</PageSection>
|
||||
);
|
||||
return VolunteerSection;
|
||||
})
|
||||
.with("references", () => {
|
||||
const ReferencesSection = (_: SectionProps) => (
|
||||
<PageSection type="references" className={sectionClassName}>
|
||||
{(item) => <ReferencesItem {...item} className={itemClassName} />}
|
||||
</PageSection>
|
||||
);
|
||||
|
||||
return ReferencesSection;
|
||||
})
|
||||
.otherwise(() => {
|
||||
// Custom section - render based on its type
|
||||
const CustomSectionComponent = ({ id }: SectionProps) => {
|
||||
const customSection = useResumeStore((state) => state.resume.data.customSections.find((s) => s.id === id));
|
||||
return ReferencesSection;
|
||||
})
|
||||
.otherwise(() => {
|
||||
// Custom section - render based on its type
|
||||
const CustomSectionComponent = ({ id }: SectionProps) => {
|
||||
const customSection = useResumeStore((state) => state.resume.data.customSections.find((s) => s.id === id));
|
||||
|
||||
if (!customSection) return null;
|
||||
if (customSection.hidden) return null;
|
||||
if (customSection.items.length === 0) return null;
|
||||
if (!customSection) return null;
|
||||
if (customSection.hidden) return null;
|
||||
if (customSection.items.length === 0) return null;
|
||||
|
||||
const visibleItems = customSection.items.filter((item) => !item.hidden);
|
||||
if (visibleItems.length === 0) return null;
|
||||
const visibleItems = customSection.items.filter((item) => !item.hidden);
|
||||
if (visibleItems.length === 0) return null;
|
||||
|
||||
return (
|
||||
<section className={cn(`page-section page-section-custom page-section-${id}`, sectionClassName)}>
|
||||
{customSection.type !== "cover-letter" && (
|
||||
<h6 className="mb-1.5 text-(--page-primary-color)">{customSection.title}</h6>
|
||||
)}
|
||||
return (
|
||||
<section className={cn(`page-section page-section-custom page-section-${id}`, sectionClassName)}>
|
||||
{customSection.type !== "cover-letter" && (
|
||||
<h6 className="mb-1.5 text-(--page-primary-color)">{customSection.title}</h6>
|
||||
)}
|
||||
|
||||
<div
|
||||
className="section-content grid gap-x-(--page-gap-x) gap-y-(--page-gap-y)"
|
||||
style={{ gridTemplateColumns: `repeat(${customSection.columns}, 1fr)` }}
|
||||
>
|
||||
{visibleItems.map((item) => (
|
||||
<div
|
||||
key={item.id}
|
||||
className={cn(`section-item section-item-${customSection.type} print:break-inside-avoid`)}
|
||||
>
|
||||
{renderItemByType(customSection.type, item, itemClassName)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
<div
|
||||
className="section-content grid gap-x-(--page-gap-x) gap-y-(--page-gap-y)"
|
||||
style={{ gridTemplateColumns: `repeat(${customSection.columns}, 1fr)` }}
|
||||
>
|
||||
{visibleItems.map((item) => (
|
||||
<div
|
||||
key={item.id}
|
||||
className={cn(`section-item section-item-${customSection.type} print:break-inside-avoid`)}
|
||||
>
|
||||
{renderItemByType(customSection.type, item, itemClassName)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
return CustomSectionComponent;
|
||||
});
|
||||
return CustomSectionComponent;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -8,42 +8,42 @@ import { LinkedTitle } from "../linked-title";
|
||||
import { PageLink } from "../page-link";
|
||||
|
||||
type AwardsItemProps = SectionItem<"awards"> & {
|
||||
className?: string;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export function AwardsItem({ className, ...item }: AwardsItemProps) {
|
||||
return (
|
||||
<div className={cn("awards-item", className)}>
|
||||
{/* Header */}
|
||||
<div className="section-item-header awards-item-header">
|
||||
{/* Row 1 */}
|
||||
<div className="flex items-start justify-between gap-x-2">
|
||||
<LinkedTitle
|
||||
title={item.title}
|
||||
website={item.website}
|
||||
showLinkInTitle={item.options?.showLinkInTitle}
|
||||
className="section-item-title awards-item-title"
|
||||
/>
|
||||
<span className="section-item-metadata awards-item-date shrink-0 text-end">{item.date}</span>
|
||||
</div>
|
||||
return (
|
||||
<div className={cn("awards-item", className)}>
|
||||
{/* Header */}
|
||||
<div className="section-item-header awards-item-header">
|
||||
{/* Row 1 */}
|
||||
<div className="flex items-start justify-between gap-x-2">
|
||||
<LinkedTitle
|
||||
title={item.title}
|
||||
website={item.website}
|
||||
showLinkInTitle={item.options?.showLinkInTitle}
|
||||
className="section-item-title awards-item-title"
|
||||
/>
|
||||
<span className="section-item-metadata awards-item-date shrink-0 text-end">{item.date}</span>
|
||||
</div>
|
||||
|
||||
{/* Row 2 */}
|
||||
<div className="flex items-start justify-between gap-x-2">
|
||||
<span className="section-item-metadata awards-item-awarder">{item.awarder}</span>
|
||||
</div>
|
||||
</div>
|
||||
{/* Row 2 */}
|
||||
<div className="flex items-start justify-between gap-x-2">
|
||||
<span className="section-item-metadata awards-item-awarder">{item.awarder}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
<div className={cn("section-item-description awards-item-description", !stripHtml(item.description) && "hidden")}>
|
||||
<TiptapContent content={item.description} />
|
||||
</div>
|
||||
{/* Description */}
|
||||
<div className={cn("section-item-description awards-item-description", !stripHtml(item.description) && "hidden")}>
|
||||
<TiptapContent content={item.description} />
|
||||
</div>
|
||||
|
||||
{/* Website */}
|
||||
{!item.options?.showLinkInTitle && (
|
||||
<div className="section-item-website awards-item-website">
|
||||
<PageLink {...item.website} label={item.website.label} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
{/* Website */}
|
||||
{!item.options?.showLinkInTitle && (
|
||||
<div className="section-item-website awards-item-website">
|
||||
<PageLink {...item.website} label={item.website.label} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -8,47 +8,47 @@ import { LinkedTitle } from "../linked-title";
|
||||
import { PageLink } from "../page-link";
|
||||
|
||||
type CertificationsItemProps = SectionItem<"certifications"> & {
|
||||
className?: string;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export function CertificationsItem({ className, ...item }: CertificationsItemProps) {
|
||||
return (
|
||||
<div className={cn("certifications-item", className)}>
|
||||
{/* Header */}
|
||||
<div className="section-item-header certifications-item-header">
|
||||
{/* Row 1 */}
|
||||
<div className="flex items-start justify-between gap-x-2">
|
||||
<LinkedTitle
|
||||
title={item.title}
|
||||
website={item.website}
|
||||
showLinkInTitle={item.options?.showLinkInTitle}
|
||||
className="section-item-title certifications-item-title"
|
||||
/>
|
||||
<span className="section-item-metadata certifications-item-date shrink-0 text-end">{item.date}</span>
|
||||
</div>
|
||||
return (
|
||||
<div className={cn("certifications-item", className)}>
|
||||
{/* Header */}
|
||||
<div className="section-item-header certifications-item-header">
|
||||
{/* Row 1 */}
|
||||
<div className="flex items-start justify-between gap-x-2">
|
||||
<LinkedTitle
|
||||
title={item.title}
|
||||
website={item.website}
|
||||
showLinkInTitle={item.options?.showLinkInTitle}
|
||||
className="section-item-title certifications-item-title"
|
||||
/>
|
||||
<span className="section-item-metadata certifications-item-date shrink-0 text-end">{item.date}</span>
|
||||
</div>
|
||||
|
||||
{/* Row 2 */}
|
||||
<div className="flex items-start justify-between gap-x-2">
|
||||
<span className="section-item-metadata certifications-item-issuer">{item.issuer}</span>
|
||||
</div>
|
||||
</div>
|
||||
{/* Row 2 */}
|
||||
<div className="flex items-start justify-between gap-x-2">
|
||||
<span className="section-item-metadata certifications-item-issuer">{item.issuer}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
<div
|
||||
className={cn(
|
||||
"section-item-description certifications-item-description",
|
||||
!stripHtml(item.description) && "hidden",
|
||||
)}
|
||||
>
|
||||
<TiptapContent content={item.description} />
|
||||
</div>
|
||||
{/* Description */}
|
||||
<div
|
||||
className={cn(
|
||||
"section-item-description certifications-item-description",
|
||||
!stripHtml(item.description) && "hidden",
|
||||
)}
|
||||
>
|
||||
<TiptapContent content={item.description} />
|
||||
</div>
|
||||
|
||||
{/* Website */}
|
||||
{!item.options?.showLinkInTitle && (
|
||||
<div className="section-item-website certifications-item-website">
|
||||
<PageLink {...item.website} label={item.website.label} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
{/* Website */}
|
||||
{!item.options?.showLinkInTitle && (
|
||||
<div className="section-item-website certifications-item-website">
|
||||
<PageLink {...item.website} label={item.website.label} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -5,21 +5,21 @@ import { stripHtml } from "@/utils/string";
|
||||
import { cn } from "@/utils/style";
|
||||
|
||||
type CoverLetterItemProps = CoverLetterItemType & {
|
||||
className?: string;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export function CoverLetterItem({ className, ...item }: CoverLetterItemProps) {
|
||||
if (!stripHtml(item.recipient) && !stripHtml(item.content)) return null;
|
||||
if (!stripHtml(item.recipient) && !stripHtml(item.content)) return null;
|
||||
|
||||
return (
|
||||
<div className={cn("cover-letter-item", className)}>
|
||||
<div className={cn("cover-letter-item-recipient mb-4", !stripHtml(item.recipient) && "hidden")}>
|
||||
<TiptapContent content={item.recipient} />
|
||||
</div>
|
||||
return (
|
||||
<div className={cn("cover-letter-item", className)}>
|
||||
<div className={cn("cover-letter-item-recipient mb-4", !stripHtml(item.recipient) && "hidden")}>
|
||||
<TiptapContent content={item.recipient} />
|
||||
</div>
|
||||
|
||||
<div className={cn("cover-letter-item-content", !stripHtml(item.content) && "hidden")}>
|
||||
<TiptapContent content={item.content} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
<div className={cn("cover-letter-item-content", !stripHtml(item.content) && "hidden")}>
|
||||
<TiptapContent content={item.content} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -8,49 +8,49 @@ import { LinkedTitle } from "../linked-title";
|
||||
import { PageLink } from "../page-link";
|
||||
|
||||
type EducationItemProps = SectionItem<"education"> & {
|
||||
className?: string;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export function EducationItem({ className, ...item }: EducationItemProps) {
|
||||
return (
|
||||
<div className={cn("education-item", className)}>
|
||||
{/* Header */}
|
||||
<div className="section-item-header education-item-header mb-2">
|
||||
{/* Row 1 */}
|
||||
<div className="flex items-start justify-between gap-x-2">
|
||||
<LinkedTitle
|
||||
title={item.school}
|
||||
website={item.website}
|
||||
showLinkInTitle={item.options?.showLinkInTitle}
|
||||
className="section-item-title education-item-title"
|
||||
/>
|
||||
<span className="section-item-metadata education-item-degree-grade shrink-0 text-end">
|
||||
{[item.degree, item.grade].filter(Boolean).join(" • ")}
|
||||
</span>
|
||||
</div>
|
||||
return (
|
||||
<div className={cn("education-item", className)}>
|
||||
{/* Header */}
|
||||
<div className="section-item-header education-item-header mb-2">
|
||||
{/* Row 1 */}
|
||||
<div className="flex items-start justify-between gap-x-2">
|
||||
<LinkedTitle
|
||||
title={item.school}
|
||||
website={item.website}
|
||||
showLinkInTitle={item.options?.showLinkInTitle}
|
||||
className="section-item-title education-item-title"
|
||||
/>
|
||||
<span className="section-item-metadata education-item-degree-grade shrink-0 text-end">
|
||||
{[item.degree, item.grade].filter(Boolean).join(" • ")}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Row 2 */}
|
||||
<div className="flex items-start justify-between gap-x-2">
|
||||
<span className="section-item-metadata education-item-area">{item.area}</span>
|
||||
<span className="section-item-metadata education-item-location-period shrink-0 text-end">
|
||||
{[item.location, item.period].filter(Boolean).join(" • ")}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{/* Row 2 */}
|
||||
<div className="flex items-start justify-between gap-x-2">
|
||||
<span className="section-item-metadata education-item-area">{item.area}</span>
|
||||
<span className="section-item-metadata education-item-location-period shrink-0 text-end">
|
||||
{[item.location, item.period].filter(Boolean).join(" • ")}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
<div
|
||||
className={cn("section-item-description education-item-description", !stripHtml(item.description) && "hidden")}
|
||||
>
|
||||
<TiptapContent content={item.description} />
|
||||
</div>
|
||||
{/* Description */}
|
||||
<div
|
||||
className={cn("section-item-description education-item-description", !stripHtml(item.description) && "hidden")}
|
||||
>
|
||||
<TiptapContent content={item.description} />
|
||||
</div>
|
||||
|
||||
{/* Website */}
|
||||
{!item.options?.showLinkInTitle && (
|
||||
<div className="section-item-website education-item-website">
|
||||
<PageLink {...item.website} label={item.website.label} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
{/* Website */}
|
||||
{!item.options?.showLinkInTitle && (
|
||||
<div className="section-item-website education-item-website">
|
||||
<PageLink {...item.website} label={item.website.label} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -8,83 +8,83 @@ import { LinkedTitle } from "../linked-title";
|
||||
import { PageLink } from "../page-link";
|
||||
|
||||
type ExperienceItemProps = SectionItem<"experience"> & {
|
||||
className?: string;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export function ExperienceItem({ className, ...item }: ExperienceItemProps) {
|
||||
const hasRoles = Array.isArray(item.roles) && item.roles.length > 0;
|
||||
const hasRoles = Array.isArray(item.roles) && item.roles.length > 0;
|
||||
|
||||
return (
|
||||
<div className={cn("experience-item", className)}>
|
||||
{/* Header */}
|
||||
<div className="section-item-header experience-item-header">
|
||||
{/* Row 1: Company + Location */}
|
||||
<div className="flex items-start justify-between gap-x-2">
|
||||
<LinkedTitle
|
||||
title={item.company}
|
||||
website={item.website}
|
||||
showLinkInTitle={item.options?.showLinkInTitle}
|
||||
className="section-item-title experience-item-title"
|
||||
/>
|
||||
<span className="section-item-metadata experience-item-location shrink-0 text-end">{item.location}</span>
|
||||
</div>
|
||||
return (
|
||||
<div className={cn("experience-item", className)}>
|
||||
{/* Header */}
|
||||
<div className="section-item-header experience-item-header">
|
||||
{/* Row 1: Company + Location */}
|
||||
<div className="flex items-start justify-between gap-x-2">
|
||||
<LinkedTitle
|
||||
title={item.company}
|
||||
website={item.website}
|
||||
showLinkInTitle={item.options?.showLinkInTitle}
|
||||
className="section-item-title experience-item-title"
|
||||
/>
|
||||
<span className="section-item-metadata experience-item-location shrink-0 text-end">{item.location}</span>
|
||||
</div>
|
||||
|
||||
{/* Row 2: Position + Period */}
|
||||
{(!hasRoles || item.position) && (
|
||||
<div className="flex items-start justify-between gap-x-2">
|
||||
<span className="section-item-metadata experience-item-position">{item.position}</span>
|
||||
<span className="section-item-metadata experience-item-period shrink-0 text-end">{item.period}</span>
|
||||
</div>
|
||||
)}
|
||||
{/* Row 2: Position + Period */}
|
||||
{(!hasRoles || item.position) && (
|
||||
<div className="flex items-start justify-between gap-x-2">
|
||||
<span className="section-item-metadata experience-item-position">{item.position}</span>
|
||||
<span className="section-item-metadata experience-item-period shrink-0 text-end">{item.period}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Overall period when hasRoles and no summary position */}
|
||||
{hasRoles && !item.position && item.period && (
|
||||
<div className="flex items-start justify-end gap-x-2">
|
||||
<span className="section-item-metadata experience-item-period shrink-0 text-end">{item.period}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{/* Overall period when hasRoles and no summary position */}
|
||||
{hasRoles && !item.position && item.period && (
|
||||
<div className="flex items-start justify-end gap-x-2">
|
||||
<span className="section-item-metadata experience-item-period shrink-0 text-end">{item.period}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Role Progression */}
|
||||
{hasRoles && (
|
||||
<div className="experience-item-roles mt-0 flex flex-col gap-y-1">
|
||||
{item.roles.map((role) => (
|
||||
<div key={role.id} className="experience-item-role">
|
||||
<div className="flex items-start justify-between gap-x-2">
|
||||
<span className="section-item-metadata experience-item-role-position">{role.position}</span>
|
||||
<span className="section-item-metadata experience-item-role-period shrink-0 text-end">
|
||||
{role.period}
|
||||
</span>
|
||||
</div>
|
||||
{/* Role Progression */}
|
||||
{hasRoles && (
|
||||
<div className="experience-item-roles mt-0 flex flex-col gap-y-1">
|
||||
{item.roles.map((role) => (
|
||||
<div key={role.id} className="experience-item-role">
|
||||
<div className="flex items-start justify-between gap-x-2">
|
||||
<span className="section-item-metadata experience-item-role-position">{role.position}</span>
|
||||
<span className="section-item-metadata experience-item-role-period shrink-0 text-end">
|
||||
{role.period}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{stripHtml(role.description) && (
|
||||
<div className="section-item-description experience-item-role-description mt-0.5">
|
||||
<TiptapContent content={role.description} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{stripHtml(role.description) && (
|
||||
<div className="section-item-description experience-item-role-description mt-0.5">
|
||||
<TiptapContent content={role.description} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Single-role description */}
|
||||
{!hasRoles && (
|
||||
<div
|
||||
className={cn(
|
||||
"section-item-description experience-item-description",
|
||||
!stripHtml(item.description) && "hidden",
|
||||
)}
|
||||
>
|
||||
<TiptapContent content={item.description} />
|
||||
</div>
|
||||
)}
|
||||
{/* Single-role description */}
|
||||
{!hasRoles && (
|
||||
<div
|
||||
className={cn(
|
||||
"section-item-description experience-item-description",
|
||||
!stripHtml(item.description) && "hidden",
|
||||
)}
|
||||
>
|
||||
<TiptapContent content={item.description} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Website */}
|
||||
{!item.options?.showLinkInTitle && (
|
||||
<div className="section-item-website experience-item-website">
|
||||
<PageLink {...item.website} label={item.website.label} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
{/* Website */}
|
||||
{!item.options?.showLinkInTitle && (
|
||||
<div className="section-item-website experience-item-website">
|
||||
<PageLink {...item.website} label={item.website.label} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -5,24 +5,24 @@ import { cn } from "@/utils/style";
|
||||
import { PageIcon } from "../page-icon";
|
||||
|
||||
type InterestsItemProps = SectionItem<"interests"> & {
|
||||
className?: string;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export function InterestsItem({ className, ...item }: InterestsItemProps) {
|
||||
return (
|
||||
<div className={cn("interests-item", className)}>
|
||||
{/* Header */}
|
||||
<div className="section-item-header interests-item-header flex items-center gap-x-1.5">
|
||||
<PageIcon icon={item.icon} className="section-item-icon interests-item-icon" />
|
||||
<strong className="section-item-title interests-item-name">{item.name}</strong>
|
||||
</div>
|
||||
return (
|
||||
<div className={cn("interests-item", className)}>
|
||||
{/* Header */}
|
||||
<div className="section-item-header interests-item-header flex items-center gap-x-1.5">
|
||||
<PageIcon icon={item.icon} className="section-item-icon interests-item-icon" />
|
||||
<strong className="section-item-title interests-item-name">{item.name}</strong>
|
||||
</div>
|
||||
|
||||
{/* Keywords */}
|
||||
{item.keywords.length > 0 && (
|
||||
<span className="section-item-keywords interests-item-keywords inline-block opacity-80">
|
||||
{item.keywords.join(", ")}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
{/* Keywords */}
|
||||
{item.keywords.length > 0 && (
|
||||
<span className="section-item-keywords interests-item-keywords inline-block opacity-80">
|
||||
{item.keywords.join(", ")}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -5,23 +5,23 @@ import { cn } from "@/utils/style";
|
||||
import { PageLevel } from "../page-level";
|
||||
|
||||
type LanguagesItemProps = SectionItem<"languages"> & {
|
||||
className?: string;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export function LanguagesItem({ className, ...item }: LanguagesItemProps) {
|
||||
return (
|
||||
<div className={cn("languages-item", className)}>
|
||||
{/* Header */}
|
||||
<div className="section-item-header languages-item-header flex flex-col">
|
||||
{/* Row 1 */}
|
||||
<strong className="section-item-title languages-item-name">{item.language}</strong>
|
||||
return (
|
||||
<div className={cn("languages-item", className)}>
|
||||
{/* Header */}
|
||||
<div className="section-item-header languages-item-header flex flex-col">
|
||||
{/* Row 1 */}
|
||||
<strong className="section-item-title languages-item-name">{item.language}</strong>
|
||||
|
||||
{/* Row 2 */}
|
||||
<span className="section-item-metadata languages-item-fluency opacity-80">{item.fluency}</span>
|
||||
</div>
|
||||
{/* Row 2 */}
|
||||
<span className="section-item-metadata languages-item-fluency opacity-80">{item.fluency}</span>
|
||||
</div>
|
||||
|
||||
{/* Level */}
|
||||
<PageLevel level={item.level} className="section-item-level languages-item-level" />
|
||||
</div>
|
||||
);
|
||||
{/* Level */}
|
||||
<PageLevel level={item.level} className="section-item-level languages-item-level" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -7,31 +7,31 @@ import { PageIcon } from "../page-icon";
|
||||
import { PageLink } from "../page-link";
|
||||
|
||||
type ProfilesItemProps = SectionItem<"profiles"> & {
|
||||
className?: string;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export function ProfilesItem({ className, ...item }: ProfilesItemProps) {
|
||||
return (
|
||||
<div className={cn("profiles-item", className)}>
|
||||
{/* Header */}
|
||||
<div className="section-item-header profiles-item-header flex items-center gap-x-1.5">
|
||||
<PageIcon icon={item.icon} className="section-item-icon profiles-item-icon" />
|
||||
<LinkedTitle
|
||||
title={item.network}
|
||||
website={item.website}
|
||||
showLinkInTitle={item.options?.showLinkInTitle}
|
||||
className="section-item-title profiles-item-network"
|
||||
/>
|
||||
</div>
|
||||
return (
|
||||
<div className={cn("profiles-item", className)}>
|
||||
{/* Header */}
|
||||
<div className="section-item-header profiles-item-header flex items-center gap-x-1.5">
|
||||
<PageIcon icon={item.icon} className="section-item-icon profiles-item-icon" />
|
||||
<LinkedTitle
|
||||
title={item.network}
|
||||
website={item.website}
|
||||
showLinkInTitle={item.options?.showLinkInTitle}
|
||||
className="section-item-title profiles-item-network"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Website */}
|
||||
{!item.options?.showLinkInTitle && (
|
||||
<PageLink
|
||||
{...item.website}
|
||||
label={item.website.label || item.username}
|
||||
className="section-item-website profiles-item-website"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
{/* Website */}
|
||||
{!item.options?.showLinkInTitle && (
|
||||
<PageLink
|
||||
{...item.website}
|
||||
label={item.website.label || item.username}
|
||||
className="section-item-website profiles-item-website"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -8,39 +8,39 @@ import { LinkedTitle } from "../linked-title";
|
||||
import { PageLink } from "../page-link";
|
||||
|
||||
type ProjectsItemProps = SectionItem<"projects"> & {
|
||||
className?: string;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export function ProjectsItem({ className, ...item }: ProjectsItemProps) {
|
||||
return (
|
||||
<div className={cn("projects-item", className)}>
|
||||
{/* Header */}
|
||||
<div className="section-item-header projects-item-header">
|
||||
{/* Row 1 */}
|
||||
<div className="flex items-start justify-between gap-x-2">
|
||||
<LinkedTitle
|
||||
title={item.name}
|
||||
website={item.website}
|
||||
showLinkInTitle={item.options?.showLinkInTitle}
|
||||
className="section-item-title projects-item-title"
|
||||
/>
|
||||
<span className="section-item-metadata projects-item-period shrink-0 text-end">{item.period}</span>
|
||||
</div>
|
||||
</div>
|
||||
return (
|
||||
<div className={cn("projects-item", className)}>
|
||||
{/* Header */}
|
||||
<div className="section-item-header projects-item-header">
|
||||
{/* Row 1 */}
|
||||
<div className="flex items-start justify-between gap-x-2">
|
||||
<LinkedTitle
|
||||
title={item.name}
|
||||
website={item.website}
|
||||
showLinkInTitle={item.options?.showLinkInTitle}
|
||||
className="section-item-title projects-item-title"
|
||||
/>
|
||||
<span className="section-item-metadata projects-item-period shrink-0 text-end">{item.period}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
<div
|
||||
className={cn("section-item-description projects-item-description", !stripHtml(item.description) && "hidden")}
|
||||
>
|
||||
<TiptapContent content={item.description} />
|
||||
</div>
|
||||
{/* Description */}
|
||||
<div
|
||||
className={cn("section-item-description projects-item-description", !stripHtml(item.description) && "hidden")}
|
||||
>
|
||||
<TiptapContent content={item.description} />
|
||||
</div>
|
||||
|
||||
{/* Website */}
|
||||
{!item.options?.showLinkInTitle && (
|
||||
<div className="section-item-website projects-item-website">
|
||||
<PageLink {...item.website} label={item.website.label} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
{/* Website */}
|
||||
{!item.options?.showLinkInTitle && (
|
||||
<div className="section-item-website projects-item-website">
|
||||
<PageLink {...item.website} label={item.website.label} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -8,47 +8,47 @@ import { LinkedTitle } from "../linked-title";
|
||||
import { PageLink } from "../page-link";
|
||||
|
||||
type PublicationsItemProps = SectionItem<"publications"> & {
|
||||
className?: string;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export function PublicationsItem({ className, ...item }: PublicationsItemProps) {
|
||||
return (
|
||||
<div className={cn("publications-item", className)}>
|
||||
{/* Header */}
|
||||
<div className="section-item-header publications-item-header">
|
||||
{/* Row 1 */}
|
||||
<div className="flex items-start justify-between gap-x-2">
|
||||
<LinkedTitle
|
||||
title={item.title}
|
||||
website={item.website}
|
||||
showLinkInTitle={item.options?.showLinkInTitle}
|
||||
className="section-item-title publications-item-title"
|
||||
/>
|
||||
<span className="section-item-metadata publications-item-date shrink-0 text-end">{item.date}</span>
|
||||
</div>
|
||||
return (
|
||||
<div className={cn("publications-item", className)}>
|
||||
{/* Header */}
|
||||
<div className="section-item-header publications-item-header">
|
||||
{/* Row 1 */}
|
||||
<div className="flex items-start justify-between gap-x-2">
|
||||
<LinkedTitle
|
||||
title={item.title}
|
||||
website={item.website}
|
||||
showLinkInTitle={item.options?.showLinkInTitle}
|
||||
className="section-item-title publications-item-title"
|
||||
/>
|
||||
<span className="section-item-metadata publications-item-date shrink-0 text-end">{item.date}</span>
|
||||
</div>
|
||||
|
||||
{/* Row 2 */}
|
||||
<div className="flex items-start justify-between gap-x-2">
|
||||
<span className="section-item-metadata publications-item-publisher">{item.publisher}</span>
|
||||
</div>
|
||||
</div>
|
||||
{/* Row 2 */}
|
||||
<div className="flex items-start justify-between gap-x-2">
|
||||
<span className="section-item-metadata publications-item-publisher">{item.publisher}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
<div
|
||||
className={cn(
|
||||
"section-item-description publications-item-description",
|
||||
!stripHtml(item.description) && "hidden",
|
||||
)}
|
||||
>
|
||||
<TiptapContent content={item.description} />
|
||||
</div>
|
||||
{/* Description */}
|
||||
<div
|
||||
className={cn(
|
||||
"section-item-description publications-item-description",
|
||||
!stripHtml(item.description) && "hidden",
|
||||
)}
|
||||
>
|
||||
<TiptapContent content={item.description} />
|
||||
</div>
|
||||
|
||||
{/* Website */}
|
||||
{!item.options?.showLinkInTitle && (
|
||||
<div className="section-item-website publications-item-website">
|
||||
<PageLink {...item.website} label={item.website.label} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
{/* Website */}
|
||||
{!item.options?.showLinkInTitle && (
|
||||
<div className="section-item-website publications-item-website">
|
||||
<PageLink {...item.website} label={item.website.label} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -8,51 +8,51 @@ import { LinkedTitle } from "../linked-title";
|
||||
import { PageLink } from "../page-link";
|
||||
|
||||
type ReferencesItemProps = SectionItem<"references"> & {
|
||||
className?: string;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export function ReferencesItem({ className, ...item }: ReferencesItemProps) {
|
||||
return (
|
||||
<div className={cn("references-item", className)}>
|
||||
{/* Header */}
|
||||
<div className="section-item-header references-item-header">
|
||||
{/* Row 1 */}
|
||||
<div className="flex items-start justify-between gap-x-2">
|
||||
<LinkedTitle
|
||||
title={item.name}
|
||||
website={item.website}
|
||||
showLinkInTitle={item.options?.showLinkInTitle}
|
||||
className="section-item-title references-item-name"
|
||||
/>
|
||||
</div>
|
||||
return (
|
||||
<div className={cn("references-item", className)}>
|
||||
{/* Header */}
|
||||
<div className="section-item-header references-item-header">
|
||||
{/* Row 1 */}
|
||||
<div className="flex items-start justify-between gap-x-2">
|
||||
<LinkedTitle
|
||||
title={item.name}
|
||||
website={item.website}
|
||||
showLinkInTitle={item.options?.showLinkInTitle}
|
||||
className="section-item-title references-item-name"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Row 2 */}
|
||||
<div className="flex items-start justify-between gap-x-2">
|
||||
<span className="section-item-metadata references-item-position">{item.position}</span>
|
||||
</div>
|
||||
</div>
|
||||
{/* Row 2 */}
|
||||
<div className="flex items-start justify-between gap-x-2">
|
||||
<span className="section-item-metadata references-item-position">{item.position}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
<div
|
||||
className={cn("section-item-description references-item-description", !stripHtml(item.description) && "hidden")}
|
||||
>
|
||||
<TiptapContent content={item.description} />
|
||||
</div>
|
||||
{/* Description */}
|
||||
<div
|
||||
className={cn("section-item-description references-item-description", !stripHtml(item.description) && "hidden")}
|
||||
>
|
||||
<TiptapContent content={item.description} />
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="section-item-footer references-item-footer flex flex-col">
|
||||
{/* Row 1 */}
|
||||
<span className="section-item-metadata references-item-phone inline-block">{item.phone}</span>
|
||||
{/* Footer */}
|
||||
<div className="section-item-footer references-item-footer flex flex-col">
|
||||
{/* Row 1 */}
|
||||
<span className="section-item-metadata references-item-phone inline-block">{item.phone}</span>
|
||||
|
||||
{/* Row 2 */}
|
||||
{!item.options?.showLinkInTitle && (
|
||||
<PageLink
|
||||
{...item.website}
|
||||
label={item.website.label}
|
||||
className="section-item-website references-item-website"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
{/* Row 2 */}
|
||||
{!item.options?.showLinkInTitle && (
|
||||
<PageLink
|
||||
{...item.website}
|
||||
label={item.website.label}
|
||||
className="section-item-website references-item-website"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -6,28 +6,28 @@ import { PageIcon } from "../page-icon";
|
||||
import { PageLevel } from "../page-level";
|
||||
|
||||
type SkillsItemProps = SectionItem<"skills"> & {
|
||||
className?: string;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export function SkillsItem({ className, ...item }: SkillsItemProps) {
|
||||
return (
|
||||
<div className={cn("skills-item", className)}>
|
||||
{/* Header */}
|
||||
<div className="section-item-header flex items-center gap-x-1.5">
|
||||
<PageIcon icon={item.icon} className="section-item-icon skills-item-icon" />
|
||||
<strong className="section-item-title skills-item-name">{item.name}</strong>
|
||||
</div>
|
||||
return (
|
||||
<div className={cn("skills-item", className)}>
|
||||
{/* Header */}
|
||||
<div className="section-item-header flex items-center gap-x-1.5">
|
||||
<PageIcon icon={item.icon} className="section-item-icon skills-item-icon" />
|
||||
<strong className="section-item-title skills-item-name">{item.name}</strong>
|
||||
</div>
|
||||
|
||||
{/* Proficiency */}
|
||||
{item.proficiency && <div className="section-item-metadata skills-item-proficiency">{item.proficiency}</div>}
|
||||
{/* Proficiency */}
|
||||
{item.proficiency && <div className="section-item-metadata skills-item-proficiency">{item.proficiency}</div>}
|
||||
|
||||
{/* Keywords */}
|
||||
{item.keywords.length > 0 && (
|
||||
<div className="section-item-keywords skills-item-keywords opacity-80">{item.keywords.join(", ")}</div>
|
||||
)}
|
||||
{/* Keywords */}
|
||||
{item.keywords.length > 0 && (
|
||||
<div className="section-item-keywords skills-item-keywords opacity-80">{item.keywords.join(", ")}</div>
|
||||
)}
|
||||
|
||||
{/* Level */}
|
||||
<PageLevel level={item.level} className="section-item-level skills-item-level" />
|
||||
</div>
|
||||
);
|
||||
{/* Level */}
|
||||
<PageLevel level={item.level} className="section-item-level skills-item-level" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -5,15 +5,15 @@ import { stripHtml } from "@/utils/string";
|
||||
import { cn } from "@/utils/style";
|
||||
|
||||
type SummaryItemProps = SummaryItemType & {
|
||||
className?: string;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export function SummaryItem({ className, ...item }: SummaryItemProps) {
|
||||
if (!stripHtml(item.content)) return null;
|
||||
if (!stripHtml(item.content)) return null;
|
||||
|
||||
return (
|
||||
<div className={cn("summary-item", className)}>
|
||||
<TiptapContent content={item.content} />
|
||||
</div>
|
||||
);
|
||||
return (
|
||||
<div className={cn("summary-item", className)}>
|
||||
<TiptapContent content={item.content} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -8,44 +8,44 @@ import { LinkedTitle } from "../linked-title";
|
||||
import { PageLink } from "../page-link";
|
||||
|
||||
type VolunteerItemProps = SectionItem<"volunteer"> & {
|
||||
className?: string;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export function VolunteerItem({ className, ...item }: VolunteerItemProps) {
|
||||
return (
|
||||
<div className={cn("volunteer-item", className)}>
|
||||
{/* Header */}
|
||||
<div className="section-item-header volunteer-item-header">
|
||||
{/* Row 1 */}
|
||||
<div className="flex items-start justify-between gap-x-2">
|
||||
<LinkedTitle
|
||||
title={item.organization}
|
||||
website={item.website}
|
||||
showLinkInTitle={item.options?.showLinkInTitle}
|
||||
className="section-item-title volunteer-item-title"
|
||||
/>
|
||||
<span className="section-item-metadata volunteer-item-period shrink-0 text-end">{item.period}</span>
|
||||
</div>
|
||||
return (
|
||||
<div className={cn("volunteer-item", className)}>
|
||||
{/* Header */}
|
||||
<div className="section-item-header volunteer-item-header">
|
||||
{/* Row 1 */}
|
||||
<div className="flex items-start justify-between gap-x-2">
|
||||
<LinkedTitle
|
||||
title={item.organization}
|
||||
website={item.website}
|
||||
showLinkInTitle={item.options?.showLinkInTitle}
|
||||
className="section-item-title volunteer-item-title"
|
||||
/>
|
||||
<span className="section-item-metadata volunteer-item-period shrink-0 text-end">{item.period}</span>
|
||||
</div>
|
||||
|
||||
{/* Row 2 */}
|
||||
<div className="flex items-start justify-between gap-x-2">
|
||||
<span className="section-item-metadata volunteer-item-location">{item.location}</span>
|
||||
</div>
|
||||
</div>
|
||||
{/* Row 2 */}
|
||||
<div className="flex items-start justify-between gap-x-2">
|
||||
<span className="section-item-metadata volunteer-item-location">{item.location}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
<div
|
||||
className={cn("section-item-description volunteer-item-description", !stripHtml(item.description) && "hidden")}
|
||||
>
|
||||
<TiptapContent content={item.description} />
|
||||
</div>
|
||||
{/* Description */}
|
||||
<div
|
||||
className={cn("section-item-description volunteer-item-description", !stripHtml(item.description) && "hidden")}
|
||||
>
|
||||
<TiptapContent content={item.description} />
|
||||
</div>
|
||||
|
||||
{/* Website */}
|
||||
{!item.options?.showLinkInTitle && (
|
||||
<div className="section-item-website volunteer-item-website">
|
||||
<PageLink {...item.website} label={item.website.label} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
{/* Website */}
|
||||
{!item.options?.showLinkInTitle && (
|
||||
<div className="section-item-website volunteer-item-website">
|
||||
<PageLink {...item.website} label={item.website.label} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,20 +1,20 @@
|
||||
import { cn } from "@/utils/style";
|
||||
|
||||
type LinkedTitleProps = {
|
||||
title: string;
|
||||
website?: { url: string; label: string };
|
||||
showLinkInTitle?: boolean;
|
||||
className?: string;
|
||||
title: string;
|
||||
website?: { url: string; label: string };
|
||||
showLinkInTitle?: boolean;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export function LinkedTitle({ title, website, showLinkInTitle, className }: LinkedTitleProps) {
|
||||
if (showLinkInTitle && website?.url) {
|
||||
return (
|
||||
<a href={website.url} target="_blank" rel="noopener" className={cn("inline-block", className)}>
|
||||
<strong>{title}</strong>
|
||||
</a>
|
||||
);
|
||||
}
|
||||
if (showLinkInTitle && website?.url) {
|
||||
return (
|
||||
<a href={website.url} target="_blank" rel="noopener" className={cn("inline-block", className)}>
|
||||
<strong>{title}</strong>
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
return <strong className={className}>{title}</strong>;
|
||||
return <strong className={className}>{title}</strong>;
|
||||
}
|
||||
|
||||
@@ -6,14 +6,14 @@ import { cn } from "@/utils/style";
|
||||
import type { ExtendedIconProps } from "../preview";
|
||||
|
||||
export function PageIcon({ icon, className }: { icon: string; className?: string }) {
|
||||
const iconContext = use<ExtendedIconProps>(IconContext);
|
||||
const iconContext = use<ExtendedIconProps>(IconContext);
|
||||
|
||||
if (!icon || iconContext.hidden) return null;
|
||||
if (!icon || iconContext.hidden) return null;
|
||||
|
||||
return (
|
||||
<i
|
||||
className={cn("ph shrink-0", `ph-${icon}`, className)}
|
||||
style={{ fontSize: `${iconContext.size}px`, color: iconContext.color }}
|
||||
/>
|
||||
);
|
||||
return (
|
||||
<i
|
||||
className={cn("ph shrink-0", `ph-${icon}`, className)}
|
||||
style={{ fontSize: `${iconContext.size}px`, color: iconContext.color }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,11 +4,11 @@ import { cn } from "@/utils/style";
|
||||
import { useResumeStore } from "../store/resume";
|
||||
|
||||
type Props = React.ComponentProps<"div"> & {
|
||||
level: number;
|
||||
level: number;
|
||||
};
|
||||
|
||||
export function PageLevel({ level, className, ...props }: Props) {
|
||||
const { icon, type } = useResumeStore((state) => state.resume.data.metadata.design.level);
|
||||
const { icon, type } = useResumeStore((state) => state.resume.data.metadata.design.level);
|
||||
|
||||
return <LevelDisplay icon={icon} type={type} level={level} className={cn("h-6", className)} {...props} />;
|
||||
return <LevelDisplay icon={icon} type={type} level={level} className={cn("h-6", className)} {...props} />;
|
||||
}
|
||||
|
||||
@@ -1,17 +1,17 @@
|
||||
import { cn } from "@/utils/style";
|
||||
|
||||
type Props = {
|
||||
url: string;
|
||||
label?: string;
|
||||
className?: string;
|
||||
url: string;
|
||||
label?: string;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export function PageLink({ url, label, className }: Props) {
|
||||
if (!url) return null;
|
||||
if (!url) return null;
|
||||
|
||||
return (
|
||||
<a href={url} target="_blank" rel="noopener" className={cn("inline-block text-wrap break-all", className)}>
|
||||
{label || url}
|
||||
</a>
|
||||
);
|
||||
return (
|
||||
<a href={url} target="_blank" rel="noopener" className={cn("inline-block text-wrap break-all", className)}>
|
||||
{label || url}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,30 +3,30 @@ import { cn } from "@/utils/style";
|
||||
import { useResumeStore } from "../store/resume";
|
||||
|
||||
export function PagePicture({ className, style }: { className?: string; style?: React.CSSProperties }) {
|
||||
const name = useResumeStore((state) => state.resume.data.basics.name);
|
||||
const picture = useResumeStore((state) => state.resume.data.picture);
|
||||
const name = useResumeStore((state) => state.resume.data.basics.name);
|
||||
const picture = useResumeStore((state) => state.resume.data.picture);
|
||||
|
||||
if (picture.url === "") return null;
|
||||
if (picture.url === "") return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn("page-picture shrink-0 overflow-hidden", picture.hidden && "hidden", className)}
|
||||
style={{
|
||||
maxWidth: `${picture.size}pt`,
|
||||
maxHeight: `${picture.size}pt`,
|
||||
aspectRatio: picture.aspectRatio,
|
||||
borderRadius: `${picture.borderRadius}%`,
|
||||
border: picture.borderWidth > 0 ? `${picture.borderWidth}pt solid ${picture.borderColor}` : "none",
|
||||
boxShadow: picture.shadowWidth > 0 ? `0 0 ${picture.shadowWidth}pt 0 ${picture.shadowColor}` : "none",
|
||||
...style,
|
||||
}}
|
||||
>
|
||||
<img
|
||||
alt={name}
|
||||
src={picture.url}
|
||||
className="size-full object-cover"
|
||||
style={{ transform: `rotate(${picture.rotation}deg)` }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
return (
|
||||
<div
|
||||
className={cn("page-picture shrink-0 overflow-hidden", picture.hidden && "hidden", className)}
|
||||
style={{
|
||||
maxWidth: `${picture.size}pt`,
|
||||
maxHeight: `${picture.size}pt`,
|
||||
aspectRatio: picture.aspectRatio,
|
||||
borderRadius: `${picture.borderRadius}%`,
|
||||
border: picture.borderWidth > 0 ? `${picture.borderWidth}pt solid ${picture.borderColor}` : "none",
|
||||
boxShadow: picture.shadowWidth > 0 ? `0 0 ${picture.shadowWidth}pt 0 ${picture.shadowColor}` : "none",
|
||||
...style,
|
||||
}}
|
||||
>
|
||||
<img
|
||||
alt={name}
|
||||
src={picture.url}
|
||||
className="size-full object-cover"
|
||||
style={{ transform: `rotate(${picture.rotation}deg)` }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -6,33 +6,33 @@ import { cn } from "@/utils/style";
|
||||
import { useResumeStore } from "../store/resume";
|
||||
|
||||
type PageSectionProps<T extends SectionType> = {
|
||||
type: T;
|
||||
className?: string;
|
||||
children: (item: SectionItem<T>) => React.ReactNode;
|
||||
type: T;
|
||||
className?: string;
|
||||
children: (item: SectionItem<T>) => React.ReactNode;
|
||||
};
|
||||
|
||||
export function PageSection<T extends SectionType>({ type, className, children }: PageSectionProps<T>) {
|
||||
const section = useResumeStore((state) => state.resume.data.sections[type]);
|
||||
const section = useResumeStore((state) => state.resume.data.sections[type]);
|
||||
|
||||
const items = section.items.filter((item) => !item.hidden);
|
||||
const items = section.items.filter((item) => !item.hidden);
|
||||
|
||||
if (section.hidden) return null;
|
||||
if (items.length === 0) return null;
|
||||
if (section.hidden) return null;
|
||||
if (items.length === 0) return null;
|
||||
|
||||
return (
|
||||
<section className={cn(`page-section page-section-${type}`, className)}>
|
||||
<h6 className="mb-1.5 text-(--page-primary-color)">{section.title || getSectionTitle(type)}</h6>
|
||||
return (
|
||||
<section className={cn(`page-section page-section-${type}`, className)}>
|
||||
<h6 className="mb-1.5 text-(--page-primary-color)">{section.title || getSectionTitle(type)}</h6>
|
||||
|
||||
<div
|
||||
className="section-content grid gap-x-(--page-gap-x) gap-y-(--page-gap-y)"
|
||||
style={{ gridTemplateColumns: `repeat(${section.columns}, 1fr)` }}
|
||||
>
|
||||
{items.map((item) => (
|
||||
<div key={item.id} className={cn(`section-item section-item-${type} print:break-inside-avoid`)}>
|
||||
{children(item)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
<div
|
||||
className="section-content grid gap-x-(--page-gap-x) gap-y-(--page-gap-y)"
|
||||
style={{ gridTemplateColumns: `repeat(${section.columns}, 1fr)` }}
|
||||
>
|
||||
{items.map((item) => (
|
||||
<div key={item.id} className={cn(`section-item section-item-${type} print:break-inside-avoid`)}>
|
||||
{children(item)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -6,21 +6,21 @@ import { cn } from "@/utils/style";
|
||||
import { useResumeStore } from "../store/resume";
|
||||
|
||||
type PageSummaryProps = {
|
||||
className?: string;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export function PageSummary({ className }: PageSummaryProps) {
|
||||
const section = useResumeStore((state) => state.resume.data.summary);
|
||||
const section = useResumeStore((state) => state.resume.data.summary);
|
||||
|
||||
if (section.hidden || !stripHtml(section.content)) return null;
|
||||
if (section.hidden || !stripHtml(section.content)) return null;
|
||||
|
||||
return (
|
||||
<section className={cn("page-section page-section-summary", className)}>
|
||||
<h6 className="mb-1.5 text-(--page-primary-color)">{section.title || getSectionTitle("summary")}</h6>
|
||||
return (
|
||||
<section className={cn("page-section page-section-summary", className)}>
|
||||
<h6 className="mb-1.5 text-(--page-primary-color)">{section.title || getSectionTitle("summary")}</h6>
|
||||
|
||||
<div className="section-content">
|
||||
<TiptapContent style={{ columnCount: section.columns }} content={section.content} />
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
<div className="section-content">
|
||||
<TiptapContent style={{ columnCount: section.columns }} content={section.content} />
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -18,13 +18,13 @@ import { orpc, type RouterOutput } from "@/integrations/orpc/client";
|
||||
type Resume = Pick<RouterOutput["resume"]["getByIdForPrinter"], "id" | "name" | "slug" | "tags" | "data" | "isLocked">;
|
||||
|
||||
type ResumeStoreState = {
|
||||
resume: Resume;
|
||||
isReady: boolean;
|
||||
resume: Resume;
|
||||
isReady: boolean;
|
||||
};
|
||||
|
||||
type ResumeStoreActions = {
|
||||
initialize: (resume: Resume | null) => void;
|
||||
updateResumeData: (fn: (draft: WritableDraft<ResumeData>) => void) => void;
|
||||
initialize: (resume: Resume | null) => void;
|
||||
updateResumeData: (fn: (draft: WritableDraft<ResumeData>) => void) => void;
|
||||
};
|
||||
|
||||
type ResumeStore = ResumeStoreState & ResumeStoreActions;
|
||||
@@ -33,7 +33,7 @@ const controller = new AbortController();
|
||||
const signal = controller.signal;
|
||||
|
||||
const _syncResume = (resume: Resume) => {
|
||||
void orpc.resume.update.call({ id: resume.id, data: resume.data }, { signal });
|
||||
void orpc.resume.update.call({ id: resume.id, data: resume.data }, { signal });
|
||||
};
|
||||
|
||||
const syncResume = debounce(_syncResume, 500, { signal });
|
||||
@@ -43,41 +43,41 @@ let errorToastId: string | number | undefined;
|
||||
type PartializedState = { resume: Resume | null };
|
||||
|
||||
export const useResumeStore = create<ResumeStore>()(
|
||||
temporal(
|
||||
immer((set) => ({
|
||||
resume: null as unknown as Resume,
|
||||
isReady: false,
|
||||
temporal(
|
||||
immer((set) => ({
|
||||
resume: null as unknown as Resume,
|
||||
isReady: false,
|
||||
|
||||
initialize: (resume) => {
|
||||
set((state) => {
|
||||
state.resume = resume as Resume;
|
||||
state.isReady = resume !== null;
|
||||
useResumeStore.temporal.getState().clear();
|
||||
});
|
||||
},
|
||||
initialize: (resume) => {
|
||||
set((state) => {
|
||||
state.resume = resume as Resume;
|
||||
state.isReady = resume !== null;
|
||||
useResumeStore.temporal.getState().clear();
|
||||
});
|
||||
},
|
||||
|
||||
updateResumeData: (fn) => {
|
||||
set((state) => {
|
||||
if (!state.resume) return state;
|
||||
updateResumeData: (fn) => {
|
||||
set((state) => {
|
||||
if (!state.resume) return state;
|
||||
|
||||
if (state.resume.isLocked) {
|
||||
errorToastId = toast.error(t`This resume is locked and cannot be updated.`, { id: errorToastId });
|
||||
return state;
|
||||
}
|
||||
if (state.resume.isLocked) {
|
||||
errorToastId = toast.error(t`This resume is locked and cannot be updated.`, { id: errorToastId });
|
||||
return state;
|
||||
}
|
||||
|
||||
fn(state.resume.data);
|
||||
syncResume(current(state.resume));
|
||||
});
|
||||
},
|
||||
})),
|
||||
{
|
||||
partialize: (state) => ({ resume: state.resume }),
|
||||
equality: (pastState, currentState) => isDeepEqual(pastState, currentState),
|
||||
limit: 100,
|
||||
},
|
||||
),
|
||||
fn(state.resume.data);
|
||||
syncResume(current(state.resume));
|
||||
});
|
||||
},
|
||||
})),
|
||||
{
|
||||
partialize: (state) => ({ resume: state.resume }),
|
||||
equality: (pastState, currentState) => isDeepEqual(pastState, currentState),
|
||||
limit: 100,
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
export function useTemporalStore<T>(selector: (state: TemporalState<PartializedState>) => T): T {
|
||||
return useStoreWithEqualityFn(useResumeStore.temporal, selector);
|
||||
return useStoreWithEqualityFn(useResumeStore.temporal, selector);
|
||||
}
|
||||
|
||||
@@ -11,136 +11,136 @@ import { PagePicture } from "../shared/page-picture";
|
||||
import { useResumeStore } from "../store/resume";
|
||||
|
||||
const sectionClassName = cn(
|
||||
// Heading Decoration in Sidebar Layout
|
||||
"group-data-[layout=sidebar]:[&>h6]:px-4",
|
||||
"group-data-[layout=sidebar]:[&>h6]:relative",
|
||||
"group-data-[layout=sidebar]:[&>h6]:inline-flex",
|
||||
"group-data-[layout=sidebar]:[&>h6]:items-center",
|
||||
"group-data-[layout=sidebar]:[&>h6]:before:content-['']",
|
||||
"group-data-[layout=sidebar]:[&>h6]:before:absolute",
|
||||
"group-data-[layout=sidebar]:[&>h6]:before:left-0",
|
||||
"group-data-[layout=sidebar]:[&>h6]:before:rounded-full",
|
||||
"group-data-[layout=sidebar]:[&>h6]:before:size-2",
|
||||
"group-data-[layout=sidebar]:[&>h6]:before:border",
|
||||
"group-data-[layout=sidebar]:[&>h6]:before:border-(--page-primary-color)",
|
||||
"group-data-[layout=sidebar]:[&>h6]:after:content-['']",
|
||||
"group-data-[layout=sidebar]:[&>h6]:after:absolute",
|
||||
"group-data-[layout=sidebar]:[&>h6]:after:right-0",
|
||||
"group-data-[layout=sidebar]:[&>h6]:after:rounded-full",
|
||||
"group-data-[layout=sidebar]:[&>h6]:after:size-2",
|
||||
"group-data-[layout=sidebar]:[&>h6]:after:border",
|
||||
"group-data-[layout=sidebar]:[&>h6]:after:border-(--page-primary-color)",
|
||||
// Heading Decoration in Sidebar Layout
|
||||
"group-data-[layout=sidebar]:[&>h6]:px-4",
|
||||
"group-data-[layout=sidebar]:[&>h6]:relative",
|
||||
"group-data-[layout=sidebar]:[&>h6]:inline-flex",
|
||||
"group-data-[layout=sidebar]:[&>h6]:items-center",
|
||||
"group-data-[layout=sidebar]:[&>h6]:before:content-['']",
|
||||
"group-data-[layout=sidebar]:[&>h6]:before:absolute",
|
||||
"group-data-[layout=sidebar]:[&>h6]:before:left-0",
|
||||
"group-data-[layout=sidebar]:[&>h6]:before:rounded-full",
|
||||
"group-data-[layout=sidebar]:[&>h6]:before:size-2",
|
||||
"group-data-[layout=sidebar]:[&>h6]:before:border",
|
||||
"group-data-[layout=sidebar]:[&>h6]:before:border-(--page-primary-color)",
|
||||
"group-data-[layout=sidebar]:[&>h6]:after:content-['']",
|
||||
"group-data-[layout=sidebar]:[&>h6]:after:absolute",
|
||||
"group-data-[layout=sidebar]:[&>h6]:after:right-0",
|
||||
"group-data-[layout=sidebar]:[&>h6]:after:rounded-full",
|
||||
"group-data-[layout=sidebar]:[&>h6]:after:size-2",
|
||||
"group-data-[layout=sidebar]:[&>h6]:after:border",
|
||||
"group-data-[layout=sidebar]:[&>h6]:after:border-(--page-primary-color)",
|
||||
|
||||
// Section in Sidebar Layout
|
||||
"group-data-[layout=sidebar]:[&_.section-item-header>div]:flex-col",
|
||||
"group-data-[layout=sidebar]:[&_.section-item-header>div]:items-start",
|
||||
// Section in Sidebar Layout
|
||||
"group-data-[layout=sidebar]:[&_.section-item-header>div]:flex-col",
|
||||
"group-data-[layout=sidebar]:[&_.section-item-header>div]:items-start",
|
||||
|
||||
// Section in Main Layout
|
||||
"group-data-[layout=main]:[&>.section-content]:relative",
|
||||
"group-data-[layout=main]:[&>.section-content]:ml-4",
|
||||
"group-data-[layout=main]:[&>.section-content]:pl-4",
|
||||
"group-data-[layout=main]:[&>.section-content]:border-l",
|
||||
"group-data-[layout=main]:[&>.section-content]:border-(--page-primary-color)",
|
||||
// Section in Main Layout
|
||||
"group-data-[layout=main]:[&>.section-content]:relative",
|
||||
"group-data-[layout=main]:[&>.section-content]:ml-4",
|
||||
"group-data-[layout=main]:[&>.section-content]:pl-4",
|
||||
"group-data-[layout=main]:[&>.section-content]:border-l",
|
||||
"group-data-[layout=main]:[&>.section-content]:border-(--page-primary-color)",
|
||||
|
||||
// Timeline Marker in Main Layout
|
||||
"group-data-[layout=main]:[&>.section-content]:after:content-['']",
|
||||
"group-data-[layout=main]:[&>.section-content]:after:absolute",
|
||||
"group-data-[layout=main]:[&>.section-content]:after:top-5",
|
||||
"group-data-[layout=main]:[&>.section-content]:after:left-0",
|
||||
"group-data-[layout=main]:[&>.section-content]:after:size-2.5",
|
||||
"group-data-[layout=main]:[&>.section-content]:after:translate-x-[-50%]",
|
||||
"group-data-[layout=main]:[&>.section-content]:after:translate-y-[-50%]",
|
||||
"group-data-[layout=main]:[&>.section-content]:after:rounded-full",
|
||||
"group-data-[layout=main]:[&>.section-content]:after:border",
|
||||
"group-data-[layout=main]:[&>.section-content]:after:border-(--page-primary-color)",
|
||||
"group-data-[layout=main]:[&>.section-content]:after:bg-(--page-background-color)",
|
||||
// Timeline Marker in Main Layout
|
||||
"group-data-[layout=main]:[&>.section-content]:after:content-['']",
|
||||
"group-data-[layout=main]:[&>.section-content]:after:absolute",
|
||||
"group-data-[layout=main]:[&>.section-content]:after:top-5",
|
||||
"group-data-[layout=main]:[&>.section-content]:after:left-0",
|
||||
"group-data-[layout=main]:[&>.section-content]:after:size-2.5",
|
||||
"group-data-[layout=main]:[&>.section-content]:after:translate-x-[-50%]",
|
||||
"group-data-[layout=main]:[&>.section-content]:after:translate-y-[-50%]",
|
||||
"group-data-[layout=main]:[&>.section-content]:after:rounded-full",
|
||||
"group-data-[layout=main]:[&>.section-content]:after:border",
|
||||
"group-data-[layout=main]:[&>.section-content]:after:border-(--page-primary-color)",
|
||||
"group-data-[layout=main]:[&>.section-content]:after:bg-(--page-background-color)",
|
||||
);
|
||||
|
||||
/**
|
||||
* Template: Azurill
|
||||
*/
|
||||
export function AzurillTemplate({ pageIndex, pageLayout }: TemplateProps) {
|
||||
const isFirstPage = pageIndex === 0;
|
||||
const { main, sidebar, fullWidth } = pageLayout;
|
||||
const isFirstPage = pageIndex === 0;
|
||||
const { main, sidebar, fullWidth } = pageLayout;
|
||||
|
||||
return (
|
||||
<div className="template-azurill page-content space-y-(--page-gap-y) px-(--page-margin-x) pt-(--page-margin-y) print:p-0">
|
||||
{isFirstPage && <Header />}
|
||||
return (
|
||||
<div className="template-azurill page-content space-y-(--page-gap-y) px-(--page-margin-x) pt-(--page-margin-y) print:p-0">
|
||||
{isFirstPage && <Header />}
|
||||
|
||||
<div className="flex gap-x-(--page-gap-x)">
|
||||
{!fullWidth && (
|
||||
<aside
|
||||
data-layout="sidebar"
|
||||
className="group page-sidebar w-(--page-sidebar-width) shrink-0 space-y-(--page-gap-y) overflow-x-hidden"
|
||||
>
|
||||
{sidebar.map((section) => {
|
||||
const Component = getSectionComponent(section, { sectionClassName });
|
||||
return <Component key={section} id={section} />;
|
||||
})}
|
||||
</aside>
|
||||
)}
|
||||
<div className="flex gap-x-(--page-gap-x)">
|
||||
{!fullWidth && (
|
||||
<aside
|
||||
data-layout="sidebar"
|
||||
className="group page-sidebar w-(--page-sidebar-width) shrink-0 space-y-(--page-gap-y) overflow-x-hidden"
|
||||
>
|
||||
{sidebar.map((section) => {
|
||||
const Component = getSectionComponent(section, { sectionClassName });
|
||||
return <Component key={section} id={section} />;
|
||||
})}
|
||||
</aside>
|
||||
)}
|
||||
|
||||
<main data-layout="main" className="group page-main grow space-y-(--page-gap-y)">
|
||||
{main.map((section) => {
|
||||
const Component = getSectionComponent(section, { sectionClassName });
|
||||
return <Component key={section} id={section} />;
|
||||
})}
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
<main data-layout="main" className="group page-main grow space-y-(--page-gap-y)">
|
||||
{main.map((section) => {
|
||||
const Component = getSectionComponent(section, { sectionClassName });
|
||||
return <Component key={section} id={section} />;
|
||||
})}
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Header() {
|
||||
const basics = useResumeStore((state) => state.resume.data.basics);
|
||||
const basics = useResumeStore((state) => state.resume.data.basics);
|
||||
|
||||
return (
|
||||
<div className="page-header flex flex-col items-center gap-y-(--page-gap-y)">
|
||||
<PagePicture />
|
||||
return (
|
||||
<div className="page-header flex flex-col items-center gap-y-(--page-gap-y)">
|
||||
<PagePicture />
|
||||
|
||||
<div className="page-basics space-y-(--page-gap-y) text-center">
|
||||
<div className="basics-header">
|
||||
<h2 className="basics-name">{basics.name}</h2>
|
||||
<p className="basics-headline">{basics.headline}</p>
|
||||
</div>
|
||||
<div className="page-basics space-y-(--page-gap-y) text-center">
|
||||
<div className="basics-header">
|
||||
<h2 className="basics-name">{basics.name}</h2>
|
||||
<p className="basics-headline">{basics.headline}</p>
|
||||
</div>
|
||||
|
||||
<div className="basics-items flex flex-wrap justify-center gap-x-3 gap-y-1 *:flex *:items-center *:gap-x-1.5">
|
||||
{basics.email && (
|
||||
<div className="basics-item-email">
|
||||
<EnvelopeIcon />
|
||||
<PageLink url={`mailto:${basics.email}`} label={basics.email} />
|
||||
</div>
|
||||
)}
|
||||
<div className="basics-items flex flex-wrap justify-center gap-x-3 gap-y-1 *:flex *:items-center *:gap-x-1.5">
|
||||
{basics.email && (
|
||||
<div className="basics-item-email">
|
||||
<EnvelopeIcon />
|
||||
<PageLink url={`mailto:${basics.email}`} label={basics.email} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{basics.phone && (
|
||||
<div className="basics-item-phone">
|
||||
<PhoneIcon />
|
||||
<PageLink url={`tel:${basics.phone}`} label={basics.phone} />
|
||||
</div>
|
||||
)}
|
||||
{basics.phone && (
|
||||
<div className="basics-item-phone">
|
||||
<PhoneIcon />
|
||||
<PageLink url={`tel:${basics.phone}`} label={basics.phone} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{basics.location && (
|
||||
<div className="basics-item-location">
|
||||
<MapPinIcon />
|
||||
<span>{basics.location}</span>
|
||||
</div>
|
||||
)}
|
||||
{basics.location && (
|
||||
<div className="basics-item-location">
|
||||
<MapPinIcon />
|
||||
<span>{basics.location}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{basics.website.url && (
|
||||
<div className="basics-item-website">
|
||||
<GlobeIcon />
|
||||
<PageLink {...basics.website} />
|
||||
</div>
|
||||
)}
|
||||
{basics.website.url && (
|
||||
<div className="basics-item-website">
|
||||
<GlobeIcon />
|
||||
<PageLink {...basics.website} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{basics.customFields.map((field) => (
|
||||
<div key={field.id} className="basics-item-custom">
|
||||
<PageIcon icon={field.icon} />
|
||||
{field.link ? <PageLink url={field.link} label={field.text} /> : <span>{field.text}</span>}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
{basics.customFields.map((field) => (
|
||||
<div key={field.id} className="basics-item-custom">
|
||||
<PageIcon icon={field.icon} />
|
||||
{field.link ? <PageLink url={field.link} label={field.text} /> : <span>{field.text}</span>}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -11,95 +11,95 @@ import { PagePicture } from "../shared/page-picture";
|
||||
import { useResumeStore } from "../store/resume";
|
||||
|
||||
const sectionClassName = cn(
|
||||
// Section Layout
|
||||
"grid grid-cols-5 border-t border-(--page-primary-color) pt-1",
|
||||
// Section Layout
|
||||
"grid grid-cols-5 border-t border-(--page-primary-color) pt-1",
|
||||
|
||||
// Section Content
|
||||
"[&>.section-content]:col-span-4",
|
||||
// Section Content
|
||||
"[&>.section-content]:col-span-4",
|
||||
);
|
||||
|
||||
/**
|
||||
* Template: Bronzor
|
||||
*/
|
||||
export function BronzorTemplate({ pageIndex, pageLayout }: TemplateProps) {
|
||||
const isFirstPage = pageIndex === 0;
|
||||
const { main, sidebar, fullWidth } = pageLayout;
|
||||
const isFirstPage = pageIndex === 0;
|
||||
const { main, sidebar, fullWidth } = pageLayout;
|
||||
|
||||
return (
|
||||
<div className="template-bronzor page-content space-y-(--page-gap-y) px-(--page-margin-x) pt-(--page-margin-y) print:p-0">
|
||||
{isFirstPage && <Header />}
|
||||
return (
|
||||
<div className="template-bronzor page-content space-y-(--page-gap-y) px-(--page-margin-x) pt-(--page-margin-y) print:p-0">
|
||||
{isFirstPage && <Header />}
|
||||
|
||||
<div className="space-y-(--page-gap-y)">
|
||||
<main data-layout="main" className="group page-main space-y-(--page-gap-y)">
|
||||
{main.map((section) => {
|
||||
const Component = getSectionComponent(section, { sectionClassName });
|
||||
return <Component key={section} id={section} />;
|
||||
})}
|
||||
</main>
|
||||
<div className="space-y-(--page-gap-y)">
|
||||
<main data-layout="main" className="group page-main space-y-(--page-gap-y)">
|
||||
{main.map((section) => {
|
||||
const Component = getSectionComponent(section, { sectionClassName });
|
||||
return <Component key={section} id={section} />;
|
||||
})}
|
||||
</main>
|
||||
|
||||
{!fullWidth && (
|
||||
<aside data-layout="sidebar" className="group page-sidebar space-y-(--page-gap-y)">
|
||||
{sidebar.map((section) => {
|
||||
const Component = getSectionComponent(section, { sectionClassName });
|
||||
return <Component key={section} id={section} />;
|
||||
})}
|
||||
</aside>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
{!fullWidth && (
|
||||
<aside data-layout="sidebar" className="group page-sidebar space-y-(--page-gap-y)">
|
||||
{sidebar.map((section) => {
|
||||
const Component = getSectionComponent(section, { sectionClassName });
|
||||
return <Component key={section} id={section} />;
|
||||
})}
|
||||
</aside>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Header() {
|
||||
const basics = useResumeStore((state) => state.resume.data.basics);
|
||||
const basics = useResumeStore((state) => state.resume.data.basics);
|
||||
|
||||
return (
|
||||
<div className="page-header flex flex-col items-center gap-y-2">
|
||||
<PagePicture />
|
||||
return (
|
||||
<div className="page-header flex flex-col items-center gap-y-2">
|
||||
<PagePicture />
|
||||
|
||||
<div className="page-basics space-y-2 text-center">
|
||||
<div className="basics-header">
|
||||
<h2 className="basics-name">{basics.name}</h2>
|
||||
<p className="basics-headline">{basics.headline}</p>
|
||||
</div>
|
||||
<div className="page-basics space-y-2 text-center">
|
||||
<div className="basics-header">
|
||||
<h2 className="basics-name">{basics.name}</h2>
|
||||
<p className="basics-headline">{basics.headline}</p>
|
||||
</div>
|
||||
|
||||
<div className="basics-items flex flex-wrap justify-center gap-x-3 gap-y-1 text-center *:flex *:items-center *:gap-x-1.5">
|
||||
{basics.email && (
|
||||
<div className="basics-item-email">
|
||||
<EnvelopeIcon />
|
||||
<PageLink url={`mailto:${basics.email}`} label={basics.email} />
|
||||
</div>
|
||||
)}
|
||||
<div className="basics-items flex flex-wrap justify-center gap-x-3 gap-y-1 text-center *:flex *:items-center *:gap-x-1.5">
|
||||
{basics.email && (
|
||||
<div className="basics-item-email">
|
||||
<EnvelopeIcon />
|
||||
<PageLink url={`mailto:${basics.email}`} label={basics.email} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{basics.phone && (
|
||||
<div className="basics-item-phone">
|
||||
<PhoneIcon />
|
||||
<PageLink url={`tel:${basics.phone}`} label={basics.phone} />
|
||||
</div>
|
||||
)}
|
||||
{basics.phone && (
|
||||
<div className="basics-item-phone">
|
||||
<PhoneIcon />
|
||||
<PageLink url={`tel:${basics.phone}`} label={basics.phone} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{basics.location && (
|
||||
<div className="basics-item-location">
|
||||
<MapPinIcon />
|
||||
<span>{basics.location}</span>
|
||||
</div>
|
||||
)}
|
||||
{basics.location && (
|
||||
<div className="basics-item-location">
|
||||
<MapPinIcon />
|
||||
<span>{basics.location}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{basics.website.url && (
|
||||
<div className="basics-item-website">
|
||||
<GlobeIcon />
|
||||
<PageLink {...basics.website} />
|
||||
</div>
|
||||
)}
|
||||
{basics.website.url && (
|
||||
<div className="basics-item-website">
|
||||
<GlobeIcon />
|
||||
<PageLink {...basics.website} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{basics.customFields.map((field) => (
|
||||
<div key={field.id} className="basics-item-custom">
|
||||
<PageIcon icon={field.icon} />
|
||||
{field.link ? <PageLink url={field.link} label={field.text} /> : <span>{field.text}</span>}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
{basics.customFields.map((field) => (
|
||||
<div key={field.id} className="basics-item-custom">
|
||||
<PageIcon icon={field.icon} />
|
||||
{field.link ? <PageLink url={field.link} label={field.text} /> : <span>{field.text}</span>}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -11,120 +11,120 @@ import { PagePicture } from "../shared/page-picture";
|
||||
import { useResumeStore } from "../store/resume";
|
||||
|
||||
const sectionClassName = cn(
|
||||
// Section Heading
|
||||
"[&>h6]:border-b [&>h6]:border-(--page-primary-color)",
|
||||
// Section Heading
|
||||
"[&>h6]:border-b [&>h6]:border-(--page-primary-color)",
|
||||
|
||||
// Section Heading in Sidebar Layout
|
||||
"group-data-[layout=sidebar]:[&>h6]:text-(--page-background-color)",
|
||||
"group-data-[layout=sidebar]:[&>h6]:border-(--page-background-color)",
|
||||
// Section Heading in Sidebar Layout
|
||||
"group-data-[layout=sidebar]:[&>h6]:text-(--page-background-color)",
|
||||
"group-data-[layout=sidebar]:[&>h6]:border-(--page-background-color)",
|
||||
|
||||
// Icon Colors in Sidebar Layout
|
||||
"group-data-[layout=sidebar]:[&_.section-item_i]:text-(--page-background-color)!",
|
||||
// Icon Colors in Sidebar Layout
|
||||
"group-data-[layout=sidebar]:[&_.section-item_i]:text-(--page-background-color)!",
|
||||
|
||||
// Level Display in Sidebar Layout
|
||||
"group-data-[layout=sidebar]:[&_.section-item-level>div]:border-(--page-background-color)",
|
||||
"group-data-[layout=sidebar]:[&_.section-item-level>div]:data-[active=true]:bg-(--page-background-color)",
|
||||
// Level Display in Sidebar Layout
|
||||
"group-data-[layout=sidebar]:[&_.section-item-level>div]:border-(--page-background-color)",
|
||||
"group-data-[layout=sidebar]:[&_.section-item-level>div]:data-[active=true]:bg-(--page-background-color)",
|
||||
|
||||
// Section Item Header in Sidebar Layout
|
||||
"group-data-[layout=sidebar]:[&_.section-item-header>div]:flex-col",
|
||||
"group-data-[layout=sidebar]:[&_.section-item-header>div]:items-start",
|
||||
// Section Item Header in Sidebar Layout
|
||||
"group-data-[layout=sidebar]:[&_.section-item-header>div]:flex-col",
|
||||
"group-data-[layout=sidebar]:[&_.section-item-header>div]:items-start",
|
||||
);
|
||||
|
||||
/**
|
||||
* Template: Chikorita
|
||||
*/
|
||||
export function ChikoritaTemplate({ pageIndex, pageLayout }: TemplateProps) {
|
||||
const isFirstPage = pageIndex === 0;
|
||||
const { main, sidebar, fullWidth } = pageLayout;
|
||||
const isFirstPage = pageIndex === 0;
|
||||
const { main, sidebar, fullWidth } = pageLayout;
|
||||
|
||||
return (
|
||||
<div className="template-chikorita page-content">
|
||||
{/* Sidebar Background */}
|
||||
{!fullWidth && (
|
||||
<div className="page-sidebar-background pointer-events-none absolute inset-y-0 z-0 w-(--page-sidebar-width) shrink-0 bg-(--page-primary-color) ltr:inset-e-0 rtl:inset-s-0" />
|
||||
)}
|
||||
return (
|
||||
<div className="template-chikorita page-content">
|
||||
{/* Sidebar Background */}
|
||||
{!fullWidth && (
|
||||
<div className="page-sidebar-background pointer-events-none absolute inset-y-0 z-0 w-(--page-sidebar-width) shrink-0 bg-(--page-primary-color) ltr:inset-e-0 rtl:inset-s-0" />
|
||||
)}
|
||||
|
||||
<div className="flex">
|
||||
<main
|
||||
data-layout="main"
|
||||
className="group page-main z-10 flex-1 space-y-4 px-(--page-margin-x) pt-(--page-margin-y)"
|
||||
>
|
||||
{isFirstPage && <Header />}
|
||||
<div className="flex">
|
||||
<main
|
||||
data-layout="main"
|
||||
className="group page-main z-10 flex-1 space-y-4 px-(--page-margin-x) pt-(--page-margin-y)"
|
||||
>
|
||||
{isFirstPage && <Header />}
|
||||
|
||||
{main.map((section) => {
|
||||
const Component = getSectionComponent(section, { sectionClassName });
|
||||
return <Component key={section} id={section} />;
|
||||
})}
|
||||
</main>
|
||||
{main.map((section) => {
|
||||
const Component = getSectionComponent(section, { sectionClassName });
|
||||
return <Component key={section} id={section} />;
|
||||
})}
|
||||
</main>
|
||||
|
||||
{!fullWidth && (
|
||||
<aside
|
||||
data-layout="sidebar"
|
||||
className="group page-sidebar z-10 w-(--page-sidebar-width) shrink-0 space-y-4 overflow-x-hidden px-(--page-margin-x) pt-(--page-margin-y) text-(--page-background-color)"
|
||||
>
|
||||
{sidebar.map((section) => {
|
||||
const Component = getSectionComponent(section, { sectionClassName });
|
||||
return <Component key={section} id={section} />;
|
||||
})}
|
||||
</aside>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
{!fullWidth && (
|
||||
<aside
|
||||
data-layout="sidebar"
|
||||
className="group page-sidebar z-10 w-(--page-sidebar-width) shrink-0 space-y-4 overflow-x-hidden px-(--page-margin-x) pt-(--page-margin-y) text-(--page-background-color)"
|
||||
>
|
||||
{sidebar.map((section) => {
|
||||
const Component = getSectionComponent(section, { sectionClassName });
|
||||
return <Component key={section} id={section} />;
|
||||
})}
|
||||
</aside>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Header() {
|
||||
const basics = useResumeStore((state) => state.resume.data.basics);
|
||||
const basics = useResumeStore((state) => state.resume.data.basics);
|
||||
|
||||
return (
|
||||
<div className="page-header relative flex">
|
||||
<div className="flex flex-1 items-center gap-x-(--page-margin-x)">
|
||||
<PagePicture />
|
||||
return (
|
||||
<div className="page-header relative flex">
|
||||
<div className="flex flex-1 items-center gap-x-(--page-margin-x)">
|
||||
<PagePicture />
|
||||
|
||||
<div className="page-basics space-y-2">
|
||||
<div>
|
||||
<h2 className="basics-name">{basics.name}</h2>
|
||||
<p className="basics-headline">{basics.headline}</p>
|
||||
</div>
|
||||
<div className="page-basics space-y-2">
|
||||
<div>
|
||||
<h2 className="basics-name">{basics.name}</h2>
|
||||
<p className="basics-headline">{basics.headline}</p>
|
||||
</div>
|
||||
|
||||
<div className="basics-items flex flex-wrap gap-x-2 gap-y-0.5 *:flex *:items-center *:gap-x-1.5">
|
||||
{basics.email && (
|
||||
<div className="basics-item-email">
|
||||
<EnvelopeIcon />
|
||||
<PageLink url={`mailto:${basics.email}`} label={basics.email} />
|
||||
</div>
|
||||
)}
|
||||
<div className="basics-items flex flex-wrap gap-x-2 gap-y-0.5 *:flex *:items-center *:gap-x-1.5">
|
||||
{basics.email && (
|
||||
<div className="basics-item-email">
|
||||
<EnvelopeIcon />
|
||||
<PageLink url={`mailto:${basics.email}`} label={basics.email} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{basics.phone && (
|
||||
<div className="basics-item-phone">
|
||||
<PhoneIcon />
|
||||
<PageLink url={`tel:${basics.phone}`} label={basics.phone} />
|
||||
</div>
|
||||
)}
|
||||
{basics.phone && (
|
||||
<div className="basics-item-phone">
|
||||
<PhoneIcon />
|
||||
<PageLink url={`tel:${basics.phone}`} label={basics.phone} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{basics.location && (
|
||||
<div className="basics-item-location">
|
||||
<MapPinIcon />
|
||||
<span>{basics.location}</span>
|
||||
</div>
|
||||
)}
|
||||
{basics.location && (
|
||||
<div className="basics-item-location">
|
||||
<MapPinIcon />
|
||||
<span>{basics.location}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{basics.website.url && (
|
||||
<div className="basics-item-website">
|
||||
<GlobeIcon />
|
||||
<PageLink {...basics.website} />
|
||||
</div>
|
||||
)}
|
||||
{basics.website.url && (
|
||||
<div className="basics-item-website">
|
||||
<GlobeIcon />
|
||||
<PageLink {...basics.website} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{basics.customFields.map((field) => (
|
||||
<div key={field.id} className="basics-item-custom">
|
||||
<PageIcon icon={field.icon} />
|
||||
{field.link ? <PageLink url={field.link} label={field.text} /> : <span>{field.text}</span>}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
{basics.customFields.map((field) => (
|
||||
<div key={field.id} className="basics-item-custom">
|
||||
<PageIcon icon={field.icon} />
|
||||
{field.link ? <PageLink url={field.link} label={field.text} /> : <span>{field.text}</span>}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -9,110 +9,110 @@ import { PagePicture } from "../shared/page-picture";
|
||||
import { useResumeStore } from "../store/resume";
|
||||
|
||||
const sectionClassName = cn(
|
||||
// Section Heading
|
||||
"[&>h6]:border-b [&>h6]:border-(--page-primary-color)",
|
||||
// Section Heading
|
||||
"[&>h6]:border-b [&>h6]:border-(--page-primary-color)",
|
||||
|
||||
// Section Item Header in Sidebar Layout
|
||||
"group-data-[layout=sidebar]:[&_.section-item-header>div]:flex-col",
|
||||
"group-data-[layout=sidebar]:[&_.section-item-header>div]:items-start",
|
||||
// Section Item Header in Sidebar Layout
|
||||
"group-data-[layout=sidebar]:[&_.section-item-header>div]:flex-col",
|
||||
"group-data-[layout=sidebar]:[&_.section-item-header>div]:items-start",
|
||||
|
||||
// Decoration Line in Section Item Header
|
||||
"group-data-[layout=main]:[&_.section-item-header]:ps-2",
|
||||
"group-data-[layout=main]:[&_.section-item-header]:py-0.5",
|
||||
"group-data-[layout=main]:[&_.section-item-header]:-ms-2.5",
|
||||
"group-data-[layout=main]:[&_.section-item-header]:border-s-2",
|
||||
"group-data-[layout=main]:[&_.section-item-header]:border-(--page-primary-color)",
|
||||
// Decoration Line in Section Item Header
|
||||
"group-data-[layout=main]:[&_.section-item-header]:ps-2",
|
||||
"group-data-[layout=main]:[&_.section-item-header]:py-0.5",
|
||||
"group-data-[layout=main]:[&_.section-item-header]:-ms-2.5",
|
||||
"group-data-[layout=main]:[&_.section-item-header]:border-s-2",
|
||||
"group-data-[layout=main]:[&_.section-item-header]:border-(--page-primary-color)",
|
||||
);
|
||||
|
||||
/**
|
||||
* Template: Ditgar
|
||||
*/
|
||||
export function DitgarTemplate({ pageIndex, pageLayout }: TemplateProps) {
|
||||
const isFirstPage = pageIndex === 0;
|
||||
const { main, sidebar, fullWidth } = pageLayout;
|
||||
const isFirstPage = pageIndex === 0;
|
||||
const { main, sidebar, fullWidth } = pageLayout;
|
||||
|
||||
return (
|
||||
<div className="template-ditgar page-content">
|
||||
{/* Sidebar Background */}
|
||||
{(!fullWidth || isFirstPage) && (
|
||||
<div className="page-sidebar-background pointer-events-none absolute inset-y-0 z-0 w-(--page-sidebar-width) shrink-0 bg-(--page-primary-color)/20 ltr:inset-s-0 rtl:inset-e-0" />
|
||||
)}
|
||||
return (
|
||||
<div className="template-ditgar page-content">
|
||||
{/* Sidebar Background */}
|
||||
{(!fullWidth || isFirstPage) && (
|
||||
<div className="page-sidebar-background pointer-events-none absolute inset-y-0 z-0 w-(--page-sidebar-width) shrink-0 bg-(--page-primary-color)/20 ltr:inset-s-0 rtl:inset-e-0" />
|
||||
)}
|
||||
|
||||
<div className="flex">
|
||||
{(!fullWidth || isFirstPage) && (
|
||||
<aside data-layout="sidebar" className="sidebar group z-10 flex w-(--page-sidebar-width) shrink-0 flex-col">
|
||||
{isFirstPage && <Header />}
|
||||
<div className="flex">
|
||||
{(!fullWidth || isFirstPage) && (
|
||||
<aside data-layout="sidebar" className="sidebar group z-10 flex w-(--page-sidebar-width) shrink-0 flex-col">
|
||||
{isFirstPage && <Header />}
|
||||
|
||||
<div className="flex-1 space-y-4 px-(--page-margin-x) pt-(--page-margin-y)">
|
||||
{sidebar.map((section) => {
|
||||
const Component = getSectionComponent(section, { sectionClassName });
|
||||
return <Component key={section} id={section} />;
|
||||
})}
|
||||
</div>
|
||||
</aside>
|
||||
)}
|
||||
<div className="flex-1 space-y-4 px-(--page-margin-x) pt-(--page-margin-y)">
|
||||
{sidebar.map((section) => {
|
||||
const Component = getSectionComponent(section, { sectionClassName });
|
||||
return <Component key={section} id={section} />;
|
||||
})}
|
||||
</div>
|
||||
</aside>
|
||||
)}
|
||||
|
||||
<main data-layout="main" className={cn("main group z-10", !fullWidth ? "col-span-2" : "col-span-3")}>
|
||||
<div className="space-y-4 px-(--page-margin-x) pt-(--page-margin-y)">
|
||||
{main.map((section) => {
|
||||
const Component = getSectionComponent(section, { sectionClassName });
|
||||
return <Component key={section} id={section} />;
|
||||
})}
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
<main data-layout="main" className={cn("main group z-10", !fullWidth ? "col-span-2" : "col-span-3")}>
|
||||
<div className="space-y-4 px-(--page-margin-x) pt-(--page-margin-y)">
|
||||
{main.map((section) => {
|
||||
const Component = getSectionComponent(section, { sectionClassName });
|
||||
return <Component key={section} id={section} />;
|
||||
})}
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Header() {
|
||||
const basics = useResumeStore((state) => state.resume.data.basics);
|
||||
const basics = useResumeStore((state) => state.resume.data.basics);
|
||||
|
||||
return (
|
||||
<div className="page-header space-y-4 bg-(--page-primary-color) px-(--page-margin-x) py-(--page-margin-y) text-(--page-background-color)">
|
||||
<PagePicture />
|
||||
return (
|
||||
<div className="page-header space-y-4 bg-(--page-primary-color) px-(--page-margin-x) py-(--page-margin-y) text-(--page-background-color)">
|
||||
<PagePicture />
|
||||
|
||||
<div className="basics-header">
|
||||
<h2 className="basics-name text-2xl font-bold">{basics.name}</h2>
|
||||
<p className="basics-headline">{basics.headline}</p>
|
||||
</div>
|
||||
<div className="basics-header">
|
||||
<h2 className="basics-name text-2xl font-bold">{basics.name}</h2>
|
||||
<p className="basics-headline">{basics.headline}</p>
|
||||
</div>
|
||||
|
||||
<div className="basics-items flex flex-col items-start gap-y-2 text-sm [&>div>i]:text-(--page-background-color)!">
|
||||
{basics.location && (
|
||||
<div className="basics-item-location flex items-center gap-x-1.5">
|
||||
<PageIcon icon="map-pin" className="ph-bold" />
|
||||
<div>{basics.location}</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="basics-items flex flex-col items-start gap-y-2 text-sm [&>div>i]:text-(--page-background-color)!">
|
||||
{basics.location && (
|
||||
<div className="basics-item-location flex items-center gap-x-1.5">
|
||||
<PageIcon icon="map-pin" className="ph-bold" />
|
||||
<div>{basics.location}</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{basics.phone && (
|
||||
<div className="basics-item-phone flex items-center gap-x-1.5">
|
||||
<PageIcon icon="phone" className="ph-bold" />
|
||||
<PageLink url={`tel:${basics.phone}`} label={basics.phone} />
|
||||
</div>
|
||||
)}
|
||||
{basics.phone && (
|
||||
<div className="basics-item-phone flex items-center gap-x-1.5">
|
||||
<PageIcon icon="phone" className="ph-bold" />
|
||||
<PageLink url={`tel:${basics.phone}`} label={basics.phone} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{basics.email && (
|
||||
<div className="basics-item-email flex items-center gap-x-1.5">
|
||||
<PageIcon icon="at" className="ph-bold" />
|
||||
<PageLink url={`mailto:${basics.email}`} label={basics.email} />
|
||||
</div>
|
||||
)}
|
||||
{basics.email && (
|
||||
<div className="basics-item-email flex items-center gap-x-1.5">
|
||||
<PageIcon icon="at" className="ph-bold" />
|
||||
<PageLink url={`mailto:${basics.email}`} label={basics.email} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{basics.website.url && (
|
||||
<div className="basics-item-website flex items-center gap-x-1.5">
|
||||
<PageIcon icon="globe" className="ph-bold" />
|
||||
<PageLink {...basics.website} />
|
||||
</div>
|
||||
)}
|
||||
{basics.website.url && (
|
||||
<div className="basics-item-website flex items-center gap-x-1.5">
|
||||
<PageIcon icon="globe" className="ph-bold" />
|
||||
<PageLink {...basics.website} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{basics.customFields.map((field) => (
|
||||
<div key={field.id} className="basics-item-custom flex items-center gap-x-1.5">
|
||||
<PageIcon icon={field.icon} />
|
||||
{field.link ? <PageLink url={field.link} label={field.text} /> : <span>{field.text}</span>}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
{basics.customFields.map((field) => (
|
||||
<div key={field.id} className="basics-item-custom flex items-center gap-x-1.5">
|
||||
<PageIcon icon={field.icon} />
|
||||
{field.link ? <PageLink url={field.link} label={field.text} /> : <span>{field.text}</span>}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -11,104 +11,104 @@ import { PagePicture } from "../shared/page-picture";
|
||||
import { useResumeStore } from "../store/resume";
|
||||
|
||||
const sectionClassName = cn(
|
||||
// Section Item Header in Sidebar Layout
|
||||
"group-data-[layout=sidebar]:[&_.section-item-header>div]:flex-col",
|
||||
"group-data-[layout=sidebar]:[&_.section-item-header>div]:items-start",
|
||||
// Section Item Header in Sidebar Layout
|
||||
"group-data-[layout=sidebar]:[&_.section-item-header>div]:flex-col",
|
||||
"group-data-[layout=sidebar]:[&_.section-item-header>div]:items-start",
|
||||
);
|
||||
|
||||
/**
|
||||
* Template: Ditto
|
||||
*/
|
||||
export function DittoTemplate({ pageIndex, pageLayout }: TemplateProps) {
|
||||
const isFirstPage = pageIndex === 0;
|
||||
const { main, sidebar, fullWidth } = pageLayout;
|
||||
const isFirstPage = pageIndex === 0;
|
||||
const { main, sidebar, fullWidth } = pageLayout;
|
||||
|
||||
return (
|
||||
<div className="template-ditto page-content">
|
||||
{isFirstPage && <Header />}
|
||||
return (
|
||||
<div className="template-ditto page-content">
|
||||
{isFirstPage && <Header />}
|
||||
|
||||
<div className="flex pt-(--page-margin-y)">
|
||||
{!fullWidth && (
|
||||
<aside
|
||||
data-layout="sidebar"
|
||||
className="group page-sidebar w-(--page-sidebar-width) shrink-0 space-y-4 overflow-x-hidden ps-(--page-margin-x)"
|
||||
>
|
||||
{sidebar.map((section) => {
|
||||
const Component = getSectionComponent(section, { sectionClassName });
|
||||
return <Component key={section} id={section} />;
|
||||
})}
|
||||
</aside>
|
||||
)}
|
||||
<div className="flex pt-(--page-margin-y)">
|
||||
{!fullWidth && (
|
||||
<aside
|
||||
data-layout="sidebar"
|
||||
className="group page-sidebar w-(--page-sidebar-width) shrink-0 space-y-4 overflow-x-hidden ps-(--page-margin-x)"
|
||||
>
|
||||
{sidebar.map((section) => {
|
||||
const Component = getSectionComponent(section, { sectionClassName });
|
||||
return <Component key={section} id={section} />;
|
||||
})}
|
||||
</aside>
|
||||
)}
|
||||
|
||||
<main data-layout="main" className="group page-main space-y-4 px-(--page-margin-x)">
|
||||
{main.map((section) => {
|
||||
const Component = getSectionComponent(section, { sectionClassName });
|
||||
return <Component key={section} id={section} />;
|
||||
})}
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
<main data-layout="main" className="group page-main space-y-4 px-(--page-margin-x)">
|
||||
{main.map((section) => {
|
||||
const Component = getSectionComponent(section, { sectionClassName });
|
||||
return <Component key={section} id={section} />;
|
||||
})}
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Header() {
|
||||
const basics = useResumeStore((state) => state.resume.data.basics);
|
||||
const basics = useResumeStore((state) => state.resume.data.basics);
|
||||
|
||||
return (
|
||||
<div className="page-header relative">
|
||||
<div className="page-basics bg-(--page-primary-color) text-(--page-background-color)">
|
||||
<div className="basics-header flex items-center">
|
||||
<div className="flex w-(--page-sidebar-width) shrink-0 justify-center ps-(--page-margin-x)">
|
||||
<PagePicture className="absolute top-8" />
|
||||
</div>
|
||||
return (
|
||||
<div className="page-header relative">
|
||||
<div className="page-basics bg-(--page-primary-color) text-(--page-background-color)">
|
||||
<div className="basics-header flex items-center">
|
||||
<div className="flex w-(--page-sidebar-width) shrink-0 justify-center ps-(--page-margin-x)">
|
||||
<PagePicture className="absolute top-8" />
|
||||
</div>
|
||||
|
||||
<div className="px-(--page-margin-x) py-(--page-margin-y)">
|
||||
<h2 className="basics-name">{basics.name}</h2>
|
||||
<p className="basics-headline">{basics.headline}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="px-(--page-margin-x) py-(--page-margin-y)">
|
||||
<h2 className="basics-name">{basics.name}</h2>
|
||||
<p className="basics-headline">{basics.headline}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center">
|
||||
<div className="w-(--page-sidebar-width) shrink-0" />
|
||||
<div className="flex items-center">
|
||||
<div className="w-(--page-sidebar-width) shrink-0" />
|
||||
|
||||
<div className="basics-items flex flex-wrap gap-x-3 gap-y-1 px-(--page-margin-x) pt-3 *:flex *:items-center *:gap-x-1.5">
|
||||
{basics.email && (
|
||||
<div className="basics-item-email">
|
||||
<EnvelopeIcon />
|
||||
<PageLink url={`mailto:${basics.email}`} label={basics.email} />
|
||||
</div>
|
||||
)}
|
||||
<div className="basics-items flex flex-wrap gap-x-3 gap-y-1 px-(--page-margin-x) pt-3 *:flex *:items-center *:gap-x-1.5">
|
||||
{basics.email && (
|
||||
<div className="basics-item-email">
|
||||
<EnvelopeIcon />
|
||||
<PageLink url={`mailto:${basics.email}`} label={basics.email} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{basics.phone && (
|
||||
<div className="basics-item-phone">
|
||||
<PhoneIcon />
|
||||
<PageLink url={`tel:${basics.phone}`} label={basics.phone} />
|
||||
</div>
|
||||
)}
|
||||
{basics.phone && (
|
||||
<div className="basics-item-phone">
|
||||
<PhoneIcon />
|
||||
<PageLink url={`tel:${basics.phone}`} label={basics.phone} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{basics.location && (
|
||||
<div className="basics-item-location">
|
||||
<MapPinIcon />
|
||||
<span>{basics.location}</span>
|
||||
</div>
|
||||
)}
|
||||
{basics.location && (
|
||||
<div className="basics-item-location">
|
||||
<MapPinIcon />
|
||||
<span>{basics.location}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{basics.website.url && (
|
||||
<div className="basics-item-website">
|
||||
<GlobeIcon />
|
||||
<PageLink {...basics.website} />
|
||||
</div>
|
||||
)}
|
||||
{basics.website.url && (
|
||||
<div className="basics-item-website">
|
||||
<GlobeIcon />
|
||||
<PageLink {...basics.website} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{basics.customFields.map((field) => (
|
||||
<div key={field.id} className="basics-item-custom">
|
||||
<PageIcon icon={field.icon} />
|
||||
{field.link ? <PageLink url={field.link} label={field.text} /> : <span>{field.text}</span>}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
{basics.customFields.map((field) => (
|
||||
<div key={field.id} className="basics-item-custom">
|
||||
<PageIcon icon={field.icon} />
|
||||
{field.link ? <PageLink url={field.link} label={field.text} /> : <span>{field.text}</span>}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -10,126 +10,126 @@ import { PageSummary } from "../shared/page-summary";
|
||||
import { useResumeStore } from "../store/resume";
|
||||
|
||||
const sectionClassName = cn(
|
||||
// Section Heading
|
||||
"[&>h6]:border-b [&>h6]:border-(--page-primary-color)",
|
||||
// Section Heading
|
||||
"[&>h6]:border-b [&>h6]:border-(--page-primary-color)",
|
||||
|
||||
// Section Item Header in Sidebar Layout
|
||||
"group-data-[layout=sidebar]:[&_.section-item-header>div]:flex-col",
|
||||
"group-data-[layout=sidebar]:[&_.section-item-header>div]:items-start",
|
||||
// Section Item Header in Sidebar Layout
|
||||
"group-data-[layout=sidebar]:[&_.section-item-header>div]:flex-col",
|
||||
"group-data-[layout=sidebar]:[&_.section-item-header>div]:items-start",
|
||||
);
|
||||
|
||||
/**
|
||||
* Template: Gengar
|
||||
*/
|
||||
export function GengarTemplate({ pageIndex, pageLayout }: TemplateProps) {
|
||||
const isFirstPage = pageIndex === 0;
|
||||
const { main, sidebar, fullWidth } = pageLayout;
|
||||
const isFirstPage = pageIndex === 0;
|
||||
const { main, sidebar, fullWidth } = pageLayout;
|
||||
|
||||
return (
|
||||
<div className="template-gengar page-content">
|
||||
{/* Sidebar Background */}
|
||||
{(!fullWidth || isFirstPage) && (
|
||||
<div className="page-sidebar-background pointer-events-none absolute inset-y-0 z-0 w-(--page-sidebar-width) shrink-0 bg-(--page-primary-color)/20 ltr:inset-s-0 rtl:inset-e-0" />
|
||||
)}
|
||||
return (
|
||||
<div className="template-gengar page-content">
|
||||
{/* Sidebar Background */}
|
||||
{(!fullWidth || isFirstPage) && (
|
||||
<div className="page-sidebar-background pointer-events-none absolute inset-y-0 z-0 w-(--page-sidebar-width) shrink-0 bg-(--page-primary-color)/20 ltr:inset-s-0 rtl:inset-e-0" />
|
||||
)}
|
||||
|
||||
<div className="flex">
|
||||
{(!fullWidth || isFirstPage) && (
|
||||
<aside
|
||||
data-layout="sidebar"
|
||||
className="group page-sidebar z-10 flex w-(--page-sidebar-width) shrink-0 flex-col"
|
||||
>
|
||||
{isFirstPage && <Header />}
|
||||
<div className="flex">
|
||||
{(!fullWidth || isFirstPage) && (
|
||||
<aside
|
||||
data-layout="sidebar"
|
||||
className="group page-sidebar z-10 flex w-(--page-sidebar-width) shrink-0 flex-col"
|
||||
>
|
||||
{isFirstPage && <Header />}
|
||||
|
||||
{!fullWidth && (
|
||||
<div className="shrink-0 space-y-4 overflow-x-hidden px-(--page-margin-x) pt-(--page-margin-y)">
|
||||
{sidebar
|
||||
.filter((section) => section !== "summary")
|
||||
.map((section) => {
|
||||
const Component = getSectionComponent(section, { sectionClassName });
|
||||
return <Component key={section} id={section} />;
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</aside>
|
||||
)}
|
||||
{!fullWidth && (
|
||||
<div className="shrink-0 space-y-4 overflow-x-hidden px-(--page-margin-x) pt-(--page-margin-y)">
|
||||
{sidebar
|
||||
.filter((section) => section !== "summary")
|
||||
.map((section) => {
|
||||
const Component = getSectionComponent(section, { sectionClassName });
|
||||
return <Component key={section} id={section} />;
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</aside>
|
||||
)}
|
||||
|
||||
<main data-layout="main" className="group page-main z-10">
|
||||
{isFirstPage && (
|
||||
<PageSummary
|
||||
className={cn(
|
||||
sectionClassName,
|
||||
"bg-(--page-primary-color)/20 px-(--page-margin-x) py-(--page-margin-y) [&>h6]:hidden",
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
<main data-layout="main" className="group page-main z-10">
|
||||
{isFirstPage && (
|
||||
<PageSummary
|
||||
className={cn(
|
||||
sectionClassName,
|
||||
"bg-(--page-primary-color)/20 px-(--page-margin-x) py-(--page-margin-y) [&>h6]:hidden",
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="space-y-4 px-(--page-margin-x) pt-(--page-margin-y)">
|
||||
{main
|
||||
.filter((section) => section !== "summary")
|
||||
.map((section) => {
|
||||
const Component = getSectionComponent(section, { sectionClassName });
|
||||
return <Component key={section} id={section} />;
|
||||
})}
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
<div className="space-y-4 px-(--page-margin-x) pt-(--page-margin-y)">
|
||||
{main
|
||||
.filter((section) => section !== "summary")
|
||||
.map((section) => {
|
||||
const Component = getSectionComponent(section, { sectionClassName });
|
||||
return <Component key={section} id={section} />;
|
||||
})}
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Header() {
|
||||
const basics = useResumeStore((state) => state.resume.data.basics);
|
||||
const basics = useResumeStore((state) => state.resume.data.basics);
|
||||
|
||||
return (
|
||||
<div className="page-header relative flex">
|
||||
<div className="flex w-full shrink-0 flex-col justify-center gap-y-2 bg-(--page-primary-color) px-(--page-margin-x) py-(--page-margin-y) text-(--page-background-color)">
|
||||
<PagePicture />
|
||||
return (
|
||||
<div className="page-header relative flex">
|
||||
<div className="flex w-full shrink-0 flex-col justify-center gap-y-2 bg-(--page-primary-color) px-(--page-margin-x) py-(--page-margin-y) text-(--page-background-color)">
|
||||
<PagePicture />
|
||||
|
||||
<div>
|
||||
<h2 className="basics-name">{basics.name}</h2>
|
||||
<p className="basics-headline">{basics.headline}</p>
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="basics-name">{basics.name}</h2>
|
||||
<p className="basics-headline">{basics.headline}</p>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="basics-items flex flex-col gap-y-1 *:flex *:items-center *:gap-x-1.5"
|
||||
style={{ "--page-primary-color": "var(--page-background-color)" } as React.CSSProperties}
|
||||
>
|
||||
{basics.email && (
|
||||
<div className="basics-item-email">
|
||||
<PageIcon icon="envelope" />
|
||||
<PageLink url={`mailto:${basics.email}`} label={basics.email} />
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
className="basics-items flex flex-col gap-y-1 *:flex *:items-center *:gap-x-1.5"
|
||||
style={{ "--page-primary-color": "var(--page-background-color)" } as React.CSSProperties}
|
||||
>
|
||||
{basics.email && (
|
||||
<div className="basics-item-email">
|
||||
<PageIcon icon="envelope" />
|
||||
<PageLink url={`mailto:${basics.email}`} label={basics.email} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{basics.phone && (
|
||||
<div className="basics-item-phone">
|
||||
<PageIcon icon="phone" />
|
||||
<PageLink url={`tel:${basics.phone}`} label={basics.phone} />
|
||||
</div>
|
||||
)}
|
||||
{basics.phone && (
|
||||
<div className="basics-item-phone">
|
||||
<PageIcon icon="phone" />
|
||||
<PageLink url={`tel:${basics.phone}`} label={basics.phone} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{basics.location && (
|
||||
<div className="basics-item-location">
|
||||
<PageIcon icon="map-pin" />
|
||||
<span>{basics.location}</span>
|
||||
</div>
|
||||
)}
|
||||
{basics.location && (
|
||||
<div className="basics-item-location">
|
||||
<PageIcon icon="map-pin" />
|
||||
<span>{basics.location}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{basics.website.url && (
|
||||
<div className="basics-item-website">
|
||||
<PageIcon icon="globe" />
|
||||
<PageLink {...basics.website} />
|
||||
</div>
|
||||
)}
|
||||
{basics.website.url && (
|
||||
<div className="basics-item-website">
|
||||
<PageIcon icon="globe" />
|
||||
<PageLink {...basics.website} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{basics.customFields.map((field) => (
|
||||
<div key={field.id} className="basics-item-custom">
|
||||
<PageIcon icon={field.icon} />
|
||||
{field.link ? <PageLink url={field.link} label={field.text} /> : <span>{field.text}</span>}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
{basics.customFields.map((field) => (
|
||||
<div key={field.id} className="basics-item-custom">
|
||||
<PageIcon icon={field.icon} />
|
||||
{field.link ? <PageLink url={field.link} label={field.text} /> : <span>{field.text}</span>}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -11,113 +11,113 @@ import { PagePicture } from "../shared/page-picture";
|
||||
import { useResumeStore } from "../store/resume";
|
||||
|
||||
const sectionClassName = cn(
|
||||
// Section Heading
|
||||
"[&>h6]:border-b [&>h6]:border-(--page-primary-color)",
|
||||
// Section Heading
|
||||
"[&>h6]:border-b [&>h6]:border-(--page-primary-color)",
|
||||
|
||||
// Section Item Header in Sidebar Layout
|
||||
"group-data-[layout=sidebar]:[&_.section-item-header>div]:flex-col",
|
||||
"group-data-[layout=sidebar]:[&_.section-item-header>div]:items-start",
|
||||
// Section Item Header in Sidebar Layout
|
||||
"group-data-[layout=sidebar]:[&_.section-item-header>div]:flex-col",
|
||||
"group-data-[layout=sidebar]:[&_.section-item-header>div]:items-start",
|
||||
);
|
||||
|
||||
/**
|
||||
* Template: Glalie
|
||||
*/
|
||||
export function GlalieTemplate({ pageIndex, pageLayout }: TemplateProps) {
|
||||
const isFirstPage = pageIndex === 0;
|
||||
const { main, sidebar, fullWidth } = pageLayout;
|
||||
const isFirstPage = pageIndex === 0;
|
||||
const { main, sidebar, fullWidth } = pageLayout;
|
||||
|
||||
return (
|
||||
<div className="template-glalie page-content">
|
||||
{/* Sidebar Background */}
|
||||
{(!fullWidth || isFirstPage) && (
|
||||
<div className="page-sidebar-background pointer-events-none absolute inset-y-0 z-0 w-(--page-sidebar-width) shrink-0 bg-(--page-primary-color)/20 ltr:inset-s-0 rtl:inset-e-0" />
|
||||
)}
|
||||
return (
|
||||
<div className="template-glalie page-content">
|
||||
{/* Sidebar Background */}
|
||||
{(!fullWidth || isFirstPage) && (
|
||||
<div className="page-sidebar-background pointer-events-none absolute inset-y-0 z-0 w-(--page-sidebar-width) shrink-0 bg-(--page-primary-color)/20 ltr:inset-s-0 rtl:inset-e-0" />
|
||||
)}
|
||||
|
||||
<div className="flex">
|
||||
{(!fullWidth || isFirstPage) && (
|
||||
<aside
|
||||
data-layout="sidebar"
|
||||
className="group page-sidebar z-10 flex w-(--page-sidebar-width) shrink-0 flex-col space-y-4 px-(--page-margin-x) pt-(--page-margin-y)"
|
||||
>
|
||||
{isFirstPage && <Header />}
|
||||
<div className="flex">
|
||||
{(!fullWidth || isFirstPage) && (
|
||||
<aside
|
||||
data-layout="sidebar"
|
||||
className="group page-sidebar z-10 flex w-(--page-sidebar-width) shrink-0 flex-col space-y-4 px-(--page-margin-x) pt-(--page-margin-y)"
|
||||
>
|
||||
{isFirstPage && <Header />}
|
||||
|
||||
{!fullWidth && (
|
||||
<div className="shrink-0 space-y-4 overflow-x-hidden">
|
||||
{sidebar.map((section) => {
|
||||
const Component = getSectionComponent(section, { sectionClassName });
|
||||
return <Component key={section} id={section} />;
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</aside>
|
||||
)}
|
||||
{!fullWidth && (
|
||||
<div className="shrink-0 space-y-4 overflow-x-hidden">
|
||||
{sidebar.map((section) => {
|
||||
const Component = getSectionComponent(section, { sectionClassName });
|
||||
return <Component key={section} id={section} />;
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</aside>
|
||||
)}
|
||||
|
||||
<main data-layout="main" className="group page-main z-10">
|
||||
<div className="space-y-4 px-(--page-margin-x) pt-(--page-margin-y)">
|
||||
{main.map((section) => {
|
||||
const Component = getSectionComponent(section, { sectionClassName });
|
||||
return <Component key={section} id={section} />;
|
||||
})}
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
<main data-layout="main" className="group page-main z-10">
|
||||
<div className="space-y-4 px-(--page-margin-x) pt-(--page-margin-y)">
|
||||
{main.map((section) => {
|
||||
const Component = getSectionComponent(section, { sectionClassName });
|
||||
return <Component key={section} id={section} />;
|
||||
})}
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Header() {
|
||||
const basics = useResumeStore((state) => state.resume.data.basics);
|
||||
const basics = useResumeStore((state) => state.resume.data.basics);
|
||||
|
||||
return (
|
||||
<div className="page-header relative flex">
|
||||
<div className="flex w-full shrink-0 flex-col items-center justify-center gap-y-3">
|
||||
<PagePicture />
|
||||
return (
|
||||
<div className="page-header relative flex">
|
||||
<div className="flex w-full shrink-0 flex-col items-center justify-center gap-y-3">
|
||||
<PagePicture />
|
||||
|
||||
<div className="text-center">
|
||||
<h2 className="basics-name">{basics.name}</h2>
|
||||
<p className="basics-headline">{basics.headline}</p>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<h2 className="basics-name">{basics.name}</h2>
|
||||
<p className="basics-headline">{basics.headline}</p>
|
||||
</div>
|
||||
|
||||
<div
|
||||
style={{ "--box-radius": "calc(var(--picture-border-radius) / 4)" } as React.CSSProperties}
|
||||
className="basics-items flex w-full flex-col gap-y-1 rounded-(--box-radius) border border-(--page-primary-color) p-3 *:flex *:items-center *:gap-x-1.5"
|
||||
>
|
||||
{basics.email && (
|
||||
<div className="basics-item-email">
|
||||
<EnvelopeIcon />
|
||||
<PageLink url={`mailto:${basics.email}`} label={basics.email} />
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
style={{ "--box-radius": "calc(var(--picture-border-radius) / 4)" } as React.CSSProperties}
|
||||
className="basics-items flex w-full flex-col gap-y-1 rounded-(--box-radius) border border-(--page-primary-color) p-3 *:flex *:items-center *:gap-x-1.5"
|
||||
>
|
||||
{basics.email && (
|
||||
<div className="basics-item-email">
|
||||
<EnvelopeIcon />
|
||||
<PageLink url={`mailto:${basics.email}`} label={basics.email} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{basics.phone && (
|
||||
<div className="basics-item-phone">
|
||||
<PhoneIcon />
|
||||
<PageLink url={`tel:${basics.phone}`} label={basics.phone} />
|
||||
</div>
|
||||
)}
|
||||
{basics.phone && (
|
||||
<div className="basics-item-phone">
|
||||
<PhoneIcon />
|
||||
<PageLink url={`tel:${basics.phone}`} label={basics.phone} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{basics.location && (
|
||||
<div className="basics-item-location">
|
||||
<MapPinIcon />
|
||||
<span>{basics.location}</span>
|
||||
</div>
|
||||
)}
|
||||
{basics.location && (
|
||||
<div className="basics-item-location">
|
||||
<MapPinIcon />
|
||||
<span>{basics.location}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{basics.website.url && (
|
||||
<div className="basics-item-website">
|
||||
<GlobeIcon />
|
||||
<PageLink {...basics.website} />
|
||||
</div>
|
||||
)}
|
||||
{basics.website.url && (
|
||||
<div className="basics-item-website">
|
||||
<GlobeIcon />
|
||||
<PageLink {...basics.website} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{basics.customFields.map((field) => (
|
||||
<div key={field.id} className="basics-item-custom">
|
||||
<PageIcon icon={field.icon} />
|
||||
{field.link ? <PageLink url={field.link} label={field.text} /> : <span>{field.text}</span>}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
{basics.customFields.map((field) => (
|
||||
<div key={field.id} className="basics-item-custom">
|
||||
<PageIcon icon={field.icon} />
|
||||
{field.link ? <PageLink url={field.link} label={field.text} /> : <span>{field.text}</span>}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -11,90 +11,90 @@ import { PagePicture } from "../shared/page-picture";
|
||||
import { useResumeStore } from "../store/resume";
|
||||
|
||||
const sectionClassName = cn(
|
||||
// Section Heading
|
||||
"[&>h6]:border-b [&>h6]:border-(--page-primary-color) [&>h6]:pb-0.5 [&>h6]:text-center",
|
||||
// Section Heading
|
||||
"[&>h6]:border-b [&>h6]:border-(--page-primary-color) [&>h6]:pb-0.5 [&>h6]:text-center",
|
||||
);
|
||||
|
||||
/**
|
||||
* Template: Kakuna
|
||||
*/
|
||||
export function KakunaTemplate({ pageIndex, pageLayout }: TemplateProps) {
|
||||
const isFirstPage = pageIndex === 0;
|
||||
const { main, sidebar, fullWidth } = pageLayout;
|
||||
const isFirstPage = pageIndex === 0;
|
||||
const { main, sidebar, fullWidth } = pageLayout;
|
||||
|
||||
return (
|
||||
<div className="template-kakuna page-content space-y-(--page-gap-y) px-(--page-margin-x) pt-(--page-margin-y) print:p-0">
|
||||
{isFirstPage && <Header />}
|
||||
return (
|
||||
<div className="template-kakuna page-content space-y-(--page-gap-y) px-(--page-margin-x) pt-(--page-margin-y) print:p-0">
|
||||
{isFirstPage && <Header />}
|
||||
|
||||
<main data-layout="main" className="group page-main space-y-(--page-gap-y)">
|
||||
{main.map((section) => {
|
||||
const Component = getSectionComponent(section, { sectionClassName });
|
||||
return <Component key={section} id={section} />;
|
||||
})}
|
||||
</main>
|
||||
<main data-layout="main" className="group page-main space-y-(--page-gap-y)">
|
||||
{main.map((section) => {
|
||||
const Component = getSectionComponent(section, { sectionClassName });
|
||||
return <Component key={section} id={section} />;
|
||||
})}
|
||||
</main>
|
||||
|
||||
{!fullWidth && (
|
||||
<aside data-layout="sidebar" className="group page-sidebar space-y-(--page-gap-y)">
|
||||
{sidebar.map((section) => {
|
||||
const Component = getSectionComponent(section, { sectionClassName });
|
||||
return <Component key={section} id={section} />;
|
||||
})}
|
||||
</aside>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
{!fullWidth && (
|
||||
<aside data-layout="sidebar" className="group page-sidebar space-y-(--page-gap-y)">
|
||||
{sidebar.map((section) => {
|
||||
const Component = getSectionComponent(section, { sectionClassName });
|
||||
return <Component key={section} id={section} />;
|
||||
})}
|
||||
</aside>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Header() {
|
||||
const basics = useResumeStore((state) => state.resume.data.basics);
|
||||
const basics = useResumeStore((state) => state.resume.data.basics);
|
||||
|
||||
return (
|
||||
<div className="page-header flex flex-col items-center gap-y-(--page-gap-y)">
|
||||
<PagePicture />
|
||||
return (
|
||||
<div className="page-header flex flex-col items-center gap-y-(--page-gap-y)">
|
||||
<PagePicture />
|
||||
|
||||
<div className="page-basics space-y-(--page-gap-y) text-center">
|
||||
<div>
|
||||
<h2 className="basics-name">{basics.name}</h2>
|
||||
<p className="basics-headline">{basics.headline}</p>
|
||||
</div>
|
||||
<div className="page-basics space-y-(--page-gap-y) text-center">
|
||||
<div>
|
||||
<h2 className="basics-name">{basics.name}</h2>
|
||||
<p className="basics-headline">{basics.headline}</p>
|
||||
</div>
|
||||
|
||||
<div className="basics-items flex flex-wrap justify-center gap-x-3 gap-y-0.5 *:flex *:items-center *:gap-x-1.5">
|
||||
{basics.email && (
|
||||
<div className="basics-item-email">
|
||||
<EnvelopeIcon />
|
||||
<PageLink url={`mailto:${basics.email}`} label={basics.email} />
|
||||
</div>
|
||||
)}
|
||||
<div className="basics-items flex flex-wrap justify-center gap-x-3 gap-y-0.5 *:flex *:items-center *:gap-x-1.5">
|
||||
{basics.email && (
|
||||
<div className="basics-item-email">
|
||||
<EnvelopeIcon />
|
||||
<PageLink url={`mailto:${basics.email}`} label={basics.email} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{basics.phone && (
|
||||
<div className="basics-item-phone">
|
||||
<PhoneIcon />
|
||||
<PageLink url={`tel:${basics.phone}`} label={basics.phone} />
|
||||
</div>
|
||||
)}
|
||||
{basics.phone && (
|
||||
<div className="basics-item-phone">
|
||||
<PhoneIcon />
|
||||
<PageLink url={`tel:${basics.phone}`} label={basics.phone} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{basics.location && (
|
||||
<div className="basics-item-location">
|
||||
<MapPinIcon />
|
||||
<span>{basics.location}</span>
|
||||
</div>
|
||||
)}
|
||||
{basics.location && (
|
||||
<div className="basics-item-location">
|
||||
<MapPinIcon />
|
||||
<span>{basics.location}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{basics.website.url && (
|
||||
<div className="basics-item-website">
|
||||
<GlobeIcon />
|
||||
<PageLink {...basics.website} />
|
||||
</div>
|
||||
)}
|
||||
{basics.website.url && (
|
||||
<div className="basics-item-website">
|
||||
<GlobeIcon />
|
||||
<PageLink {...basics.website} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{basics.customFields.map((field) => (
|
||||
<div key={field.id} className="basics-item-custom">
|
||||
<PageIcon icon={field.icon} />
|
||||
{field.link ? <PageLink url={field.link} label={field.text} /> : <span>{field.text}</span>}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
{basics.customFields.map((field) => (
|
||||
<div key={field.id} className="basics-item-custom">
|
||||
<PageIcon icon={field.icon} />
|
||||
{field.link ? <PageLink url={field.link} label={field.text} /> : <span>{field.text}</span>}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -12,114 +12,114 @@ import { PagePicture } from "../shared/page-picture";
|
||||
import { useResumeStore } from "../store/resume";
|
||||
|
||||
const sectionClassName = cn(
|
||||
// Container
|
||||
"rounded-(--container-border-radius) border border-(--page-text-color)/10 bg-(--page-background-color) p-4",
|
||||
// Container
|
||||
"rounded-(--container-border-radius) border border-(--page-text-color)/10 bg-(--page-background-color) p-4",
|
||||
|
||||
// Section Heading
|
||||
"[&>h6]:-mt-(--heading-negative-margin) [&>h6]:max-w-fit [&>h6]:bg-(--page-background-color) [&>h6]:px-4",
|
||||
// Section Heading
|
||||
"[&>h6]:-mt-(--heading-negative-margin) [&>h6]:max-w-fit [&>h6]:bg-(--page-background-color) [&>h6]:px-4",
|
||||
|
||||
// Push the first section of a page down, to avoid clipping the header
|
||||
"group-data-[layout=main]:first-of-type:mt-4",
|
||||
// Push the first section of a page down, to avoid clipping the header
|
||||
"group-data-[layout=main]:first-of-type:mt-4",
|
||||
);
|
||||
|
||||
/**
|
||||
* Template: Lapras
|
||||
*/
|
||||
export function LaprasTemplate({ pageIndex, pageLayout }: TemplateProps) {
|
||||
const isFirstPage = pageIndex === 0;
|
||||
const { main, sidebar, fullWidth } = pageLayout;
|
||||
const isFirstPage = pageIndex === 0;
|
||||
const { main, sidebar, fullWidth } = pageLayout;
|
||||
|
||||
const containerBorderRadius = useResumeStore((state) => Math.min(state.resume.data.picture.borderRadius, 30));
|
||||
const headingNegativeMargin = useResumeStore((state) => state.resume.data.metadata.typography.heading.fontSize + 6);
|
||||
const containerBorderRadius = useResumeStore((state) => Math.min(state.resume.data.picture.borderRadius, 30));
|
||||
const headingNegativeMargin = useResumeStore((state) => state.resume.data.metadata.typography.heading.fontSize + 6);
|
||||
|
||||
const style = useMemo(() => {
|
||||
return {
|
||||
"--container-border-radius": `${containerBorderRadius}pt`,
|
||||
"--heading-negative-margin": `${headingNegativeMargin}pt`,
|
||||
} as React.CSSProperties;
|
||||
}, [containerBorderRadius, headingNegativeMargin]);
|
||||
const style = useMemo(() => {
|
||||
return {
|
||||
"--container-border-radius": `${containerBorderRadius}pt`,
|
||||
"--heading-negative-margin": `${headingNegativeMargin}pt`,
|
||||
} as React.CSSProperties;
|
||||
}, [containerBorderRadius, headingNegativeMargin]);
|
||||
|
||||
return (
|
||||
<div
|
||||
style={style}
|
||||
className="template-lapras page-content space-y-6 px-(--page-margin-x) pt-(--page-margin-y) print:p-0"
|
||||
>
|
||||
{isFirstPage && <Header />}
|
||||
return (
|
||||
<div
|
||||
style={style}
|
||||
className="template-lapras page-content space-y-6 px-(--page-margin-x) pt-(--page-margin-y) print:p-0"
|
||||
>
|
||||
{isFirstPage && <Header />}
|
||||
|
||||
<main data-layout="main" className="group page-main space-y-6">
|
||||
{main.map((section) => {
|
||||
const Component = getSectionComponent(section, { sectionClassName });
|
||||
return <Component key={section} id={section} />;
|
||||
})}
|
||||
</main>
|
||||
<main data-layout="main" className="group page-main space-y-6">
|
||||
{main.map((section) => {
|
||||
const Component = getSectionComponent(section, { sectionClassName });
|
||||
return <Component key={section} id={section} />;
|
||||
})}
|
||||
</main>
|
||||
|
||||
{!fullWidth && (
|
||||
<aside data-layout="sidebar" className="group page-sidebar space-y-6">
|
||||
{sidebar.map((section) => {
|
||||
const Component = getSectionComponent(section, { sectionClassName });
|
||||
return <Component key={section} id={section} />;
|
||||
})}
|
||||
</aside>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
{!fullWidth && (
|
||||
<aside data-layout="sidebar" className="group page-sidebar space-y-6">
|
||||
{sidebar.map((section) => {
|
||||
const Component = getSectionComponent(section, { sectionClassName });
|
||||
return <Component key={section} id={section} />;
|
||||
})}
|
||||
</aside>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Header() {
|
||||
const basics = useResumeStore((state) => state.resume.data.basics);
|
||||
const basics = useResumeStore((state) => state.resume.data.basics);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"page-header flex items-center gap-x-(--page-margin-x)",
|
||||
"rounded-(--picture-border-radius) border border-(--page-text-color)/10 bg-(--page-background-color) p-4",
|
||||
)}
|
||||
>
|
||||
<PagePicture />
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"page-header flex items-center gap-x-(--page-margin-x)",
|
||||
"rounded-(--picture-border-radius) border border-(--page-text-color)/10 bg-(--page-background-color) p-4",
|
||||
)}
|
||||
>
|
||||
<PagePicture />
|
||||
|
||||
<div className="page-basics space-y-(--page-gap-y)">
|
||||
<div>
|
||||
<h2 className="basics-name">{basics.name}</h2>
|
||||
<p className="basics-headline">{basics.headline}</p>
|
||||
</div>
|
||||
<div className="page-basics space-y-(--page-gap-y)">
|
||||
<div>
|
||||
<h2 className="basics-name">{basics.name}</h2>
|
||||
<p className="basics-headline">{basics.headline}</p>
|
||||
</div>
|
||||
|
||||
<div className="basics-items flex flex-wrap gap-x-2 gap-y-0.5 *:flex *:items-center *:gap-x-1.5 *:border-e *:border-(--page-primary-color) *:py-0.5 *:pe-2 *:last:border-e-0">
|
||||
{basics.email && (
|
||||
<div className="basics-item-email">
|
||||
<EnvelopeIcon />
|
||||
<PageLink url={`mailto:${basics.email}`} label={basics.email} />
|
||||
</div>
|
||||
)}
|
||||
<div className="basics-items flex flex-wrap gap-x-2 gap-y-0.5 *:flex *:items-center *:gap-x-1.5 *:border-e *:border-(--page-primary-color) *:py-0.5 *:pe-2 *:last:border-e-0">
|
||||
{basics.email && (
|
||||
<div className="basics-item-email">
|
||||
<EnvelopeIcon />
|
||||
<PageLink url={`mailto:${basics.email}`} label={basics.email} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{basics.phone && (
|
||||
<div className="basics-item-phone">
|
||||
<PhoneIcon />
|
||||
<PageLink url={`tel:${basics.phone}`} label={basics.phone} />
|
||||
</div>
|
||||
)}
|
||||
{basics.phone && (
|
||||
<div className="basics-item-phone">
|
||||
<PhoneIcon />
|
||||
<PageLink url={`tel:${basics.phone}`} label={basics.phone} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{basics.location && (
|
||||
<div className="basics-item-location">
|
||||
<MapPinIcon />
|
||||
<span>{basics.location}</span>
|
||||
</div>
|
||||
)}
|
||||
{basics.location && (
|
||||
<div className="basics-item-location">
|
||||
<MapPinIcon />
|
||||
<span>{basics.location}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{basics.website.url && (
|
||||
<div className="basics-item-website">
|
||||
<GlobeIcon />
|
||||
<PageLink {...basics.website} />
|
||||
</div>
|
||||
)}
|
||||
{basics.website.url && (
|
||||
<div className="basics-item-website">
|
||||
<GlobeIcon />
|
||||
<PageLink {...basics.website} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{basics.customFields.map((field) => (
|
||||
<div key={field.id} className="basics-item-custom">
|
||||
<PageIcon icon={field.icon} />
|
||||
{field.link ? <PageLink url={field.link} label={field.text} /> : <span>{field.text}</span>}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
{basics.customFields.map((field) => (
|
||||
<div key={field.id} className="basics-item-custom">
|
||||
<PageIcon icon={field.icon} />
|
||||
{field.link ? <PageLink url={field.link} label={field.text} /> : <span>{field.text}</span>}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -12,105 +12,105 @@ import { PageSummary } from "../shared/page-summary";
|
||||
import { useResumeStore } from "../store/resume";
|
||||
|
||||
const sectionClassName = cn(
|
||||
// Section Heading
|
||||
"[&>h6]:border-b [&>h6]:border-(--page-primary-color)",
|
||||
// Section Heading
|
||||
"[&>h6]:border-b [&>h6]:border-(--page-primary-color)",
|
||||
);
|
||||
|
||||
/**
|
||||
* Template: Leafish
|
||||
*/
|
||||
export function LeafishTemplate({ pageIndex, pageLayout }: TemplateProps) {
|
||||
const isFirstPage = pageIndex === 0;
|
||||
const { main, sidebar, fullWidth } = pageLayout;
|
||||
const isFirstPage = pageIndex === 0;
|
||||
const { main, sidebar, fullWidth } = pageLayout;
|
||||
|
||||
return (
|
||||
<div className="template-leafish page-content">
|
||||
{isFirstPage && <Header />}
|
||||
return (
|
||||
<div className="template-leafish page-content">
|
||||
{isFirstPage && <Header />}
|
||||
|
||||
<div className="flex gap-x-(--page-margin-x) px-(--page-margin-x) pt-(--page-margin-y)">
|
||||
<main data-layout="main" className="group page-main space-y-(--page-gap-y)">
|
||||
{main
|
||||
.filter((section) => section !== "summary")
|
||||
.map((section) => {
|
||||
const Component = getSectionComponent(section, { sectionClassName });
|
||||
return <Component key={section} id={section} />;
|
||||
})}
|
||||
</main>
|
||||
<div className="flex gap-x-(--page-margin-x) px-(--page-margin-x) pt-(--page-margin-y)">
|
||||
<main data-layout="main" className="group page-main space-y-(--page-gap-y)">
|
||||
{main
|
||||
.filter((section) => section !== "summary")
|
||||
.map((section) => {
|
||||
const Component = getSectionComponent(section, { sectionClassName });
|
||||
return <Component key={section} id={section} />;
|
||||
})}
|
||||
</main>
|
||||
|
||||
{!fullWidth && (
|
||||
<aside
|
||||
data-layout="sidebar"
|
||||
className="group page-sidebar w-(--page-sidebar-width) shrink-0 space-y-(--page-gap-y)"
|
||||
>
|
||||
{sidebar
|
||||
.filter((section) => section !== "summary")
|
||||
.map((section) => {
|
||||
const Component = getSectionComponent(section, { sectionClassName });
|
||||
return <Component key={section} id={section} />;
|
||||
})}
|
||||
</aside>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
{!fullWidth && (
|
||||
<aside
|
||||
data-layout="sidebar"
|
||||
className="group page-sidebar w-(--page-sidebar-width) shrink-0 space-y-(--page-gap-y)"
|
||||
>
|
||||
{sidebar
|
||||
.filter((section) => section !== "summary")
|
||||
.map((section) => {
|
||||
const Component = getSectionComponent(section, { sectionClassName });
|
||||
return <Component key={section} id={section} />;
|
||||
})}
|
||||
</aside>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Header() {
|
||||
const basics = useResumeStore((state) => state.resume.data.basics);
|
||||
const basics = useResumeStore((state) => state.resume.data.basics);
|
||||
|
||||
return (
|
||||
<div className="page-header bg-(--page-primary-color)/10">
|
||||
<div className="flex items-center gap-x-(--page-margin-x) px-(--page-margin-x) py-(--page-margin-y)">
|
||||
<PagePicture />
|
||||
return (
|
||||
<div className="page-header bg-(--page-primary-color)/10">
|
||||
<div className="flex items-center gap-x-(--page-margin-x) px-(--page-margin-x) py-(--page-margin-y)">
|
||||
<PagePicture />
|
||||
|
||||
<div className="space-y-(--page-gap-y)">
|
||||
<div>
|
||||
<h2 className="basics-name">{basics.name}</h2>
|
||||
<p className="basics-headline">{basics.headline}</p>
|
||||
</div>
|
||||
<div className="space-y-(--page-gap-y)">
|
||||
<div>
|
||||
<h2 className="basics-name">{basics.name}</h2>
|
||||
<p className="basics-headline">{basics.headline}</p>
|
||||
</div>
|
||||
|
||||
<PageSummary className="[&>h6]:hidden" />
|
||||
</div>
|
||||
</div>
|
||||
<PageSummary className="[&>h6]:hidden" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="page-basics bg-(--page-primary-color)/10 px-(--page-margin-x) py-(--page-margin-y)">
|
||||
<div className="basics-items flex flex-wrap gap-x-4 gap-y-1 *:flex *:items-center *:gap-x-1.5">
|
||||
{basics.email && (
|
||||
<div className="basics-item-email">
|
||||
<EnvelopeIcon />
|
||||
<PageLink url={`mailto:${basics.email}`} label={basics.email} />
|
||||
</div>
|
||||
)}
|
||||
<div className="page-basics bg-(--page-primary-color)/10 px-(--page-margin-x) py-(--page-margin-y)">
|
||||
<div className="basics-items flex flex-wrap gap-x-4 gap-y-1 *:flex *:items-center *:gap-x-1.5">
|
||||
{basics.email && (
|
||||
<div className="basics-item-email">
|
||||
<EnvelopeIcon />
|
||||
<PageLink url={`mailto:${basics.email}`} label={basics.email} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{basics.phone && (
|
||||
<div className="basics-item-phone">
|
||||
<PhoneIcon />
|
||||
<PageLink url={`tel:${basics.phone}`} label={basics.phone} />
|
||||
</div>
|
||||
)}
|
||||
{basics.phone && (
|
||||
<div className="basics-item-phone">
|
||||
<PhoneIcon />
|
||||
<PageLink url={`tel:${basics.phone}`} label={basics.phone} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{basics.location && (
|
||||
<div className="basics-item-location">
|
||||
<MapPinIcon />
|
||||
<span>{basics.location}</span>
|
||||
</div>
|
||||
)}
|
||||
{basics.location && (
|
||||
<div className="basics-item-location">
|
||||
<MapPinIcon />
|
||||
<span>{basics.location}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{basics.website.url && (
|
||||
<div className="basics-item-website">
|
||||
<GlobeIcon />
|
||||
<PageLink {...basics.website} />
|
||||
</div>
|
||||
)}
|
||||
{basics.website.url && (
|
||||
<div className="basics-item-website">
|
||||
<GlobeIcon />
|
||||
<PageLink {...basics.website} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{basics.customFields.map((field) => (
|
||||
<div key={field.id} className="basics-item-custom">
|
||||
<PageIcon icon={field.icon} />
|
||||
{field.link ? <PageLink url={field.link} label={field.text} /> : <span>{field.text}</span>}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
{basics.customFields.map((field) => (
|
||||
<div key={field.id} className="basics-item-custom">
|
||||
<PageIcon icon={field.icon} />
|
||||
{field.link ? <PageLink url={field.link} label={field.text} /> : <span>{field.text}</span>}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -16,82 +16,82 @@ const sectionClassName = cn();
|
||||
* Template: Onyx
|
||||
*/
|
||||
export function OnyxTemplate({ pageIndex, pageLayout }: TemplateProps) {
|
||||
const isFirstPage = pageIndex === 0;
|
||||
const { main, sidebar, fullWidth } = pageLayout;
|
||||
const isFirstPage = pageIndex === 0;
|
||||
const { main, sidebar, fullWidth } = pageLayout;
|
||||
|
||||
return (
|
||||
<div className="template-onyx page-content space-y-(--page-gap-y) px-(--page-margin-x) pt-(--page-margin-y) print:p-0">
|
||||
{isFirstPage && <Header />}
|
||||
return (
|
||||
<div className="template-onyx page-content space-y-(--page-gap-y) px-(--page-margin-x) pt-(--page-margin-y) print:p-0">
|
||||
{isFirstPage && <Header />}
|
||||
|
||||
<main data-layout="main" className="group page-main space-y-(--page-gap-y)">
|
||||
{main.map((section) => {
|
||||
const Component = getSectionComponent(section, { sectionClassName });
|
||||
return <Component key={section} id={section} />;
|
||||
})}
|
||||
</main>
|
||||
<main data-layout="main" className="group page-main space-y-(--page-gap-y)">
|
||||
{main.map((section) => {
|
||||
const Component = getSectionComponent(section, { sectionClassName });
|
||||
return <Component key={section} id={section} />;
|
||||
})}
|
||||
</main>
|
||||
|
||||
{!fullWidth && (
|
||||
<aside data-layout="sidebar" className="group page-sidebar space-y-(--page-gap-y)">
|
||||
{sidebar.map((section) => {
|
||||
const Component = getSectionComponent(section, { sectionClassName });
|
||||
return <Component key={section} id={section} />;
|
||||
})}
|
||||
</aside>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
{!fullWidth && (
|
||||
<aside data-layout="sidebar" className="group page-sidebar space-y-(--page-gap-y)">
|
||||
{sidebar.map((section) => {
|
||||
const Component = getSectionComponent(section, { sectionClassName });
|
||||
return <Component key={section} id={section} />;
|
||||
})}
|
||||
</aside>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Header() {
|
||||
const basics = useResumeStore((state) => state.resume.data.basics);
|
||||
const basics = useResumeStore((state) => state.resume.data.basics);
|
||||
|
||||
return (
|
||||
<div className="page-header flex items-center gap-x-(--page-margin-x) border-b border-(--page-primary-color) pb-(--page-margin-y)">
|
||||
<PagePicture />
|
||||
return (
|
||||
<div className="page-header flex items-center gap-x-(--page-margin-x) border-b border-(--page-primary-color) pb-(--page-margin-y)">
|
||||
<PagePicture />
|
||||
|
||||
<div className="page-basics space-y-(--page-gap-y)">
|
||||
<div>
|
||||
<h2 className="basics-name">{basics.name}</h2>
|
||||
<p className="basics-headline">{basics.headline}</p>
|
||||
</div>
|
||||
<div className="page-basics space-y-(--page-gap-y)">
|
||||
<div>
|
||||
<h2 className="basics-name">{basics.name}</h2>
|
||||
<p className="basics-headline">{basics.headline}</p>
|
||||
</div>
|
||||
|
||||
<div className="basics-items flex flex-wrap gap-x-3 gap-y-0.5 *:flex *:items-center *:gap-x-1.5">
|
||||
{basics.email && (
|
||||
<div className="basics-item-email">
|
||||
<EnvelopeIcon />
|
||||
<PageLink url={`mailto:${basics.email}`} label={basics.email} />
|
||||
</div>
|
||||
)}
|
||||
<div className="basics-items flex flex-wrap gap-x-3 gap-y-0.5 *:flex *:items-center *:gap-x-1.5">
|
||||
{basics.email && (
|
||||
<div className="basics-item-email">
|
||||
<EnvelopeIcon />
|
||||
<PageLink url={`mailto:${basics.email}`} label={basics.email} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{basics.phone && (
|
||||
<div className="basics-item-phone">
|
||||
<PhoneIcon />
|
||||
<PageLink url={`tel:${basics.phone}`} label={basics.phone} />
|
||||
</div>
|
||||
)}
|
||||
{basics.phone && (
|
||||
<div className="basics-item-phone">
|
||||
<PhoneIcon />
|
||||
<PageLink url={`tel:${basics.phone}`} label={basics.phone} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{basics.location && (
|
||||
<div className="basics-item-location">
|
||||
<MapPinIcon />
|
||||
<span>{basics.location}</span>
|
||||
</div>
|
||||
)}
|
||||
{basics.location && (
|
||||
<div className="basics-item-location">
|
||||
<MapPinIcon />
|
||||
<span>{basics.location}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{basics.website.url && (
|
||||
<div className="basics-item-website">
|
||||
<GlobeIcon />
|
||||
<PageLink {...basics.website} />
|
||||
</div>
|
||||
)}
|
||||
{basics.website.url && (
|
||||
<div className="basics-item-website">
|
||||
<GlobeIcon />
|
||||
<PageLink {...basics.website} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{basics.customFields.map((field) => (
|
||||
<div key={field.id} className="basics-item-custom">
|
||||
<PageIcon icon={field.icon} />
|
||||
{field.link ? <PageLink url={field.link} label={field.text} /> : <span>{field.text}</span>}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
{basics.customFields.map((field) => (
|
||||
<div key={field.id} className="basics-item-custom">
|
||||
<PageIcon icon={field.icon} />
|
||||
{field.link ? <PageLink url={field.link} label={field.text} /> : <span>{field.text}</span>}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -11,115 +11,115 @@ import { PagePicture } from "../shared/page-picture";
|
||||
import { useResumeStore } from "../store/resume";
|
||||
|
||||
const sectionClassName = cn(
|
||||
// Section Heading
|
||||
"[&>h6]:border-b [&>h6]:border-(--page-primary-color)",
|
||||
// Section Heading
|
||||
"[&>h6]:border-b [&>h6]:border-(--page-primary-color)",
|
||||
|
||||
// Section Item Header in Sidebar Layout
|
||||
"group-data-[layout=sidebar]:[&_.section-item-header>div]:flex-col",
|
||||
"group-data-[layout=sidebar]:[&_.section-item-header>div]:items-start",
|
||||
// Section Item Header in Sidebar Layout
|
||||
"group-data-[layout=sidebar]:[&_.section-item-header>div]:flex-col",
|
||||
"group-data-[layout=sidebar]:[&_.section-item-header>div]:items-start",
|
||||
);
|
||||
|
||||
/**
|
||||
* Template: Pikachu
|
||||
*/
|
||||
export function PikachuTemplate({ pageIndex, pageLayout }: TemplateProps) {
|
||||
const isFirstPage = pageIndex === 0;
|
||||
const { main, sidebar, fullWidth } = pageLayout;
|
||||
const isFirstPage = pageIndex === 0;
|
||||
const { main, sidebar, fullWidth } = pageLayout;
|
||||
|
||||
return (
|
||||
<div className="template-pikachu page-content px-(--page-margin-x) pt-(--page-margin-y) print:p-0">
|
||||
<div className="flex gap-x-(--page-margin-x)">
|
||||
{!fullWidth && (
|
||||
<aside
|
||||
data-layout="sidebar"
|
||||
className="group page-sidebar flex w-(--page-sidebar-width) shrink-0 flex-col space-y-(--page-gap-y)"
|
||||
>
|
||||
{isFirstPage && (
|
||||
<div className="flex max-w-(--page-sidebar-width) items-center justify-start">
|
||||
<PagePicture />
|
||||
</div>
|
||||
)}
|
||||
return (
|
||||
<div className="template-pikachu page-content px-(--page-margin-x) pt-(--page-margin-y) print:p-0">
|
||||
<div className="flex gap-x-(--page-margin-x)">
|
||||
{!fullWidth && (
|
||||
<aside
|
||||
data-layout="sidebar"
|
||||
className="group page-sidebar flex w-(--page-sidebar-width) shrink-0 flex-col space-y-(--page-gap-y)"
|
||||
>
|
||||
{isFirstPage && (
|
||||
<div className="flex max-w-(--page-sidebar-width) items-center justify-start">
|
||||
<PagePicture />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!fullWidth && (
|
||||
<div className="shrink-0 space-y-(--page-gap-y) overflow-x-hidden">
|
||||
{sidebar.map((section) => {
|
||||
const Component = getSectionComponent(section, { sectionClassName });
|
||||
return <Component key={section} id={section} />;
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</aside>
|
||||
)}
|
||||
{!fullWidth && (
|
||||
<div className="shrink-0 space-y-(--page-gap-y) overflow-x-hidden">
|
||||
{sidebar.map((section) => {
|
||||
const Component = getSectionComponent(section, { sectionClassName });
|
||||
return <Component key={section} id={section} />;
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</aside>
|
||||
)}
|
||||
|
||||
<main data-layout="main" className="group page-main flex-1 space-y-(--page-gap-y)">
|
||||
{isFirstPage && (
|
||||
<div className="flex items-center gap-x-6">
|
||||
{fullWidth && <PagePicture />}
|
||||
<Header />
|
||||
</div>
|
||||
)}
|
||||
<main data-layout="main" className="group page-main flex-1 space-y-(--page-gap-y)">
|
||||
{isFirstPage && (
|
||||
<div className="flex items-center gap-x-6">
|
||||
{fullWidth && <PagePicture />}
|
||||
<Header />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-(--page-gap-y)">
|
||||
{main.map((section) => {
|
||||
const Component = getSectionComponent(section, { sectionClassName });
|
||||
return <Component key={section} id={section} />;
|
||||
})}
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
<div className="space-y-(--page-gap-y)">
|
||||
{main.map((section) => {
|
||||
const Component = getSectionComponent(section, { sectionClassName });
|
||||
return <Component key={section} id={section} />;
|
||||
})}
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Header() {
|
||||
const basics = useResumeStore((state) => state.resume.data.basics);
|
||||
const basics = useResumeStore((state) => state.resume.data.basics);
|
||||
|
||||
return (
|
||||
<div className="page-header w-full space-y-(--page-gap-y) rounded-(--picture-border-radius) bg-(--page-primary-color) px-(--page-margin-x) py-(--page-margin-y) text-(--page-background-color)">
|
||||
<div className="border-b border-(--page-background-color)/50 pb-2">
|
||||
<h2 className="basics-name">{basics.name}</h2>
|
||||
<p className="basics-headline">{basics.headline}</p>
|
||||
</div>
|
||||
return (
|
||||
<div className="page-header w-full space-y-(--page-gap-y) rounded-(--picture-border-radius) bg-(--page-primary-color) px-(--page-margin-x) py-(--page-margin-y) text-(--page-background-color)">
|
||||
<div className="border-b border-(--page-background-color)/50 pb-2">
|
||||
<h2 className="basics-name">{basics.name}</h2>
|
||||
<p className="basics-headline">{basics.headline}</p>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="basics-items flex flex-wrap gap-x-3 gap-y-0.5 *:flex *:items-center *:gap-x-1.5"
|
||||
style={{ "--page-primary-color": "var(--page-background-color)" } as React.CSSProperties}
|
||||
>
|
||||
{basics.email && (
|
||||
<div className="basics-item-email">
|
||||
<EnvelopeIcon />
|
||||
<PageLink url={`mailto:${basics.email}`} label={basics.email} />
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
className="basics-items flex flex-wrap gap-x-3 gap-y-0.5 *:flex *:items-center *:gap-x-1.5"
|
||||
style={{ "--page-primary-color": "var(--page-background-color)" } as React.CSSProperties}
|
||||
>
|
||||
{basics.email && (
|
||||
<div className="basics-item-email">
|
||||
<EnvelopeIcon />
|
||||
<PageLink url={`mailto:${basics.email}`} label={basics.email} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{basics.phone && (
|
||||
<div className="basics-item-phone">
|
||||
<PhoneIcon />
|
||||
<PageLink url={`tel:${basics.phone}`} label={basics.phone} />
|
||||
</div>
|
||||
)}
|
||||
{basics.phone && (
|
||||
<div className="basics-item-phone">
|
||||
<PhoneIcon />
|
||||
<PageLink url={`tel:${basics.phone}`} label={basics.phone} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{basics.location && (
|
||||
<div className="basics-item-location">
|
||||
<MapPinIcon />
|
||||
<span>{basics.location}</span>
|
||||
</div>
|
||||
)}
|
||||
{basics.location && (
|
||||
<div className="basics-item-location">
|
||||
<MapPinIcon />
|
||||
<span>{basics.location}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{basics.website.url && (
|
||||
<div className="basics-item-website">
|
||||
<GlobeIcon />
|
||||
<PageLink {...basics.website} />
|
||||
</div>
|
||||
)}
|
||||
{basics.website.url && (
|
||||
<div className="basics-item-website">
|
||||
<GlobeIcon />
|
||||
<PageLink {...basics.website} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{basics.customFields.map((field) => (
|
||||
<div key={field.id} className="basics-item-custom">
|
||||
<PageIcon icon={field.icon} />
|
||||
{field.link ? <PageLink url={field.link} label={field.text} /> : <span>{field.text}</span>}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
{basics.customFields.map((field) => (
|
||||
<div key={field.id} className="basics-item-custom">
|
||||
<PageIcon icon={field.icon} />
|
||||
{field.link ? <PageLink url={field.link} label={field.text} /> : <span>{field.text}</span>}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -11,90 +11,90 @@ import { PagePicture } from "../shared/page-picture";
|
||||
import { useResumeStore } from "../store/resume";
|
||||
|
||||
const sectionClassName = cn(
|
||||
// Section Heading
|
||||
"[&>h6]:border-b [&>h6]:border-(--page-primary-color)",
|
||||
// Section Heading
|
||||
"[&>h6]:border-b [&>h6]:border-(--page-primary-color)",
|
||||
);
|
||||
|
||||
/**
|
||||
* Template: Rhyhorn
|
||||
*/
|
||||
export function RhyhornTemplate({ pageIndex, pageLayout }: TemplateProps) {
|
||||
const isFirstPage = pageIndex === 0;
|
||||
const { main, sidebar, fullWidth } = pageLayout;
|
||||
const isFirstPage = pageIndex === 0;
|
||||
const { main, sidebar, fullWidth } = pageLayout;
|
||||
|
||||
return (
|
||||
<div className="template-rhyhorn page-content space-y-(--page-gap-y) px-(--page-margin-x) pt-(--page-margin-y) print:p-0">
|
||||
{isFirstPage && <Header />}
|
||||
return (
|
||||
<div className="template-rhyhorn page-content space-y-(--page-gap-y) px-(--page-margin-x) pt-(--page-margin-y) print:p-0">
|
||||
{isFirstPage && <Header />}
|
||||
|
||||
<main data-layout="main" className="group page-main space-y-(--page-gap-y)">
|
||||
{main.map((section) => {
|
||||
const Component = getSectionComponent(section, { sectionClassName });
|
||||
return <Component key={section} id={section} />;
|
||||
})}
|
||||
</main>
|
||||
<main data-layout="main" className="group page-main space-y-(--page-gap-y)">
|
||||
{main.map((section) => {
|
||||
const Component = getSectionComponent(section, { sectionClassName });
|
||||
return <Component key={section} id={section} />;
|
||||
})}
|
||||
</main>
|
||||
|
||||
{!fullWidth && (
|
||||
<aside data-layout="sidebar" className="group page-sidebar space-y-(--page-gap-y)">
|
||||
{sidebar.map((section) => {
|
||||
const Component = getSectionComponent(section, { sectionClassName });
|
||||
return <Component key={section} id={section} />;
|
||||
})}
|
||||
</aside>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
{!fullWidth && (
|
||||
<aside data-layout="sidebar" className="group page-sidebar space-y-(--page-gap-y)">
|
||||
{sidebar.map((section) => {
|
||||
const Component = getSectionComponent(section, { sectionClassName });
|
||||
return <Component key={section} id={section} />;
|
||||
})}
|
||||
</aside>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Header() {
|
||||
const basics = useResumeStore((state) => state.resume.data.basics);
|
||||
const basics = useResumeStore((state) => state.resume.data.basics);
|
||||
|
||||
return (
|
||||
<div className="page-header flex items-center gap-x-(--page-gap-x)">
|
||||
<div className="page-basics grow space-y-(--page-gap-y)">
|
||||
<div>
|
||||
<h2 className="basics-name">{basics.name}</h2>
|
||||
<p className="basics-headline">{basics.headline}</p>
|
||||
</div>
|
||||
return (
|
||||
<div className="page-header flex items-center gap-x-(--page-gap-x)">
|
||||
<div className="page-basics grow space-y-(--page-gap-y)">
|
||||
<div>
|
||||
<h2 className="basics-name">{basics.name}</h2>
|
||||
<p className="basics-headline">{basics.headline}</p>
|
||||
</div>
|
||||
|
||||
<div className="basics-items flex flex-wrap gap-x-2 gap-y-0.5 *:flex *:items-center *:gap-x-1.5 *:border-e *:border-(--page-primary-color) *:py-0.5 *:pe-2 *:last:border-e-0">
|
||||
{basics.email && (
|
||||
<div className="basics-item-email">
|
||||
<EnvelopeIcon />
|
||||
<PageLink url={`mailto:${basics.email}`} label={basics.email} />
|
||||
</div>
|
||||
)}
|
||||
<div className="basics-items flex flex-wrap gap-x-2 gap-y-0.5 *:flex *:items-center *:gap-x-1.5 *:border-e *:border-(--page-primary-color) *:py-0.5 *:pe-2 *:last:border-e-0">
|
||||
{basics.email && (
|
||||
<div className="basics-item-email">
|
||||
<EnvelopeIcon />
|
||||
<PageLink url={`mailto:${basics.email}`} label={basics.email} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{basics.phone && (
|
||||
<div className="basics-item-phone">
|
||||
<PhoneIcon />
|
||||
<PageLink url={`tel:${basics.phone}`} label={basics.phone} />
|
||||
</div>
|
||||
)}
|
||||
{basics.phone && (
|
||||
<div className="basics-item-phone">
|
||||
<PhoneIcon />
|
||||
<PageLink url={`tel:${basics.phone}`} label={basics.phone} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{basics.location && (
|
||||
<div className="basics-item-location">
|
||||
<MapPinIcon />
|
||||
<span>{basics.location}</span>
|
||||
</div>
|
||||
)}
|
||||
{basics.location && (
|
||||
<div className="basics-item-location">
|
||||
<MapPinIcon />
|
||||
<span>{basics.location}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{basics.website.url && (
|
||||
<div className="basics-item-website">
|
||||
<GlobeIcon />
|
||||
<PageLink {...basics.website} />
|
||||
</div>
|
||||
)}
|
||||
{basics.website.url && (
|
||||
<div className="basics-item-website">
|
||||
<GlobeIcon />
|
||||
<PageLink {...basics.website} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{basics.customFields.map((field) => (
|
||||
<div key={field.id} className="basics-item-custom">
|
||||
<PageIcon icon={field.icon} />
|
||||
{field.link ? <PageLink url={field.link} label={field.text} /> : <span>{field.text}</span>}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
{basics.customFields.map((field) => (
|
||||
<div key={field.id} className="basics-item-custom">
|
||||
<PageIcon icon={field.icon} />
|
||||
{field.link ? <PageLink url={field.link} label={field.text} /> : <span>{field.text}</span>}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<PagePicture />
|
||||
</div>
|
||||
);
|
||||
<PagePicture />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,6 +3,6 @@ import type z from "zod";
|
||||
import type { pageLayoutSchema } from "@/schema/resume/data";
|
||||
|
||||
export type TemplateProps = {
|
||||
pageIndex: number;
|
||||
pageLayout: z.infer<typeof pageLayoutSchema>;
|
||||
pageIndex: number;
|
||||
pageLayout: z.infer<typeof pageLayoutSchema>;
|
||||
};
|
||||
|
||||
@@ -9,22 +9,22 @@ import { useTheme } from "./provider";
|
||||
type Props = Omit<SingleComboboxProps, "options" | "value" | "onValueChange">;
|
||||
|
||||
export function ThemeCombobox(props: Props) {
|
||||
const router = useRouter();
|
||||
const { i18n } = useLingui();
|
||||
const { theme, setTheme } = useTheme();
|
||||
const router = useRouter();
|
||||
const { i18n } = useLingui();
|
||||
const { theme, setTheme } = useTheme();
|
||||
|
||||
const options = Object.entries(themeMap).map(([value, label]) => ({
|
||||
value,
|
||||
label: i18n.t(label),
|
||||
keywords: [i18n.t(label)],
|
||||
}));
|
||||
const options = Object.entries(themeMap).map(([value, label]) => ({
|
||||
value,
|
||||
label: i18n.t(label),
|
||||
keywords: [i18n.t(label)],
|
||||
}));
|
||||
|
||||
const onThemeChange = async (value: string | null) => {
|
||||
if (!value || !isTheme(value)) return;
|
||||
await setThemeServerFn({ data: value });
|
||||
setTheme(value);
|
||||
void router.invalidate();
|
||||
};
|
||||
const onThemeChange = async (value: string | null) => {
|
||||
if (!value || !isTheme(value)) return;
|
||||
await setThemeServerFn({ data: value });
|
||||
setTheme(value);
|
||||
void router.invalidate();
|
||||
};
|
||||
|
||||
return <Combobox {...props} showClear={false} options={options} defaultValue={theme} onValueChange={onThemeChange} />;
|
||||
return <Combobox {...props} showClear={false} options={options} defaultValue={theme} onValueChange={onThemeChange} />;
|
||||
}
|
||||
|
||||
@@ -4,9 +4,9 @@ import { createContext, type PropsWithChildren, use } from "react";
|
||||
import { setThemeServerFn, type Theme } from "@/utils/theme";
|
||||
|
||||
type ThemeContextValue = {
|
||||
theme: Theme;
|
||||
setTheme: (value: Theme, options?: { playSound?: boolean }) => void;
|
||||
toggleTheme: (options?: { playSound?: boolean }) => void;
|
||||
theme: Theme;
|
||||
setTheme: (value: Theme, options?: { playSound?: boolean }) => void;
|
||||
toggleTheme: (options?: { playSound?: boolean }) => void;
|
||||
};
|
||||
|
||||
const ThemeContext = createContext<ThemeContextValue | null>(null);
|
||||
@@ -14,37 +14,37 @@ const ThemeContext = createContext<ThemeContextValue | null>(null);
|
||||
type Props = PropsWithChildren<{ theme: Theme }>;
|
||||
|
||||
export function ThemeProvider({ children, theme }: Props) {
|
||||
const router = useRouter();
|
||||
const router = useRouter();
|
||||
|
||||
async function setTheme(value: Theme, options: { playSound?: boolean } = {}) {
|
||||
const { playSound = true } = options;
|
||||
async function setTheme(value: Theme, options: { playSound?: boolean } = {}) {
|
||||
const { playSound = true } = options;
|
||||
|
||||
document.documentElement.classList.toggle("dark", value === "dark");
|
||||
await setThemeServerFn({ data: value });
|
||||
void router.invalidate();
|
||||
document.documentElement.classList.toggle("dark", value === "dark");
|
||||
await setThemeServerFn({ data: value });
|
||||
void router.invalidate();
|
||||
|
||||
if (!playSound) return;
|
||||
if (!playSound) return;
|
||||
|
||||
try {
|
||||
const soundClip = value === "dark" ? "/sounds/switch-off.mp3" : "/sounds/switch-on.mp3";
|
||||
const audio = new Audio(soundClip);
|
||||
await audio.play();
|
||||
} catch {
|
||||
// ignore errors
|
||||
}
|
||||
}
|
||||
try {
|
||||
const soundClip = value === "dark" ? "/sounds/switch-off.mp3" : "/sounds/switch-on.mp3";
|
||||
const audio = new Audio(soundClip);
|
||||
await audio.play();
|
||||
} catch {
|
||||
// ignore errors
|
||||
}
|
||||
}
|
||||
|
||||
function toggleTheme(options: { playSound?: boolean } = {}) {
|
||||
void setTheme(theme === "dark" ? "light" : "dark", options);
|
||||
}
|
||||
function toggleTheme(options: { playSound?: boolean } = {}) {
|
||||
void setTheme(theme === "dark" ? "light" : "dark", options);
|
||||
}
|
||||
|
||||
return <ThemeContext value={{ theme, setTheme, toggleTheme }}>{children}</ThemeContext>;
|
||||
return <ThemeContext value={{ theme, setTheme, toggleTheme }}>{children}</ThemeContext>;
|
||||
}
|
||||
|
||||
export function useTheme() {
|
||||
const value = use(ThemeContext);
|
||||
const value = use(ThemeContext);
|
||||
|
||||
if (!value) throw new Error("useTheme must be used within a ThemeProvider");
|
||||
if (!value) throw new Error("useTheme must be used within a ThemeProvider");
|
||||
|
||||
return value;
|
||||
return value;
|
||||
}
|
||||
|
||||
@@ -8,60 +8,60 @@ import { Button } from "@/components/ui/button";
|
||||
import { useTheme } from "./provider";
|
||||
|
||||
export function ThemeToggleButton(props: React.ComponentProps<typeof Button>) {
|
||||
const { theme, toggleTheme } = useTheme();
|
||||
const buttonRef = useRef<HTMLButtonElement>(null);
|
||||
const { theme, toggleTheme } = useTheme();
|
||||
const buttonRef = useRef<HTMLButtonElement>(null);
|
||||
|
||||
const onToggleTheme = useCallback(async () => {
|
||||
if (
|
||||
!buttonRef.current ||
|
||||
!document.startViewTransition ||
|
||||
window.matchMedia("(prefers-reduced-motion: reduce)").matches
|
||||
) {
|
||||
toggleTheme();
|
||||
return;
|
||||
}
|
||||
const onToggleTheme = useCallback(async () => {
|
||||
if (
|
||||
!buttonRef.current ||
|
||||
!document.startViewTransition ||
|
||||
window.matchMedia("(prefers-reduced-motion: reduce)").matches
|
||||
) {
|
||||
toggleTheme();
|
||||
return;
|
||||
}
|
||||
|
||||
let timeout: NodeJS.Timeout;
|
||||
const style = document.createElement("style");
|
||||
let timeout: NodeJS.Timeout;
|
||||
const style = document.createElement("style");
|
||||
|
||||
style.textContent = `
|
||||
style.textContent = `
|
||||
::view-transition-old(root), ::view-transition-new(root) {
|
||||
mix-blend-mode: normal !important;
|
||||
animation: none !important;
|
||||
}
|
||||
`;
|
||||
|
||||
function transitionCallback() {
|
||||
flushSync(() => {
|
||||
toggleTheme();
|
||||
timeout = setTimeout(() => {
|
||||
clearTimeout(timeout);
|
||||
document.head.removeChild(style);
|
||||
}, 1000);
|
||||
});
|
||||
}
|
||||
function transitionCallback() {
|
||||
flushSync(() => {
|
||||
toggleTheme();
|
||||
timeout = setTimeout(() => {
|
||||
clearTimeout(timeout);
|
||||
document.head.removeChild(style);
|
||||
}, 1000);
|
||||
});
|
||||
}
|
||||
|
||||
document.head.appendChild(style);
|
||||
await document.startViewTransition(transitionCallback).ready;
|
||||
document.head.appendChild(style);
|
||||
await document.startViewTransition(transitionCallback).ready;
|
||||
|
||||
const { top, left, width, height } = buttonRef.current.getBoundingClientRect();
|
||||
const x = left + width / 2;
|
||||
const y = top + height / 2;
|
||||
const right = window.innerWidth - left;
|
||||
const bottom = window.innerHeight - top;
|
||||
const maxRadius = Math.hypot(Math.max(left, right), Math.max(top, bottom));
|
||||
const { top, left, width, height } = buttonRef.current.getBoundingClientRect();
|
||||
const x = left + width / 2;
|
||||
const y = top + height / 2;
|
||||
const right = window.innerWidth - left;
|
||||
const bottom = window.innerHeight - top;
|
||||
const maxRadius = Math.hypot(Math.max(left, right), Math.max(top, bottom));
|
||||
|
||||
document.documentElement.animate(
|
||||
{ clipPath: [`circle(0px at ${x}px ${y}px)`, `circle(${maxRadius}px at ${x}px ${y}px)`] },
|
||||
{ duration: 500, easing: "ease-in-out", pseudoElement: "::view-transition-new(root)" },
|
||||
);
|
||||
}, [toggleTheme]);
|
||||
document.documentElement.animate(
|
||||
{ clipPath: [`circle(0px at ${x}px ${y}px)`, `circle(${maxRadius}px at ${x}px ${y}px)`] },
|
||||
{ duration: 500, easing: "ease-in-out", pseudoElement: "::view-transition-new(root)" },
|
||||
);
|
||||
}, [toggleTheme]);
|
||||
|
||||
const ariaLabel = theme === "dark" ? t`Switch to light theme` : t`Switch to dark theme`;
|
||||
const ariaLabel = theme === "dark" ? t`Switch to light theme` : t`Switch to dark theme`;
|
||||
|
||||
return (
|
||||
<Button size="icon" variant="ghost" ref={buttonRef} onClick={onToggleTheme} aria-label={ariaLabel} {...props}>
|
||||
{theme === "dark" ? <MoonIcon aria-hidden="true" /> : <SunIcon aria-hidden="true" />}
|
||||
</Button>
|
||||
);
|
||||
return (
|
||||
<Button size="icon" variant="ghost" ref={buttonRef} onClick={onToggleTheme} aria-label={ariaLabel} {...props}>
|
||||
{theme === "dark" ? <MoonIcon aria-hidden="true" /> : <SunIcon aria-hidden="true" />}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -11,96 +11,96 @@ import webFontListJSON from "./webfontlist.json";
|
||||
type Weight = "100" | "200" | "300" | "400" | "500" | "600" | "700" | "800" | "900";
|
||||
|
||||
const localFontList = [
|
||||
{ type: "local", category: "sans-serif", family: "Arial", weights: ["400", "600", "700"] },
|
||||
{ type: "local", category: "sans-serif", family: "Calibri", weights: ["400", "600", "700"] },
|
||||
{ type: "local", category: "sans-serif", family: "Helvetica", weights: ["400", "600", "700"] },
|
||||
{ type: "local", category: "sans-serif", family: "Tahoma", weights: ["400", "600", "700"] },
|
||||
{ type: "local", category: "sans-serif", family: "Trebuchet MS", weights: ["400", "600", "700"] },
|
||||
{ type: "local", category: "sans-serif", family: "Verdana", weights: ["400", "600", "700"] },
|
||||
{ type: "local", category: "serif", family: "Bookman", weights: ["400", "600", "700"] },
|
||||
{ type: "local", category: "serif", family: "Cambria", weights: ["400", "600", "700"] },
|
||||
{ type: "local", category: "serif", family: "Garamond", weights: ["400", "600", "700"] },
|
||||
{ type: "local", category: "serif", family: "Georgia", weights: ["400", "600", "700"] },
|
||||
{ type: "local", category: "serif", family: "Palatino", weights: ["400", "600", "700"] },
|
||||
{ type: "local", category: "serif", family: "Times New Roman", weights: ["400", "600", "700"] },
|
||||
{ type: "local", category: "sans-serif", family: "Arial", weights: ["400", "600", "700"] },
|
||||
{ type: "local", category: "sans-serif", family: "Calibri", weights: ["400", "600", "700"] },
|
||||
{ type: "local", category: "sans-serif", family: "Helvetica", weights: ["400", "600", "700"] },
|
||||
{ type: "local", category: "sans-serif", family: "Tahoma", weights: ["400", "600", "700"] },
|
||||
{ type: "local", category: "sans-serif", family: "Trebuchet MS", weights: ["400", "600", "700"] },
|
||||
{ type: "local", category: "sans-serif", family: "Verdana", weights: ["400", "600", "700"] },
|
||||
{ type: "local", category: "serif", family: "Bookman", weights: ["400", "600", "700"] },
|
||||
{ type: "local", category: "serif", family: "Cambria", weights: ["400", "600", "700"] },
|
||||
{ type: "local", category: "serif", family: "Garamond", weights: ["400", "600", "700"] },
|
||||
{ type: "local", category: "serif", family: "Georgia", weights: ["400", "600", "700"] },
|
||||
{ type: "local", category: "serif", family: "Palatino", weights: ["400", "600", "700"] },
|
||||
{ type: "local", category: "serif", family: "Times New Roman", weights: ["400", "600", "700"] },
|
||||
] as LocalFont[];
|
||||
|
||||
const webFontList = webFontListJSON as WebFont[];
|
||||
|
||||
function buildWebFontMap() {
|
||||
const webFontMap = new Map<string, WebFont>();
|
||||
const webFontMap = new Map<string, WebFont>();
|
||||
|
||||
for (const font of webFontList) {
|
||||
webFontMap.set(font.family, font);
|
||||
}
|
||||
for (const font of webFontList) {
|
||||
webFontMap.set(font.family, font);
|
||||
}
|
||||
|
||||
return webFontMap;
|
||||
return webFontMap;
|
||||
}
|
||||
|
||||
const webFontMap: Map<string, WebFont> = buildWebFontMap();
|
||||
|
||||
export function getNextWeights(fontFamily: string): Weight[] | null {
|
||||
const fontData = webFontMap.get(fontFamily);
|
||||
if (!fontData || !Array.isArray(fontData.weights) || fontData.weights.length === 0) return null;
|
||||
const fontData = webFontMap.get(fontFamily);
|
||||
if (!fontData || !Array.isArray(fontData.weights) || fontData.weights.length === 0) return null;
|
||||
|
||||
const uniqueWeights = Array.from(new Set(fontData.weights)) as Weight[];
|
||||
const uniqueWeights = Array.from(new Set(fontData.weights)) as Weight[];
|
||||
|
||||
// Try to pick 400 and 600 if available
|
||||
const weights: Weight[] = [];
|
||||
// Try to pick 400 and 600 if available
|
||||
const weights: Weight[] = [];
|
||||
|
||||
if (uniqueWeights.includes("400")) weights.push("400");
|
||||
if (uniqueWeights.includes("600")) weights.push("600");
|
||||
if (uniqueWeights.includes("400")) weights.push("400");
|
||||
if (uniqueWeights.includes("600")) weights.push("600");
|
||||
|
||||
// If we didn't find both, fill in with first/last, ensuring uniqueness
|
||||
while (weights.length < 2 && uniqueWeights.length > 0) {
|
||||
// candidateIndex: 0 (first), 1 (last)
|
||||
const lastIndex = uniqueWeights.length - 1;
|
||||
const candidate = weights.length === 0 ? uniqueWeights[0] : uniqueWeights[lastIndex];
|
||||
if (!weights.includes(candidate)) weights.push(candidate);
|
||||
else break;
|
||||
}
|
||||
// If we didn't find both, fill in with first/last, ensuring uniqueness
|
||||
while (weights.length < 2 && uniqueWeights.length > 0) {
|
||||
// candidateIndex: 0 (first), 1 (last)
|
||||
const lastIndex = uniqueWeights.length - 1;
|
||||
const candidate = weights.length === 0 ? uniqueWeights[0] : uniqueWeights[lastIndex];
|
||||
if (!weights.includes(candidate)) weights.push(candidate);
|
||||
else break;
|
||||
}
|
||||
|
||||
return weights.length > 0 ? weights : null;
|
||||
return weights.length > 0 ? weights : null;
|
||||
}
|
||||
|
||||
type FontFamilyComboboxProps = Omit<SingleComboboxProps, "options">;
|
||||
|
||||
export function FontFamilyCombobox({ className, ...props }: FontFamilyComboboxProps) {
|
||||
const options = useMemo(() => {
|
||||
return [...webFontList, ...localFontList].map((font: LocalFont | WebFont) => ({
|
||||
value: font.family,
|
||||
keywords: [font.family],
|
||||
label: <FontDisplay name={font.family} type={font.type} url={"preview" in font ? font.preview : undefined} />,
|
||||
}));
|
||||
}, []);
|
||||
const options = useMemo(() => {
|
||||
return [...webFontList, ...localFontList].map((font: LocalFont | WebFont) => ({
|
||||
value: font.family,
|
||||
keywords: [font.family],
|
||||
label: <FontDisplay name={font.family} type={font.type} url={"preview" in font ? font.preview : undefined} />,
|
||||
}));
|
||||
}, []);
|
||||
|
||||
return <Combobox {...props} options={options} className={cn("w-full", className)} />;
|
||||
return <Combobox {...props} options={options} className={cn("w-full", className)} />;
|
||||
}
|
||||
|
||||
type FontWeightComboboxProps = Omit<MultiComboboxProps, "options" | "multiple"> & { fontFamily: string };
|
||||
|
||||
export function FontWeightCombobox({ fontFamily, ...props }: FontWeightComboboxProps) {
|
||||
const options = useMemo(() => {
|
||||
const webFontData = webFontMap.get(fontFamily);
|
||||
const localFontData = localFontList.find((font) => font.family === fontFamily);
|
||||
const options = useMemo(() => {
|
||||
const webFontData = webFontMap.get(fontFamily);
|
||||
const localFontData = localFontList.find((font) => font.family === fontFamily);
|
||||
|
||||
let weights: string[] = [];
|
||||
let weights: string[] = [];
|
||||
|
||||
if (webFontData && Array.isArray(webFontData.weights) && webFontData.weights.length > 0) {
|
||||
weights = webFontData.weights as string[];
|
||||
} else if (localFontData && Array.isArray(localFontData.weights) && localFontData.weights.length > 0) {
|
||||
weights = localFontData.weights as string[];
|
||||
} else {
|
||||
// Fallback to all possible weights
|
||||
weights = ["100", "200", "300", "400", "500", "600", "700", "800", "900"];
|
||||
}
|
||||
if (webFontData && Array.isArray(webFontData.weights) && webFontData.weights.length > 0) {
|
||||
weights = webFontData.weights as string[];
|
||||
} else if (localFontData && Array.isArray(localFontData.weights) && localFontData.weights.length > 0) {
|
||||
weights = localFontData.weights as string[];
|
||||
} else {
|
||||
// Fallback to all possible weights
|
||||
weights = ["100", "200", "300", "400", "500", "600", "700", "800", "900"];
|
||||
}
|
||||
|
||||
return weights.map((variant: string) => ({
|
||||
value: variant,
|
||||
label: variant,
|
||||
keywords: [variant],
|
||||
}));
|
||||
}, [fontFamily]);
|
||||
return weights.map((variant: string) => ({
|
||||
value: variant,
|
||||
label: variant,
|
||||
keywords: [variant],
|
||||
}));
|
||||
}, [fontFamily]);
|
||||
|
||||
return <Combobox {...props} multiple options={options} />;
|
||||
return <Combobox {...props} multiple options={options} />;
|
||||
}
|
||||
|
||||
@@ -4,40 +4,40 @@ import { useEffect, useRef, useState } from "react";
|
||||
import { cn } from "@/utils/style";
|
||||
|
||||
interface FontDisplayProps {
|
||||
name: string;
|
||||
url?: string;
|
||||
type: "local" | "web";
|
||||
name: string;
|
||||
url?: string;
|
||||
type: "local" | "web";
|
||||
}
|
||||
|
||||
const loadedFonts = new Set<string>();
|
||||
|
||||
export function FontDisplay({ name, url, type = "web" }: FontDisplayProps) {
|
||||
const previewName = type === "local" ? name : `${name} Preview`;
|
||||
const previewName = type === "local" ? name : `${name} Preview`;
|
||||
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const [isLoaded, setIsLoaded] = useState(() => type === "local" || loadedFonts.has(previewName));
|
||||
const isInView = useInView(containerRef, { once: true, amount: 0.1, margin: "50px" });
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const [isLoaded, setIsLoaded] = useState(() => type === "local" || loadedFonts.has(previewName));
|
||||
const isInView = useInView(containerRef, { once: true, amount: 0.1, margin: "50px" });
|
||||
|
||||
useEffect(() => {
|
||||
if (!isInView || isLoaded || !url) return;
|
||||
useEffect(() => {
|
||||
if (!isInView || isLoaded || !url) return;
|
||||
|
||||
const fontFace = new FontFace(previewName, `url(${url})`, { display: "swap" });
|
||||
const fontFace = new FontFace(previewName, `url(${url})`, { display: "swap" });
|
||||
|
||||
void fontFace.load().then((loadedFace) => {
|
||||
if (!document.fonts.has(loadedFace)) document.fonts.add(loadedFace);
|
||||
loadedFonts.add(previewName);
|
||||
setIsLoaded(true);
|
||||
});
|
||||
}, [isInView, isLoaded, previewName, url]);
|
||||
void fontFace.load().then((loadedFace) => {
|
||||
if (!document.fonts.has(loadedFace)) document.fonts.add(loadedFace);
|
||||
loadedFonts.add(previewName);
|
||||
setIsLoaded(true);
|
||||
});
|
||||
}, [isInView, isLoaded, previewName, url]);
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className="inline">
|
||||
<span
|
||||
style={{ fontFamily: isLoaded ? `'${previewName}', sans-serif` : "sans-serif" }}
|
||||
className={cn(isLoaded ? "opacity-100" : "opacity-50", "transition-opacity duration-200 ease-in")}
|
||||
>
|
||||
{name}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
return (
|
||||
<div ref={containerRef} className="inline">
|
||||
<span
|
||||
style={{ fontFamily: isLoaded ? `'${previewName}', sans-serif` : "sans-serif" }}
|
||||
className={cn(isLoaded ? "opacity-100" : "opacity-50", "transition-opacity duration-200 ease-in")}
|
||||
>
|
||||
{name}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,17 +3,17 @@ type Weight = "100" | "200" | "300" | "400" | "500" | "600" | "700" | "800" | "9
|
||||
type FileWeight = Weight | `${Weight}italic`;
|
||||
|
||||
export type LocalFont = {
|
||||
type: "local";
|
||||
category: Category;
|
||||
family: string;
|
||||
weights: Weight[];
|
||||
type: "local";
|
||||
category: Category;
|
||||
family: string;
|
||||
weights: Weight[];
|
||||
};
|
||||
|
||||
export type WebFont = {
|
||||
type: "web";
|
||||
category: Category;
|
||||
family: string;
|
||||
weights: Weight[];
|
||||
preview: string;
|
||||
files: Record<FileWeight, string>;
|
||||
type: "web";
|
||||
category: Category;
|
||||
family: string;
|
||||
weights: Weight[];
|
||||
preview: string;
|
||||
files: Record<FileWeight, string>;
|
||||
};
|
||||
|
||||
+7500
-7500
File diff suppressed because it is too large
Load Diff
@@ -4,50 +4,50 @@ import { CaretUpIcon } from "@phosphor-icons/react";
|
||||
import { cn } from "@/utils/style";
|
||||
|
||||
function Accordion({ className, ...props }: AccordionPrimitive.Root.Props) {
|
||||
return <AccordionPrimitive.Root data-slot="accordion" className={cn("flex w-full flex-col", className)} {...props} />;
|
||||
return <AccordionPrimitive.Root data-slot="accordion" className={cn("flex w-full flex-col", className)} {...props} />;
|
||||
}
|
||||
|
||||
function AccordionItem({ className, ...props }: AccordionPrimitive.Item.Props) {
|
||||
return (
|
||||
<AccordionPrimitive.Item data-slot="accordion-item" className={cn("not-last:border-b", className)} {...props} />
|
||||
);
|
||||
return (
|
||||
<AccordionPrimitive.Item data-slot="accordion-item" className={cn("not-last:border-b", className)} {...props} />
|
||||
);
|
||||
}
|
||||
|
||||
function AccordionTrigger({ className, children, ...props }: AccordionPrimitive.Trigger.Props) {
|
||||
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-md border border-transparent py-4 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 aria-disabled:pointer-events-none aria-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}
|
||||
<CaretUpIcon data-slot="accordion-trigger-icon" className="pointer-events-none shrink-0" />
|
||||
</AccordionPrimitive.Trigger>
|
||||
</AccordionPrimitive.Header>
|
||||
);
|
||||
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-md border border-transparent py-4 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 aria-disabled:pointer-events-none aria-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}
|
||||
<CaretUpIcon data-slot="accordion-trigger-icon" className="pointer-events-none shrink-0" />
|
||||
</AccordionPrimitive.Trigger>
|
||||
</AccordionPrimitive.Header>
|
||||
);
|
||||
}
|
||||
|
||||
function AccordionContent({ className, children, ...props }: AccordionPrimitive.Panel.Props) {
|
||||
return (
|
||||
<AccordionPrimitive.Panel
|
||||
data-slot="accordion-content"
|
||||
className="overflow-hidden text-sm data-open:animate-accordion-down data-closed:animate-accordion-up"
|
||||
{...props}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"h-(--accordion-panel-height) pt-0 pb-4 data-ending-style:h-0 data-starting-style:h-0 [&_a]:underline [&_a]:underline-offset-3 [&_a]:hover:text-foreground [&_p:not(:last-child)]:mb-4",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</AccordionPrimitive.Panel>
|
||||
);
|
||||
return (
|
||||
<AccordionPrimitive.Panel
|
||||
data-slot="accordion-content"
|
||||
className="overflow-hidden text-sm data-open:animate-accordion-down data-closed:animate-accordion-up"
|
||||
{...props}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"h-(--accordion-panel-height) pt-0 pb-4 data-ending-style:h-0 data-starting-style:h-0 [&_a]:underline [&_a]:underline-offset-3 [&_a]:hover:text-foreground [&_p:not(:last-child)]:mb-4",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</AccordionPrimitive.Panel>
|
||||
);
|
||||
}
|
||||
|
||||
export { Accordion, AccordionContent, AccordionItem, AccordionTrigger };
|
||||
|
||||
+108
-108
@@ -6,152 +6,152 @@ import { Button } from "@/components/ui/button";
|
||||
import { cn } from "@/utils/style";
|
||||
|
||||
function AlertDialog({ ...props }: AlertDialogPrimitive.Root.Props) {
|
||||
return <AlertDialogPrimitive.Root data-slot="alert-dialog" {...props} />;
|
||||
return <AlertDialogPrimitive.Root data-slot="alert-dialog" {...props} />;
|
||||
}
|
||||
|
||||
function AlertDialogTrigger({ ...props }: AlertDialogPrimitive.Trigger.Props) {
|
||||
return <AlertDialogPrimitive.Trigger data-slot="alert-dialog-trigger" {...props} />;
|
||||
return <AlertDialogPrimitive.Trigger data-slot="alert-dialog-trigger" {...props} />;
|
||||
}
|
||||
|
||||
function AlertDialogPortal({ ...props }: AlertDialogPrimitive.Portal.Props) {
|
||||
return <AlertDialogPrimitive.Portal data-slot="alert-dialog-portal" {...props} />;
|
||||
return <AlertDialogPrimitive.Portal data-slot="alert-dialog-portal" {...props} />;
|
||||
}
|
||||
|
||||
function AlertDialogOverlay({ className, ...props }: AlertDialogPrimitive.Backdrop.Props) {
|
||||
return (
|
||||
<AlertDialogPrimitive.Backdrop
|
||||
data-slot="alert-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}
|
||||
/>
|
||||
);
|
||||
return (
|
||||
<AlertDialogPrimitive.Backdrop
|
||||
data-slot="alert-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 AlertDialogContent({
|
||||
className,
|
||||
size = "default",
|
||||
...props
|
||||
className,
|
||||
size = "default",
|
||||
...props
|
||||
}: AlertDialogPrimitive.Popup.Props & {
|
||||
size?: "default" | "sm";
|
||||
size?: "default" | "sm";
|
||||
}) {
|
||||
return (
|
||||
<AlertDialogPortal>
|
||||
<AlertDialogOverlay />
|
||||
<AlertDialogPrimitive.Popup
|
||||
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-6 rounded-xl bg-background p-6 ring-1 ring-foreground/10 duration-100 outline-none data-[size=default]:max-w-2xl data-[size=sm]:max-w-sm data-[size=default]:sm:max-w-lg 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>
|
||||
);
|
||||
return (
|
||||
<AlertDialogPortal>
|
||||
<AlertDialogOverlay />
|
||||
<AlertDialogPrimitive.Popup
|
||||
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-6 rounded-xl bg-background p-6 ring-1 ring-foreground/10 duration-100 outline-none data-[size=default]:max-w-2xl data-[size=sm]:max-w-sm data-[size=default]:sm:max-w-lg 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-6 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}
|
||||
/>
|
||||
);
|
||||
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-6 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(
|
||||
"flex flex-col-reverse gap-2 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}
|
||||
/>
|
||||
);
|
||||
return (
|
||||
<div
|
||||
data-slot="alert-dialog-footer"
|
||||
className={cn(
|
||||
"flex flex-col-reverse gap-2 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-16 items-center justify-center rounded-md bg-muted sm:group-data-[size=default]/alert-dialog-content:row-span-2 *:[svg:not([class*='size-'])]:size-8",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
return (
|
||||
<div
|
||||
data-slot="alert-dialog-media"
|
||||
className={cn(
|
||||
"mb-2 inline-flex size-16 items-center justify-center rounded-md bg-muted sm:group-data-[size=default]/alert-dialog-content:row-span-2 *:[svg:not([class*='size-'])]:size-8",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function AlertDialogTitle({ className, ...props }: React.ComponentProps<typeof AlertDialogPrimitive.Title>) {
|
||||
return (
|
||||
<AlertDialogPrimitive.Title
|
||||
data-slot="alert-dialog-title"
|
||||
className={cn(
|
||||
"text-lg 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}
|
||||
/>
|
||||
);
|
||||
return (
|
||||
<AlertDialogPrimitive.Title
|
||||
data-slot="alert-dialog-title"
|
||||
className={cn(
|
||||
"text-lg 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
|
||||
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}
|
||||
/>
|
||||
);
|
||||
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, ...props }: React.ComponentProps<typeof Button>) {
|
||||
return <Button data-slot="alert-dialog-action" className={cn(className)} {...props} />;
|
||||
return <Button data-slot="alert-dialog-action" className={cn(className)} {...props} />;
|
||||
}
|
||||
|
||||
function AlertDialogCancel({
|
||||
className,
|
||||
variant = "outline",
|
||||
size = "default",
|
||||
...props
|
||||
className,
|
||||
variant = "outline",
|
||||
size = "default",
|
||||
...props
|
||||
}: AlertDialogPrimitive.Close.Props & Pick<React.ComponentProps<typeof Button>, "variant" | "size">) {
|
||||
return (
|
||||
<AlertDialogPrimitive.Close
|
||||
data-slot="alert-dialog-cancel"
|
||||
className={cn(className)}
|
||||
render={<Button variant={variant} size={size} />}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
return (
|
||||
<AlertDialogPrimitive.Close
|
||||
data-slot="alert-dialog-cancel"
|
||||
className={cn(className)}
|
||||
render={<Button variant={variant} size={size} />}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogMedia,
|
||||
AlertDialogOverlay,
|
||||
AlertDialogPortal,
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogMedia,
|
||||
AlertDialogOverlay,
|
||||
AlertDialogPortal,
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
};
|
||||
|
||||
+35
-35
@@ -5,53 +5,53 @@ import { cva, type VariantProps } from "class-variance-authority";
|
||||
import { cn } from "@/utils/style";
|
||||
|
||||
const alertVariants = cva(
|
||||
"group/alert relative grid w-full gap-1 rounded-lg border px-4 py-3 text-start text-sm has-data-[slot=alert-action]:relative has-data-[slot=alert-action]:pe-18 has-[>svg]:grid-cols-[auto_1fr] has-[>svg]:gap-x-2.5 *:[svg]:row-span-2 *:[svg]:translate-y-1 *:[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",
|
||||
},
|
||||
},
|
||||
"group/alert relative grid w-full gap-1 rounded-lg border px-4 py-3 text-start text-sm has-data-[slot=alert-action]:relative has-data-[slot=alert-action]:pe-18 has-[>svg]:grid-cols-[auto_1fr] has-[>svg]:gap-x-2.5 *:[svg]:row-span-2 *:[svg]:translate-y-1 *:[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} />;
|
||||
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(
|
||||
"font-medium group-has-[>svg]/alert:col-start-2 [&_a]:underline [&_a]:underline-offset-3 [&_a]:hover:text-foreground",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
return (
|
||||
<div
|
||||
data-slot="alert-title"
|
||||
className={cn(
|
||||
"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(
|
||||
"text-sm text-muted-foreground md:text-pretty [&_a]:underline [&_a]:underline-offset-3 [&_a]:hover:text-foreground [&_p:not(:last-child)]:mb-4",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
return (
|
||||
<div
|
||||
data-slot="alert-description"
|
||||
className={cn(
|
||||
"text-sm text-muted-foreground 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 inset-e-3 top-2.5", className)} {...props} />;
|
||||
return <div data-slot="alert-action" className={cn("absolute inset-e-3 top-2.5", className)} {...props} />;
|
||||
}
|
||||
|
||||
export { Alert, AlertAction, AlertDescription, AlertTitle };
|
||||
|
||||
@@ -5,88 +5,88 @@ import { Avatar as AvatarPrimitive } from "@base-ui/react/avatar";
|
||||
import { cn } from "@/utils/style";
|
||||
|
||||
function Avatar({
|
||||
className,
|
||||
size = "default",
|
||||
...props
|
||||
className,
|
||||
size = "default",
|
||||
...props
|
||||
}: AvatarPrimitive.Root.Props & {
|
||||
size?: "default" | "sm" | "lg";
|
||||
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}
|
||||
/>
|
||||
);
|
||||
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 }: AvatarPrimitive.Image.Props) {
|
||||
return (
|
||||
<AvatarPrimitive.Image
|
||||
data-slot="avatar-image"
|
||||
className={cn("aspect-square size-full rounded-full object-cover", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
return (
|
||||
<AvatarPrimitive.Image
|
||||
data-slot="avatar-image"
|
||||
className={cn("aspect-square size-full rounded-full object-cover", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function AvatarFallback({ className, ...props }: AvatarPrimitive.Fallback.Props) {
|
||||
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}
|
||||
/>
|
||||
);
|
||||
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 inset-e-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}
|
||||
/>
|
||||
);
|
||||
return (
|
||||
<span
|
||||
data-slot="avatar-badge"
|
||||
className={cn(
|
||||
"absolute inset-e-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}
|
||||
/>
|
||||
);
|
||||
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}
|
||||
/>
|
||||
);
|
||||
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, AvatarBadge, AvatarFallback, AvatarGroup, AvatarGroupCount, AvatarImage };
|
||||
|
||||
+27
-27
@@ -5,37 +5,37 @@ import { cva, type VariantProps } from "class-variance-authority";
|
||||
import { cn } from "@/utils/style";
|
||||
|
||||
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]:pe-1.5 has-data-[icon=inline-start]:ps-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",
|
||||
},
|
||||
},
|
||||
"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]:pe-1.5 has-data-[icon=inline-start]:ps-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",
|
||||
render,
|
||||
...props
|
||||
className,
|
||||
variant = "default",
|
||||
render,
|
||||
...props
|
||||
}: useRender.ComponentProps<"span"> & VariantProps<typeof badgeVariants>) {
|
||||
return useRender({
|
||||
render,
|
||||
defaultTagName: "span",
|
||||
state: { slot: "badge", variant },
|
||||
props: mergeProps<"span">({ className: cn(badgeVariants({ variant }), className) }, props),
|
||||
});
|
||||
return useRender({
|
||||
render,
|
||||
defaultTagName: "span",
|
||||
state: { slot: "badge", variant },
|
||||
props: mergeProps<"span">({ className: cn(badgeVariants({ variant }), className) }, props),
|
||||
});
|
||||
}
|
||||
|
||||
export { Badge, badgeVariants };
|
||||
|
||||
@@ -1,24 +1,24 @@
|
||||
import { cn } from "@/utils/style";
|
||||
|
||||
type Props = React.ComponentProps<"img"> & {
|
||||
variant?: "logo" | "icon";
|
||||
variant?: "logo" | "icon";
|
||||
};
|
||||
|
||||
export function BrandIcon({ variant = "logo", className, ...props }: Props) {
|
||||
return (
|
||||
<>
|
||||
<img
|
||||
src={`/${variant}/dark.svg`}
|
||||
alt="Reactive Resume"
|
||||
className={cn("hidden size-12 dark:block", className)}
|
||||
{...props}
|
||||
/>
|
||||
<img
|
||||
src={`/${variant}/light.svg`}
|
||||
alt="Reactive Resume"
|
||||
className={cn("block size-12 dark:hidden", className)}
|
||||
{...props}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
return (
|
||||
<>
|
||||
<img
|
||||
src={`/${variant}/dark.svg`}
|
||||
alt="Reactive Resume"
|
||||
className={cn("hidden size-12 dark:block", className)}
|
||||
{...props}
|
||||
/>
|
||||
<img
|
||||
src={`/${variant}/light.svg`}
|
||||
alt="Reactive Resume"
|
||||
className={cn("block size-12 dark:hidden", className)}
|
||||
{...props}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -6,71 +6,71 @@ import { Separator } from "@/components/ui/separator";
|
||||
import { cn } from "@/utils/style";
|
||||
|
||||
const buttonGroupVariants = cva(
|
||||
"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-e-md [&>[data-slot=select-trigger]:not([class*='w-'])]:w-fit [&>input]:flex-1",
|
||||
{
|
||||
variants: {
|
||||
orientation: {
|
||||
horizontal:
|
||||
"*:data-slot:rounded-e-none [&>[data-slot]:not(:has(~[data-slot]))]:rounded-e-md! [&>[data-slot]~[data-slot]]:rounded-s-none [&>[data-slot]~[data-slot]]:border-s-0",
|
||||
vertical:
|
||||
"flex-col *:data-slot:rounded-b-none [&>[data-slot]:not(:has(~[data-slot]))]:rounded-b-md! [&>[data-slot]~[data-slot]]:rounded-t-none [&>[data-slot]~[data-slot]]:border-t-0",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
orientation: "horizontal",
|
||||
},
|
||||
},
|
||||
"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-e-md [&>[data-slot=select-trigger]:not([class*='w-'])]:w-fit [&>input]:flex-1",
|
||||
{
|
||||
variants: {
|
||||
orientation: {
|
||||
horizontal:
|
||||
"*:data-slot:rounded-e-none [&>[data-slot]:not(:has(~[data-slot]))]:rounded-e-md! [&>[data-slot]~[data-slot]]:rounded-s-none [&>[data-slot]~[data-slot]]:border-s-0",
|
||||
vertical:
|
||||
"flex-col *:data-slot:rounded-b-none [&>[data-slot]:not(:has(~[data-slot]))]:rounded-b-md! [&>[data-slot]~[data-slot]]:rounded-t-none [&>[data-slot]~[data-slot]]:border-t-0",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
orientation: "horizontal",
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
function ButtonGroup({
|
||||
className,
|
||||
orientation,
|
||||
...props
|
||||
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}
|
||||
/>
|
||||
);
|
||||
return (
|
||||
<div
|
||||
role="group"
|
||||
data-slot="button-group"
|
||||
data-orientation={orientation}
|
||||
className={cn(buttonGroupVariants({ orientation }), className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function ButtonGroupText({ className, render, ...props }: useRender.ComponentProps<"div">) {
|
||||
return useRender({
|
||||
render,
|
||||
defaultTagName: "div",
|
||||
state: { slot: "button-group-text" },
|
||||
props: mergeProps<"div">(
|
||||
{
|
||||
className: cn(
|
||||
"flex items-center gap-2 rounded-md border bg-muted px-2.5 text-sm font-medium shadow-xs [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4",
|
||||
className,
|
||||
),
|
||||
},
|
||||
props,
|
||||
),
|
||||
});
|
||||
return useRender({
|
||||
render,
|
||||
defaultTagName: "div",
|
||||
state: { slot: "button-group-text" },
|
||||
props: mergeProps<"div">(
|
||||
{
|
||||
className: cn(
|
||||
"flex items-center gap-2 rounded-md border bg-muted px-2.5 text-sm font-medium shadow-xs [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4",
|
||||
className,
|
||||
),
|
||||
},
|
||||
props,
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
function ButtonGroupSeparator({
|
||||
className,
|
||||
orientation = "vertical",
|
||||
...props
|
||||
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}
|
||||
/>
|
||||
);
|
||||
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 };
|
||||
|
||||
@@ -4,42 +4,42 @@ import { cva, type VariantProps } from "class-variance-authority";
|
||||
import { cn } from "@/utils/style";
|
||||
|
||||
const buttonVariants = cva(
|
||||
"group/button inline-flex shrink-0 cursor-pointer items-center justify-center rounded-md 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 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 shadow-xs hover:bg-muted hover:text-foreground",
|
||||
secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||
ghost: "hover:bg-muted hover:text-foreground",
|
||||
destructive:
|
||||
"bg-destructive/10 text-destructive hover:bg-destructive/20 focus-visible:border-destructive/40 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40",
|
||||
link: "text-primary underline-offset-4 hover:underline",
|
||||
},
|
||||
size: {
|
||||
default:
|
||||
"h-9 gap-1.5 px-2.5 in-data-[slot=button-group]:rounded-md has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
|
||||
xs: "h-6 gap-1 rounded-[min(var(--radius-md),8px)] px-2 text-xs in-data-[slot=button-group]:rounded-md has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3",
|
||||
sm: "h-8 gap-1 rounded-[min(var(--radius-md),10px)] px-2.5 in-data-[slot=button-group]:rounded-md has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5",
|
||||
lg: "h-10 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-3 has-data-[icon=inline-start]:pl-3",
|
||||
icon: "size-9",
|
||||
"icon-xs":
|
||||
"size-6 rounded-[min(var(--radius-md),8px)] in-data-[slot=button-group]:rounded-md [&_svg:not([class*='size-'])]:size-3",
|
||||
"icon-sm": "size-8 rounded-[min(var(--radius-md),10px)] in-data-[slot=button-group]:rounded-md",
|
||||
"icon-lg": "size-10",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
},
|
||||
"group/button inline-flex shrink-0 cursor-pointer items-center justify-center rounded-md 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 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 shadow-xs hover:bg-muted hover:text-foreground",
|
||||
secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||
ghost: "hover:bg-muted hover:text-foreground",
|
||||
destructive:
|
||||
"bg-destructive/10 text-destructive hover:bg-destructive/20 focus-visible:border-destructive/40 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40",
|
||||
link: "text-primary underline-offset-4 hover:underline",
|
||||
},
|
||||
size: {
|
||||
default:
|
||||
"h-9 gap-1.5 px-2.5 in-data-[slot=button-group]:rounded-md has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
|
||||
xs: "h-6 gap-1 rounded-[min(var(--radius-md),8px)] px-2 text-xs in-data-[slot=button-group]:rounded-md has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3",
|
||||
sm: "h-8 gap-1 rounded-[min(var(--radius-md),10px)] px-2.5 in-data-[slot=button-group]:rounded-md has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5",
|
||||
lg: "h-10 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-3 has-data-[icon=inline-start]:pl-3",
|
||||
icon: "size-9",
|
||||
"icon-xs":
|
||||
"size-6 rounded-[min(var(--radius-md),8px)] in-data-[slot=button-group]:rounded-md [&_svg:not([class*='size-'])]:size-3",
|
||||
"icon-sm": "size-8 rounded-[min(var(--radius-md),10px)] in-data-[slot=button-group]:rounded-md",
|
||||
"icon-lg": "size-10",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
type ButtonProps = ButtonPrimitive.Props & VariantProps<typeof buttonVariants>;
|
||||
|
||||
function Button({ className, variant = "default", size = "default", ...props }: ButtonProps) {
|
||||
return <ButtonPrimitive data-slot="button" className={cn(buttonVariants({ variant, size, className }))} {...props} />;
|
||||
return <ButtonPrimitive data-slot="button" className={cn(buttonVariants({ variant, size, className }))} {...props} />;
|
||||
}
|
||||
|
||||
export { Button, type ButtonProps, buttonVariants };
|
||||
|
||||
+356
-356
@@ -12,443 +12,443 @@ import { cn } from "@/utils/style";
|
||||
const ComboboxRoot = ComboboxPrimitive.Root;
|
||||
|
||||
function ComboboxValue({ ...props }: ComboboxPrimitive.Value.Props) {
|
||||
return <ComboboxPrimitive.Value data-slot="combobox-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("shrink-0 [&_svg:not([class*='size-'])]:size-4", className)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<CaretDownIcon className="pointer-events-none size-4 text-muted-foreground" />
|
||||
</ComboboxPrimitive.Trigger>
|
||||
);
|
||||
return (
|
||||
<ComboboxPrimitive.Trigger
|
||||
data-slot="combobox-trigger"
|
||||
className={cn("shrink-0 [&_svg:not([class*='size-'])]:size-4", className)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<CaretDownIcon 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"
|
||||
className={cn(className)}
|
||||
{...props}
|
||||
render={
|
||||
<InputGroupButton variant="ghost" size="icon-xs">
|
||||
<XIcon className="pointer-events-none" />
|
||||
</InputGroupButton>
|
||||
}
|
||||
/>
|
||||
);
|
||||
return (
|
||||
<ComboboxPrimitive.Clear
|
||||
data-slot="combobox-clear"
|
||||
className={cn(className)}
|
||||
{...props}
|
||||
render={
|
||||
<InputGroupButton variant="ghost" size="icon-xs">
|
||||
<XIcon className="pointer-events-none" />
|
||||
</InputGroupButton>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function ComboboxInput({
|
||||
className,
|
||||
children,
|
||||
disabled = false,
|
||||
showTrigger = true,
|
||||
showClear = false,
|
||||
...props
|
||||
className,
|
||||
children,
|
||||
disabled = false,
|
||||
showTrigger = true,
|
||||
showClear = false,
|
||||
...props
|
||||
}: ComboboxPrimitive.Input.Props & {
|
||||
showTrigger?: boolean;
|
||||
showClear?: boolean;
|
||||
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"
|
||||
render={<ComboboxTrigger />}
|
||||
data-slot="input-group-button"
|
||||
className="group-has-data-[slot=combobox-clear]/input-group:hidden data-pressed:bg-transparent"
|
||||
disabled={disabled}
|
||||
/>
|
||||
)}
|
||||
{showClear && <ComboboxClear disabled={disabled} />}
|
||||
</InputGroupAddon>
|
||||
{children}
|
||||
</InputGroup>
|
||||
);
|
||||
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"
|
||||
render={<ComboboxTrigger />}
|
||||
data-slot="input-group-button"
|
||||
className="group-has-data-[slot=combobox-clear]/input-group:hidden data-pressed:bg-transparent"
|
||||
disabled={disabled}
|
||||
/>
|
||||
)}
|
||||
{showClear && <ComboboxClear disabled={disabled} />}
|
||||
</InputGroupAddon>
|
||||
{children}
|
||||
</InputGroup>
|
||||
);
|
||||
}
|
||||
|
||||
function ComboboxContent({
|
||||
className,
|
||||
side = "bottom",
|
||||
sideOffset = 6,
|
||||
align = "start",
|
||||
alignOffset = 0,
|
||||
anchor,
|
||||
...props
|
||||
className,
|
||||
side = "bottom",
|
||||
sideOffset = 6,
|
||||
align = "start",
|
||||
alignOffset = 0,
|
||||
anchor,
|
||||
...props
|
||||
}: ComboboxPrimitive.Popup.Props &
|
||||
Pick<ComboboxPrimitive.Positioner.Props, "side" | "align" | "sideOffset" | "alignOffset" | "anchor">) {
|
||||
return (
|
||||
<ComboboxPrimitive.Portal>
|
||||
<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-60 origin-(--transform-origin) overflow-hidden rounded-md 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>
|
||||
);
|
||||
Pick<ComboboxPrimitive.Positioner.Props, "side" | "align" | "sideOffset" | "alignOffset" | "anchor">) {
|
||||
return (
|
||||
<ComboboxPrimitive.Portal>
|
||||
<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-60 origin-(--transform-origin) overflow-hidden rounded-md 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-80 scroll-py-1 overflow-y-auto overscroll-contain p-1 data-empty:p-0",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
return (
|
||||
<ComboboxPrimitive.List
|
||||
data-slot="combobox-list"
|
||||
className={cn(
|
||||
"no-scrollbar max-h-80 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" />
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
</ComboboxPrimitive.Item>
|
||||
);
|
||||
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" />
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
</ComboboxPrimitive.Item>
|
||||
);
|
||||
}
|
||||
|
||||
function ComboboxGroup({ className, ...props }: ComboboxPrimitive.Group.Props) {
|
||||
return <ComboboxPrimitive.Group data-slot="combobox-group" className={cn(className)} {...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}
|
||||
/>
|
||||
);
|
||||
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} />;
|
||||
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}
|
||||
/>
|
||||
);
|
||||
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}
|
||||
/>
|
||||
);
|
||||
return (
|
||||
<ComboboxPrimitive.Separator
|
||||
data-slot="combobox-separator"
|
||||
className={cn("-mx-1 my-1 h-px bg-border", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function ComboboxChips({
|
||||
className,
|
||||
...props
|
||||
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-md 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-2 dark:has-aria-invalid:border-destructive/50 dark:has-aria-invalid:ring-destructive/40",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
return (
|
||||
<ComboboxPrimitive.Chips
|
||||
data-slot="combobox-chips"
|
||||
className={cn(
|
||||
"flex min-h-8 flex-wrap items-center gap-1 rounded-md 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-2 dark:has-aria-invalid:border-destructive/50 dark:has-aria-invalid:ring-destructive/40",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function ComboboxChip({
|
||||
className,
|
||||
children,
|
||||
showRemove = true,
|
||||
...props
|
||||
className,
|
||||
children,
|
||||
showRemove = true,
|
||||
...props
|
||||
}: ComboboxPrimitive.Chip.Props & {
|
||||
showRemove?: boolean;
|
||||
showRemove?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<ComboboxPrimitive.Chip
|
||||
data-slot="combobox-chip"
|
||||
className={cn(
|
||||
"flex h-6 w-fit items-center justify-center gap-1 rounded-md 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
|
||||
className="-ml-1 opacity-50 hover:opacity-100"
|
||||
data-slot="combobox-chip-remove"
|
||||
render={
|
||||
<Button variant="ghost" size="icon-xs">
|
||||
<XIcon className="pointer-events-none" />
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</ComboboxPrimitive.Chip>
|
||||
);
|
||||
return (
|
||||
<ComboboxPrimitive.Chip
|
||||
data-slot="combobox-chip"
|
||||
className={cn(
|
||||
"flex h-6 w-fit items-center justify-center gap-1 rounded-md 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
|
||||
className="-ml-1 opacity-50 hover:opacity-100"
|
||||
data-slot="combobox-chip-remove"
|
||||
render={
|
||||
<Button variant="ghost" size="icon-xs">
|
||||
<XIcon className="pointer-events-none" />
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</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}
|
||||
/>
|
||||
);
|
||||
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);
|
||||
return React.useRef<HTMLDivElement | null>(null);
|
||||
}
|
||||
|
||||
type ComboboxOption<TValue extends string | number = string> = {
|
||||
value: TValue;
|
||||
label: React.ReactNode;
|
||||
keywords?: string[];
|
||||
disabled?: boolean;
|
||||
value: TValue;
|
||||
label: React.ReactNode;
|
||||
keywords?: string[];
|
||||
disabled?: boolean;
|
||||
};
|
||||
|
||||
type SingleComboboxProps<TValue extends string | number = string> = {
|
||||
options: ComboboxOption<TValue>[];
|
||||
value?: TValue | null;
|
||||
defaultValue?: TValue | null;
|
||||
onValueChange?: (value: TValue | null) => void;
|
||||
multiple?: false;
|
||||
disabled?: boolean;
|
||||
showClear?: boolean;
|
||||
placeholder?: string;
|
||||
searchPlaceholder?: string;
|
||||
emptyMessage?: React.ReactNode;
|
||||
className?: string;
|
||||
id?: string;
|
||||
name?: string;
|
||||
render?: UseRenderRenderProp<ComboboxTriggerState>;
|
||||
options: ComboboxOption<TValue>[];
|
||||
value?: TValue | null;
|
||||
defaultValue?: TValue | null;
|
||||
onValueChange?: (value: TValue | null) => void;
|
||||
multiple?: false;
|
||||
disabled?: boolean;
|
||||
showClear?: boolean;
|
||||
placeholder?: string;
|
||||
searchPlaceholder?: string;
|
||||
emptyMessage?: React.ReactNode;
|
||||
className?: string;
|
||||
id?: string;
|
||||
name?: string;
|
||||
render?: UseRenderRenderProp<ComboboxTriggerState>;
|
||||
};
|
||||
|
||||
type MultiComboboxProps<TValue extends string | number = string> = {
|
||||
options: ComboboxOption<TValue>[];
|
||||
value?: TValue[] | null;
|
||||
defaultValue?: TValue[] | null;
|
||||
onValueChange?: (value: TValue[] | null) => void;
|
||||
multiple: true;
|
||||
disabled?: boolean;
|
||||
showClear?: boolean;
|
||||
placeholder?: string;
|
||||
searchPlaceholder?: string;
|
||||
emptyMessage?: React.ReactNode;
|
||||
className?: string;
|
||||
id?: string;
|
||||
name?: string;
|
||||
render?: UseRenderRenderProp<ComboboxTriggerState>;
|
||||
options: ComboboxOption<TValue>[];
|
||||
value?: TValue[] | null;
|
||||
defaultValue?: TValue[] | null;
|
||||
onValueChange?: (value: TValue[] | null) => void;
|
||||
multiple: true;
|
||||
disabled?: boolean;
|
||||
showClear?: boolean;
|
||||
placeholder?: string;
|
||||
searchPlaceholder?: string;
|
||||
emptyMessage?: React.ReactNode;
|
||||
className?: string;
|
||||
id?: string;
|
||||
name?: string;
|
||||
render?: UseRenderRenderProp<ComboboxTriggerState>;
|
||||
};
|
||||
|
||||
type ComboboxProps<TValue extends string | number = string> = SingleComboboxProps<TValue> | MultiComboboxProps<TValue>;
|
||||
|
||||
function Combobox<TValue extends string | number = string>(props: ComboboxProps<TValue>) {
|
||||
const {
|
||||
options,
|
||||
multiple = false,
|
||||
disabled = false,
|
||||
showClear = false,
|
||||
placeholder,
|
||||
searchPlaceholder,
|
||||
emptyMessage,
|
||||
className,
|
||||
id,
|
||||
name,
|
||||
render,
|
||||
} = props;
|
||||
const {
|
||||
options,
|
||||
multiple = false,
|
||||
disabled = false,
|
||||
showClear = false,
|
||||
placeholder,
|
||||
searchPlaceholder,
|
||||
emptyMessage,
|
||||
className,
|
||||
id,
|
||||
name,
|
||||
render,
|
||||
} = props;
|
||||
|
||||
const { contains } = ComboboxPrimitive.useFilter();
|
||||
const { contains } = ComboboxPrimitive.useFilter();
|
||||
|
||||
const optionMap = React.useMemo(() => new Map(options.map((opt) => [String(opt.value), opt])), [options]);
|
||||
const optionMap = React.useMemo(() => new Map(options.map((opt) => [String(opt.value), opt])), [options]);
|
||||
|
||||
const findOption = React.useCallback(
|
||||
(v: TValue | TValue[] | null | undefined) => {
|
||||
if (multiple) {
|
||||
if (!v || !Array.isArray(v)) return [];
|
||||
return (v as TValue[])
|
||||
.map((item) => optionMap.get(String(item)) ?? null)
|
||||
.filter(Boolean) as ComboboxOption<TValue>[];
|
||||
} else {
|
||||
if (v == null) return null;
|
||||
return optionMap.get(String(v)) ?? null;
|
||||
}
|
||||
},
|
||||
[optionMap, multiple],
|
||||
);
|
||||
const findOption = React.useCallback(
|
||||
(v: TValue | TValue[] | null | undefined) => {
|
||||
if (multiple) {
|
||||
if (!v || !Array.isArray(v)) return [];
|
||||
return (v as TValue[])
|
||||
.map((item) => optionMap.get(String(item)) ?? null)
|
||||
.filter(Boolean) as ComboboxOption<TValue>[];
|
||||
} else {
|
||||
if (v == null) return null;
|
||||
return optionMap.get(String(v)) ?? null;
|
||||
}
|
||||
},
|
||||
[optionMap, multiple],
|
||||
);
|
||||
|
||||
type OptionValue = ComboboxOption<TValue>[] | ComboboxOption<TValue> | null;
|
||||
type OptionValue = ComboboxOption<TValue>[] | ComboboxOption<TValue> | null;
|
||||
|
||||
const rawValueKey = props.value !== undefined ? JSON.stringify(props.value) : undefined;
|
||||
const resolvedValue = React.useMemo(
|
||||
() => (props.value !== undefined ? (findOption(props.value) as OptionValue) : undefined),
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- stable key avoids new-reference loops
|
||||
[rawValueKey, optionMap],
|
||||
);
|
||||
const rawValueKey = props.value !== undefined ? JSON.stringify(props.value) : undefined;
|
||||
const resolvedValue = React.useMemo(
|
||||
() => (props.value !== undefined ? (findOption(props.value) as OptionValue) : undefined),
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- stable key avoids new-reference loops
|
||||
[rawValueKey, optionMap],
|
||||
);
|
||||
|
||||
const rawDefaultKey = props.defaultValue !== undefined ? JSON.stringify(props.defaultValue) : undefined;
|
||||
const resolvedDefaultValue = React.useMemo(
|
||||
() => (props.defaultValue !== undefined ? (findOption(props.defaultValue) as OptionValue) : undefined),
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- only needed on mount / options change
|
||||
[rawDefaultKey, optionMap],
|
||||
);
|
||||
const rawDefaultKey = props.defaultValue !== undefined ? JSON.stringify(props.defaultValue) : undefined;
|
||||
const resolvedDefaultValue = React.useMemo(
|
||||
() => (props.defaultValue !== undefined ? (findOption(props.defaultValue) as OptionValue) : undefined),
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- only needed on mount / options change
|
||||
[rawDefaultKey, optionMap],
|
||||
);
|
||||
|
||||
const handleExternalChange = React.useCallback(
|
||||
(option: ComboboxOption<TValue>[] | ComboboxOption<TValue> | null) => {
|
||||
if (multiple) {
|
||||
const arrOpt = Array.isArray(option) ? option : option ? [option] : [];
|
||||
(props as MultiComboboxProps<TValue>).onValueChange?.(arrOpt.length > 0 ? arrOpt.map((opt) => opt.value) : []);
|
||||
} else {
|
||||
const value = option && !Array.isArray(option) ? (option as ComboboxOption<TValue>).value : null;
|
||||
const cb = props.onValueChange as ((value: TValue | null) => void) | undefined;
|
||||
cb?.(value ?? null);
|
||||
}
|
||||
},
|
||||
[props, multiple],
|
||||
);
|
||||
const handleExternalChange = React.useCallback(
|
||||
(option: ComboboxOption<TValue>[] | ComboboxOption<TValue> | null) => {
|
||||
if (multiple) {
|
||||
const arrOpt = Array.isArray(option) ? option : option ? [option] : [];
|
||||
(props as MultiComboboxProps<TValue>).onValueChange?.(arrOpt.length > 0 ? arrOpt.map((opt) => opt.value) : []);
|
||||
} else {
|
||||
const value = option && !Array.isArray(option) ? (option as ComboboxOption<TValue>).value : null;
|
||||
const cb = props.onValueChange as ((value: TValue | null) => void) | undefined;
|
||||
cb?.(value ?? null);
|
||||
}
|
||||
},
|
||||
[props, multiple],
|
||||
);
|
||||
|
||||
const [selectedValue, setSelectedValue] = useControlledState({
|
||||
value: resolvedValue,
|
||||
defaultValue: resolvedDefaultValue,
|
||||
onChange: handleExternalChange,
|
||||
});
|
||||
const [selectedValue, setSelectedValue] = useControlledState({
|
||||
value: resolvedValue,
|
||||
defaultValue: resolvedDefaultValue,
|
||||
onChange: handleExternalChange,
|
||||
});
|
||||
|
||||
const itemToStringLabel = React.useCallback(
|
||||
(item: ComboboxOption<TValue>) => (typeof item.label === "string" ? item.label : String(item.value)),
|
||||
[],
|
||||
);
|
||||
const itemToStringLabel = React.useCallback(
|
||||
(item: ComboboxOption<TValue>) => (typeof item.label === "string" ? item.label : String(item.value)),
|
||||
[],
|
||||
);
|
||||
|
||||
const isItemEqualToValue = React.useCallback(
|
||||
(a: ComboboxOption<TValue>, b: ComboboxOption<TValue>) => String(a.value) === String(b.value),
|
||||
[],
|
||||
);
|
||||
const isItemEqualToValue = React.useCallback(
|
||||
(a: ComboboxOption<TValue>, b: ComboboxOption<TValue>) => String(a.value) === String(b.value),
|
||||
[],
|
||||
);
|
||||
|
||||
const filter = React.useCallback(
|
||||
(item: ComboboxOption<TValue>, query: string) => {
|
||||
const labelStr = typeof item.label === "string" ? item.label : String(item.value);
|
||||
if (contains(labelStr, query)) return true;
|
||||
return item.keywords?.some((kw) => contains(kw, query)) ?? false;
|
||||
},
|
||||
[contains],
|
||||
);
|
||||
const filter = React.useCallback(
|
||||
(item: ComboboxOption<TValue>, query: string) => {
|
||||
const labelStr = typeof item.label === "string" ? item.label : String(item.value);
|
||||
if (contains(labelStr, query)) return true;
|
||||
return item.keywords?.some((kw) => contains(kw, query)) ?? false;
|
||||
},
|
||||
[contains],
|
||||
);
|
||||
|
||||
const listContent = (item: ComboboxOption<TValue>) => (
|
||||
<ComboboxItem key={String(item.value)} value={item} disabled={item.disabled}>
|
||||
{item.label}
|
||||
</ComboboxItem>
|
||||
);
|
||||
const listContent = (item: ComboboxOption<TValue>) => (
|
||||
<ComboboxItem key={String(item.value)} value={item} disabled={item.disabled}>
|
||||
{item.label}
|
||||
</ComboboxItem>
|
||||
);
|
||||
|
||||
return (
|
||||
<ComboboxRoot
|
||||
name={name}
|
||||
items={options}
|
||||
filter={filter}
|
||||
disabled={disabled}
|
||||
value={selectedValue as ComboboxOption<TValue>[] & ComboboxOption<TValue>}
|
||||
onValueChange={setSelectedValue as (value: ComboboxOption<TValue>[] | ComboboxOption<TValue> | null) => void}
|
||||
itemToStringLabel={itemToStringLabel}
|
||||
isItemEqualToValue={isItemEqualToValue}
|
||||
{...(multiple ? { multiple: true } : {})}
|
||||
>
|
||||
<ComboboxTrigger
|
||||
id={id}
|
||||
disabled={disabled}
|
||||
render={
|
||||
render ?? (
|
||||
<Button
|
||||
variant="outline"
|
||||
className={cn("justify-start text-left font-normal hover:bg-muted/20", className)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
>
|
||||
<span className="min-w-0 flex-1 truncate text-left">
|
||||
<ComboboxValue placeholder={placeholder ?? t`Select...`} />
|
||||
</span>
|
||||
return (
|
||||
<ComboboxRoot
|
||||
name={name}
|
||||
items={options}
|
||||
filter={filter}
|
||||
disabled={disabled}
|
||||
value={selectedValue as ComboboxOption<TValue>[] & ComboboxOption<TValue>}
|
||||
onValueChange={setSelectedValue as (value: ComboboxOption<TValue>[] | ComboboxOption<TValue> | null) => void}
|
||||
itemToStringLabel={itemToStringLabel}
|
||||
isItemEqualToValue={isItemEqualToValue}
|
||||
{...(multiple ? { multiple: true } : {})}
|
||||
>
|
||||
<ComboboxTrigger
|
||||
id={id}
|
||||
disabled={disabled}
|
||||
render={
|
||||
render ?? (
|
||||
<Button
|
||||
variant="outline"
|
||||
className={cn("justify-start text-left font-normal hover:bg-muted/20", className)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
>
|
||||
<span className="min-w-0 flex-1 truncate text-left">
|
||||
<ComboboxValue placeholder={placeholder ?? t`Select...`} />
|
||||
</span>
|
||||
|
||||
{showClear && <ComboboxClear disabled={disabled} />}
|
||||
</ComboboxTrigger>
|
||||
{showClear && <ComboboxClear disabled={disabled} />}
|
||||
</ComboboxTrigger>
|
||||
|
||||
<ComboboxContent>
|
||||
<ComboboxPrimitive.Input
|
||||
placeholder={searchPlaceholder ?? placeholder ?? t`Search...`}
|
||||
render={<Input disabled={disabled} className="rounded-b-none focus-visible:ring-0" />}
|
||||
/>
|
||||
<ComboboxEmpty>{emptyMessage ?? t`No results found.`}</ComboboxEmpty>
|
||||
<ComboboxList>{listContent}</ComboboxList>
|
||||
</ComboboxContent>
|
||||
</ComboboxRoot>
|
||||
);
|
||||
<ComboboxContent>
|
||||
<ComboboxPrimitive.Input
|
||||
placeholder={searchPlaceholder ?? placeholder ?? t`Search...`}
|
||||
render={<Input disabled={disabled} className="rounded-b-none focus-visible:ring-0" />}
|
||||
/>
|
||||
<ComboboxEmpty>{emptyMessage ?? t`No results found.`}</ComboboxEmpty>
|
||||
<ComboboxList>{listContent}</ComboboxList>
|
||||
</ComboboxContent>
|
||||
</ComboboxRoot>
|
||||
);
|
||||
}
|
||||
|
||||
export {
|
||||
Combobox,
|
||||
ComboboxChip,
|
||||
ComboboxChips,
|
||||
ComboboxChipsInput,
|
||||
ComboboxCollection,
|
||||
ComboboxContent,
|
||||
ComboboxEmpty,
|
||||
ComboboxGroup,
|
||||
ComboboxInput,
|
||||
ComboboxItem,
|
||||
ComboboxLabel,
|
||||
ComboboxList,
|
||||
type ComboboxOption,
|
||||
type ComboboxProps,
|
||||
ComboboxRoot,
|
||||
ComboboxSeparator,
|
||||
ComboboxTrigger,
|
||||
ComboboxValue,
|
||||
type MultiComboboxProps,
|
||||
type SingleComboboxProps,
|
||||
useComboboxAnchor,
|
||||
Combobox,
|
||||
ComboboxChip,
|
||||
ComboboxChips,
|
||||
ComboboxChipsInput,
|
||||
ComboboxCollection,
|
||||
ComboboxContent,
|
||||
ComboboxEmpty,
|
||||
ComboboxGroup,
|
||||
ComboboxInput,
|
||||
ComboboxItem,
|
||||
ComboboxLabel,
|
||||
ComboboxList,
|
||||
type ComboboxOption,
|
||||
type ComboboxProps,
|
||||
ComboboxRoot,
|
||||
ComboboxSeparator,
|
||||
ComboboxTrigger,
|
||||
ComboboxValue,
|
||||
type MultiComboboxProps,
|
||||
type SingleComboboxProps,
|
||||
useComboboxAnchor,
|
||||
};
|
||||
|
||||
+112
-112
@@ -8,145 +8,145 @@ import { InputGroup, InputGroupAddon } from "@/components/ui/input-group";
|
||||
import { cn } from "@/utils/style";
|
||||
|
||||
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}
|
||||
/>
|
||||
);
|
||||
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,
|
||||
showClose = false,
|
||||
...props
|
||||
title = "Command Palette",
|
||||
description = "Search for a command to run...",
|
||||
children,
|
||||
className,
|
||||
showClose = false,
|
||||
...props
|
||||
}: Omit<React.ComponentProps<typeof Dialog>, "children"> & {
|
||||
title?: string;
|
||||
description?: string;
|
||||
className?: string;
|
||||
showClose?: boolean;
|
||||
children: React.ReactNode;
|
||||
title?: string;
|
||||
description?: string;
|
||||
className?: string;
|
||||
showClose?: boolean;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
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)}
|
||||
showClose={showClose}
|
||||
>
|
||||
{children}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
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)}
|
||||
showClose={showClose}
|
||||
>
|
||||
{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>
|
||||
<MagnifyingGlassIcon className="size-4 shrink-0 opacity-50" />
|
||||
</InputGroupAddon>
|
||||
</InputGroup>
|
||||
</div>
|
||||
);
|
||||
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>
|
||||
<MagnifyingGlassIcon 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}
|
||||
/>
|
||||
);
|
||||
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}
|
||||
/>
|
||||
);
|
||||
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}
|
||||
/>
|
||||
);
|
||||
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 w-auto bg-border", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
return (
|
||||
<CommandPrimitive.Separator
|
||||
data-slot="command-separator"
|
||||
className={cn("-mx-1 h-px w-auto 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>
|
||||
);
|
||||
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}
|
||||
/>
|
||||
);
|
||||
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,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList,
|
||||
CommandSeparator,
|
||||
CommandShortcut,
|
||||
Command,
|
||||
CommandDialog,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList,
|
||||
CommandSeparator,
|
||||
CommandShortcut,
|
||||
};
|
||||
|
||||
+169
-169
@@ -6,229 +6,229 @@ import { CaretRightIcon, CheckIcon } from "@phosphor-icons/react";
|
||||
import { cn } from "@/utils/style";
|
||||
|
||||
function ContextMenu({ ...props }: ContextMenuPrimitive.Root.Props) {
|
||||
return <ContextMenuPrimitive.Root data-slot="context-menu" {...props} />;
|
||||
return <ContextMenuPrimitive.Root data-slot="context-menu" {...props} />;
|
||||
}
|
||||
|
||||
function ContextMenuPortal({ ...props }: ContextMenuPrimitive.Portal.Props) {
|
||||
return <ContextMenuPrimitive.Portal data-slot="context-menu-portal" {...props} />;
|
||||
return <ContextMenuPrimitive.Portal data-slot="context-menu-portal" {...props} />;
|
||||
}
|
||||
|
||||
function ContextMenuTrigger({ className, ...props }: ContextMenuPrimitive.Trigger.Props) {
|
||||
return (
|
||||
<ContextMenuPrimitive.Trigger
|
||||
data-slot="context-menu-trigger"
|
||||
className={cn("select-none", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
return (
|
||||
<ContextMenuPrimitive.Trigger
|
||||
data-slot="context-menu-trigger"
|
||||
className={cn("select-none", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function ContextMenuContent({
|
||||
className,
|
||||
align = "start",
|
||||
alignOffset = 4,
|
||||
side = "right",
|
||||
sideOffset = 0,
|
||||
...props
|
||||
className,
|
||||
align = "start",
|
||||
alignOffset = 4,
|
||||
side = "right",
|
||||
sideOffset = 0,
|
||||
...props
|
||||
}: ContextMenuPrimitive.Popup.Props &
|
||||
Pick<ContextMenuPrimitive.Positioner.Props, "align" | "alignOffset" | "side" | "sideOffset">) {
|
||||
return (
|
||||
<ContextMenuPrimitive.Portal>
|
||||
<ContextMenuPrimitive.Positioner
|
||||
className="isolate z-50 outline-none"
|
||||
align={align}
|
||||
alignOffset={alignOffset}
|
||||
side={side}
|
||||
sideOffset={sideOffset}
|
||||
>
|
||||
<ContextMenuPrimitive.Popup
|
||||
data-slot="context-menu-content"
|
||||
className={cn(
|
||||
"z-50 max-h-(--available-height) min-w-36 origin-(--transform-origin) overflow-x-hidden overflow-y-auto rounded-md bg-popover p-1 text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 outline-none 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-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.Positioner>
|
||||
</ContextMenuPrimitive.Portal>
|
||||
);
|
||||
Pick<ContextMenuPrimitive.Positioner.Props, "align" | "alignOffset" | "side" | "sideOffset">) {
|
||||
return (
|
||||
<ContextMenuPrimitive.Portal>
|
||||
<ContextMenuPrimitive.Positioner
|
||||
className="isolate z-50 outline-none"
|
||||
align={align}
|
||||
alignOffset={alignOffset}
|
||||
side={side}
|
||||
sideOffset={sideOffset}
|
||||
>
|
||||
<ContextMenuPrimitive.Popup
|
||||
data-slot="context-menu-content"
|
||||
className={cn(
|
||||
"z-50 max-h-(--available-height) min-w-36 origin-(--transform-origin) overflow-x-hidden overflow-y-auto rounded-md bg-popover p-1 text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 outline-none 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-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.Positioner>
|
||||
</ContextMenuPrimitive.Portal>
|
||||
);
|
||||
}
|
||||
|
||||
function ContextMenuGroup({ ...props }: ContextMenuPrimitive.Group.Props) {
|
||||
return <ContextMenuPrimitive.Group data-slot="context-menu-group" {...props} />;
|
||||
return <ContextMenuPrimitive.Group data-slot="context-menu-group" {...props} />;
|
||||
}
|
||||
|
||||
function ContextMenuLabel({
|
||||
className,
|
||||
inset,
|
||||
...props
|
||||
className,
|
||||
inset,
|
||||
...props
|
||||
}: ContextMenuPrimitive.GroupLabel.Props & {
|
||||
inset?: boolean;
|
||||
inset?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<ContextMenuPrimitive.GroupLabel
|
||||
data-slot="context-menu-label"
|
||||
data-inset={inset}
|
||||
className={cn("px-2 py-1.5 text-xs font-medium text-muted-foreground data-inset:pl-8", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
return (
|
||||
<ContextMenuPrimitive.GroupLabel
|
||||
data-slot="context-menu-label"
|
||||
data-inset={inset}
|
||||
className={cn("px-2 py-1.5 text-xs font-medium text-muted-foreground data-inset:pl-8", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function ContextMenuItem({
|
||||
className,
|
||||
inset,
|
||||
variant = "default",
|
||||
...props
|
||||
className,
|
||||
inset,
|
||||
variant = "default",
|
||||
...props
|
||||
}: ContextMenuPrimitive.Item.Props & {
|
||||
inset?: boolean;
|
||||
variant?: "default" | "destructive";
|
||||
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-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-inset:pl-8 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}
|
||||
/>
|
||||
);
|
||||
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-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-inset:pl-8 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 ContextMenuSub({ ...props }: ContextMenuPrimitive.SubmenuRoot.Props) {
|
||||
return <ContextMenuPrimitive.SubmenuRoot data-slot="context-menu-sub" {...props} />;
|
||||
return <ContextMenuPrimitive.SubmenuRoot data-slot="context-menu-sub" {...props} />;
|
||||
}
|
||||
|
||||
function ContextMenuSubTrigger({
|
||||
className,
|
||||
inset,
|
||||
children,
|
||||
...props
|
||||
className,
|
||||
inset,
|
||||
children,
|
||||
...props
|
||||
}: ContextMenuPrimitive.SubmenuTrigger.Props & {
|
||||
inset?: boolean;
|
||||
inset?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<ContextMenuPrimitive.SubmenuTrigger
|
||||
data-slot="context-menu-sub-trigger"
|
||||
data-inset={inset}
|
||||
className={cn(
|
||||
"flex cursor-default items-center rounded-sm px-2 py-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-inset:pl-8 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}
|
||||
<CaretRightIcon className="ml-auto" />
|
||||
</ContextMenuPrimitive.SubmenuTrigger>
|
||||
);
|
||||
return (
|
||||
<ContextMenuPrimitive.SubmenuTrigger
|
||||
data-slot="context-menu-sub-trigger"
|
||||
data-inset={inset}
|
||||
className={cn(
|
||||
"flex cursor-default items-center rounded-sm px-2 py-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-inset:pl-8 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}
|
||||
<CaretRightIcon className="ml-auto" />
|
||||
</ContextMenuPrimitive.SubmenuTrigger>
|
||||
);
|
||||
}
|
||||
|
||||
function ContextMenuSubContent({ ...props }: React.ComponentProps<typeof ContextMenuContent>) {
|
||||
return <ContextMenuContent data-slot="context-menu-sub-content" className="shadow-lg" side="right" {...props} />;
|
||||
return <ContextMenuContent data-slot="context-menu-sub-content" className="shadow-lg" side="right" {...props} />;
|
||||
}
|
||||
|
||||
function ContextMenuCheckboxItem({
|
||||
className,
|
||||
children,
|
||||
checked,
|
||||
inset,
|
||||
...props
|
||||
className,
|
||||
children,
|
||||
checked,
|
||||
inset,
|
||||
...props
|
||||
}: ContextMenuPrimitive.CheckboxItem.Props & {
|
||||
inset?: boolean;
|
||||
inset?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<ContextMenuPrimitive.CheckboxItem
|
||||
data-slot="context-menu-checkbox-item"
|
||||
data-inset={inset}
|
||||
className={cn(
|
||||
"relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-inset:pl-8 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.CheckboxItemIndicator>
|
||||
<CheckIcon />
|
||||
</ContextMenuPrimitive.CheckboxItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</ContextMenuPrimitive.CheckboxItem>
|
||||
);
|
||||
return (
|
||||
<ContextMenuPrimitive.CheckboxItem
|
||||
data-slot="context-menu-checkbox-item"
|
||||
data-inset={inset}
|
||||
className={cn(
|
||||
"relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-inset:pl-8 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.CheckboxItemIndicator>
|
||||
<CheckIcon />
|
||||
</ContextMenuPrimitive.CheckboxItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</ContextMenuPrimitive.CheckboxItem>
|
||||
);
|
||||
}
|
||||
|
||||
function ContextMenuRadioGroup({ ...props }: ContextMenuPrimitive.RadioGroup.Props) {
|
||||
return <ContextMenuPrimitive.RadioGroup data-slot="context-menu-radio-group" {...props} />;
|
||||
return <ContextMenuPrimitive.RadioGroup data-slot="context-menu-radio-group" {...props} />;
|
||||
}
|
||||
|
||||
function ContextMenuRadioItem({
|
||||
className,
|
||||
children,
|
||||
inset,
|
||||
...props
|
||||
className,
|
||||
children,
|
||||
inset,
|
||||
...props
|
||||
}: ContextMenuPrimitive.RadioItem.Props & {
|
||||
inset?: boolean;
|
||||
inset?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<ContextMenuPrimitive.RadioItem
|
||||
data-slot="context-menu-radio-item"
|
||||
data-inset={inset}
|
||||
className={cn(
|
||||
"relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-inset:pl-8 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.RadioItemIndicator>
|
||||
<CheckIcon />
|
||||
</ContextMenuPrimitive.RadioItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</ContextMenuPrimitive.RadioItem>
|
||||
);
|
||||
return (
|
||||
<ContextMenuPrimitive.RadioItem
|
||||
data-slot="context-menu-radio-item"
|
||||
data-inset={inset}
|
||||
className={cn(
|
||||
"relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-inset:pl-8 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.RadioItemIndicator>
|
||||
<CheckIcon />
|
||||
</ContextMenuPrimitive.RadioItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</ContextMenuPrimitive.RadioItem>
|
||||
);
|
||||
}
|
||||
|
||||
function ContextMenuSeparator({ className, ...props }: ContextMenuPrimitive.Separator.Props) {
|
||||
return (
|
||||
<ContextMenuPrimitive.Separator
|
||||
data-slot="context-menu-separator"
|
||||
className={cn("-mx-1 my-1 h-px bg-border", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
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}
|
||||
/>
|
||||
);
|
||||
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,
|
||||
ContextMenuCheckboxItem,
|
||||
ContextMenuContent,
|
||||
ContextMenuGroup,
|
||||
ContextMenuItem,
|
||||
ContextMenuLabel,
|
||||
ContextMenuPortal,
|
||||
ContextMenuRadioGroup,
|
||||
ContextMenuRadioItem,
|
||||
ContextMenuSeparator,
|
||||
ContextMenuShortcut,
|
||||
ContextMenuSub,
|
||||
ContextMenuSubContent,
|
||||
ContextMenuSubTrigger,
|
||||
ContextMenuTrigger,
|
||||
ContextMenu,
|
||||
ContextMenuCheckboxItem,
|
||||
ContextMenuContent,
|
||||
ContextMenuGroup,
|
||||
ContextMenuItem,
|
||||
ContextMenuLabel,
|
||||
ContextMenuPortal,
|
||||
ContextMenuRadioGroup,
|
||||
ContextMenuRadioItem,
|
||||
ContextMenuSeparator,
|
||||
ContextMenuShortcut,
|
||||
ContextMenuSub,
|
||||
ContextMenuSubContent,
|
||||
ContextMenuSubTrigger,
|
||||
ContextMenuTrigger,
|
||||
};
|
||||
|
||||
@@ -5,38 +5,38 @@ import { cn } from "@/utils/style";
|
||||
type Props = React.ComponentProps<"div">;
|
||||
|
||||
export function Copyright({ className, ...props }: Props) {
|
||||
return (
|
||||
<div className={cn("text-xs leading-relaxed text-muted-foreground/80", className)} {...props}>
|
||||
<p>
|
||||
<Trans>
|
||||
Licensed under{" "}
|
||||
<a href="#" target="_blank" rel="noopener" className="font-medium underline underline-offset-2">
|
||||
MIT
|
||||
</a>
|
||||
.
|
||||
</Trans>
|
||||
</p>
|
||||
return (
|
||||
<div className={cn("text-xs leading-relaxed text-muted-foreground/80", className)} {...props}>
|
||||
<p>
|
||||
<Trans>
|
||||
Licensed under{" "}
|
||||
<a href="#" target="_blank" rel="noopener" className="font-medium underline underline-offset-2">
|
||||
MIT
|
||||
</a>
|
||||
.
|
||||
</Trans>
|
||||
</p>
|
||||
|
||||
<p>
|
||||
<Trans>By the community, for the community.</Trans>
|
||||
</p>
|
||||
<p>
|
||||
<Trans>By the community, for the community.</Trans>
|
||||
</p>
|
||||
|
||||
<p>
|
||||
<Trans>
|
||||
A passion project by{" "}
|
||||
<a
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
href="https://amruthpillai.com"
|
||||
className="font-medium underline underline-offset-2"
|
||||
>
|
||||
Amruth Pillai
|
||||
</a>
|
||||
.
|
||||
</Trans>
|
||||
</p>
|
||||
<p>
|
||||
<Trans>
|
||||
A passion project by{" "}
|
||||
<a
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
href="https://amruthpillai.com"
|
||||
className="font-medium underline underline-offset-2"
|
||||
>
|
||||
Amruth Pillai
|
||||
</a>
|
||||
.
|
||||
</Trans>
|
||||
</p>
|
||||
|
||||
<p className="mt-4">Reactive Resume v{__APP_VERSION__}</p>
|
||||
</div>
|
||||
);
|
||||
<p className="mt-4">Reactive Resume v{__APP_VERSION__}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -8,114 +8,114 @@ import { Button } from "@/components/ui/button";
|
||||
import { cn } from "@/utils/style";
|
||||
|
||||
function Dialog({ ...props }: DialogPrimitive.Root.Props) {
|
||||
return <DialogPrimitive.Root data-slot="dialog" {...props} />;
|
||||
return <DialogPrimitive.Root data-slot="dialog" {...props} />;
|
||||
}
|
||||
|
||||
function DialogTrigger({ ...props }: DialogPrimitive.Trigger.Props) {
|
||||
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />;
|
||||
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />;
|
||||
}
|
||||
|
||||
function DialogPortal({ ...props }: DialogPrimitive.Portal.Props) {
|
||||
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />;
|
||||
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />;
|
||||
}
|
||||
|
||||
function DialogClose({ ...props }: DialogPrimitive.Close.Props) {
|
||||
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />;
|
||||
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />;
|
||||
}
|
||||
|
||||
function DialogOverlay({ className, ...props }: DialogPrimitive.Backdrop.Props) {
|
||||
return (
|
||||
<DialogPrimitive.Backdrop
|
||||
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}
|
||||
/>
|
||||
);
|
||||
return (
|
||||
<DialogPrimitive.Backdrop
|
||||
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,
|
||||
showClose = false,
|
||||
...props
|
||||
className,
|
||||
children,
|
||||
showClose = false,
|
||||
...props
|
||||
}: DialogPrimitive.Popup.Props & {
|
||||
showClose?: boolean;
|
||||
showClose?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<DialogPortal>
|
||||
<DialogOverlay />
|
||||
<DialogPrimitive.Popup
|
||||
data-slot="dialog-content"
|
||||
className={cn(
|
||||
"fixed top-1/2 left-1/2 z-50 grid w-full max-w-[calc(100%-2rem)] -translate-x-1/2 -translate-y-1/2 gap-6 rounded-xl bg-background p-6 text-sm ring-1 ring-foreground/10 duration-100 outline-none sm:max-w-2xl 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}
|
||||
{showClose && (
|
||||
<DialogPrimitive.Close
|
||||
data-slot="dialog-close"
|
||||
render={<Button variant="ghost" className="absolute top-4 right-4" size="icon-sm" />}
|
||||
>
|
||||
<XIcon />
|
||||
<span className="sr-only">
|
||||
<Trans>Close</Trans>
|
||||
</span>
|
||||
</DialogPrimitive.Close>
|
||||
)}
|
||||
</DialogPrimitive.Popup>
|
||||
</DialogPortal>
|
||||
);
|
||||
return (
|
||||
<DialogPortal>
|
||||
<DialogOverlay />
|
||||
<DialogPrimitive.Popup
|
||||
data-slot="dialog-content"
|
||||
className={cn(
|
||||
"fixed top-1/2 left-1/2 z-50 grid w-full max-w-[calc(100%-2rem)] -translate-x-1/2 -translate-y-1/2 gap-6 rounded-xl bg-background p-6 text-sm ring-1 ring-foreground/10 duration-100 outline-none sm:max-w-2xl 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}
|
||||
{showClose && (
|
||||
<DialogPrimitive.Close
|
||||
data-slot="dialog-close"
|
||||
render={<Button variant="ghost" className="absolute top-4 right-4" size="icon-sm" />}
|
||||
>
|
||||
<XIcon />
|
||||
<span className="sr-only">
|
||||
<Trans>Close</Trans>
|
||||
</span>
|
||||
</DialogPrimitive.Close>
|
||||
)}
|
||||
</DialogPrimitive.Popup>
|
||||
</DialogPortal>
|
||||
);
|
||||
}
|
||||
|
||||
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return <div data-slot="dialog-header" className={cn("flex flex-col gap-2", className)} {...props} />;
|
||||
return <div data-slot="dialog-header" className={cn("flex flex-col gap-2", className)} {...props} />;
|
||||
}
|
||||
|
||||
function DialogFooter({ className, children, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="dialog-footer"
|
||||
className={cn("flex flex-col-reverse gap-2 sm:flex-row sm:justify-end", className)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
return (
|
||||
<div
|
||||
data-slot="dialog-footer"
|
||||
className={cn("flex flex-col-reverse gap-2 sm:flex-row sm:justify-end", className)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DialogTitle({ className, ...props }: DialogPrimitive.Title.Props) {
|
||||
return (
|
||||
<DialogPrimitive.Title data-slot="dialog-title" className={cn("leading-none font-medium", className)} {...props} />
|
||||
);
|
||||
return (
|
||||
<DialogPrimitive.Title data-slot="dialog-title" className={cn("leading-none font-medium", className)} {...props} />
|
||||
);
|
||||
}
|
||||
|
||||
function DialogDescription({ className, ...props }: DialogPrimitive.Description.Props) {
|
||||
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}
|
||||
/>
|
||||
);
|
||||
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,
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogOverlay,
|
||||
DialogPortal,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
};
|
||||
|
||||
+187
-187
@@ -6,248 +6,248 @@ import { CaretRightIcon, CheckIcon } from "@phosphor-icons/react";
|
||||
import { cn } from "@/utils/style";
|
||||
|
||||
function DropdownMenu({ ...props }: MenuPrimitive.Root.Props) {
|
||||
return <MenuPrimitive.Root data-slot="dropdown-menu" {...props} />;
|
||||
return <MenuPrimitive.Root data-slot="dropdown-menu" {...props} />;
|
||||
}
|
||||
|
||||
function DropdownMenuPortal({ ...props }: MenuPrimitive.Portal.Props) {
|
||||
return <MenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />;
|
||||
return <MenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />;
|
||||
}
|
||||
|
||||
function DropdownMenuTrigger({ ...props }: MenuPrimitive.Trigger.Props) {
|
||||
return <MenuPrimitive.Trigger data-slot="dropdown-menu-trigger" {...props} />;
|
||||
return <MenuPrimitive.Trigger data-slot="dropdown-menu-trigger" {...props} />;
|
||||
}
|
||||
|
||||
function DropdownMenuContent({
|
||||
align = "start",
|
||||
alignOffset = 0,
|
||||
side = "bottom",
|
||||
sideOffset = 4,
|
||||
className,
|
||||
...props
|
||||
align = "start",
|
||||
alignOffset = 0,
|
||||
side = "bottom",
|
||||
sideOffset = 4,
|
||||
className,
|
||||
...props
|
||||
}: MenuPrimitive.Popup.Props & Pick<MenuPrimitive.Positioner.Props, "align" | "alignOffset" | "side" | "sideOffset">) {
|
||||
return (
|
||||
<MenuPrimitive.Portal>
|
||||
<MenuPrimitive.Positioner
|
||||
className="isolate z-50 outline-none"
|
||||
align={align}
|
||||
alignOffset={alignOffset}
|
||||
side={side}
|
||||
sideOffset={sideOffset}
|
||||
>
|
||||
<MenuPrimitive.Popup
|
||||
data-slot="dropdown-menu-content"
|
||||
className={cn(
|
||||
"z-50 max-h-(--available-height) w-(--anchor-width) min-w-32 origin-(--transform-origin) overflow-x-hidden overflow-y-auto rounded-md bg-popover p-1 text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 outline-none 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-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:overflow-hidden data-closed:fade-out-0 data-closed:zoom-out-95",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</MenuPrimitive.Positioner>
|
||||
</MenuPrimitive.Portal>
|
||||
);
|
||||
return (
|
||||
<MenuPrimitive.Portal>
|
||||
<MenuPrimitive.Positioner
|
||||
className="isolate z-50 outline-none"
|
||||
align={align}
|
||||
alignOffset={alignOffset}
|
||||
side={side}
|
||||
sideOffset={sideOffset}
|
||||
>
|
||||
<MenuPrimitive.Popup
|
||||
data-slot="dropdown-menu-content"
|
||||
className={cn(
|
||||
"z-50 max-h-(--available-height) w-(--anchor-width) min-w-32 origin-(--transform-origin) overflow-x-hidden overflow-y-auto rounded-md bg-popover p-1 text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 outline-none 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-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:overflow-hidden data-closed:fade-out-0 data-closed:zoom-out-95",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</MenuPrimitive.Positioner>
|
||||
</MenuPrimitive.Portal>
|
||||
);
|
||||
}
|
||||
|
||||
function DropdownMenuGroup({ ...props }: MenuPrimitive.Group.Props) {
|
||||
return <MenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />;
|
||||
return <MenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />;
|
||||
}
|
||||
|
||||
function DropdownMenuLabel({
|
||||
className,
|
||||
inset,
|
||||
...props
|
||||
className,
|
||||
inset,
|
||||
...props
|
||||
}: MenuPrimitive.GroupLabel.Props & {
|
||||
inset?: boolean;
|
||||
inset?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<MenuPrimitive.GroupLabel
|
||||
data-slot="dropdown-menu-label"
|
||||
data-inset={inset}
|
||||
className={cn("px-2 py-1.5 text-xs font-medium text-muted-foreground data-inset:pl-8", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
return (
|
||||
<MenuPrimitive.GroupLabel
|
||||
data-slot="dropdown-menu-label"
|
||||
data-inset={inset}
|
||||
className={cn("px-2 py-1.5 text-xs font-medium text-muted-foreground data-inset:pl-8", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function DropdownMenuItem({
|
||||
className,
|
||||
inset,
|
||||
variant = "default",
|
||||
...props
|
||||
className,
|
||||
inset,
|
||||
variant = "default",
|
||||
...props
|
||||
}: MenuPrimitive.Item.Props & {
|
||||
inset?: boolean;
|
||||
variant?: "default" | "destructive";
|
||||
inset?: boolean;
|
||||
variant?: "default" | "destructive";
|
||||
}) {
|
||||
return (
|
||||
<MenuPrimitive.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-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-inset:pl-8 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}
|
||||
/>
|
||||
);
|
||||
return (
|
||||
<MenuPrimitive.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-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-inset:pl-8 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 DropdownMenuSub({ ...props }: MenuPrimitive.SubmenuRoot.Props) {
|
||||
return <MenuPrimitive.SubmenuRoot data-slot="dropdown-menu-sub" {...props} />;
|
||||
return <MenuPrimitive.SubmenuRoot data-slot="dropdown-menu-sub" {...props} />;
|
||||
}
|
||||
|
||||
function DropdownMenuSubTrigger({
|
||||
className,
|
||||
inset,
|
||||
children,
|
||||
...props
|
||||
className,
|
||||
inset,
|
||||
children,
|
||||
...props
|
||||
}: MenuPrimitive.SubmenuTrigger.Props & {
|
||||
inset?: boolean;
|
||||
inset?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<MenuPrimitive.SubmenuTrigger
|
||||
data-slot="dropdown-menu-sub-trigger"
|
||||
data-inset={inset}
|
||||
className={cn(
|
||||
"flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-inset:pl-8 data-popup-open:bg-accent data-popup-open:text-accent-foreground 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}
|
||||
<CaretRightIcon className="ml-auto" />
|
||||
</MenuPrimitive.SubmenuTrigger>
|
||||
);
|
||||
return (
|
||||
<MenuPrimitive.SubmenuTrigger
|
||||
data-slot="dropdown-menu-sub-trigger"
|
||||
data-inset={inset}
|
||||
className={cn(
|
||||
"flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-inset:pl-8 data-popup-open:bg-accent data-popup-open:text-accent-foreground 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}
|
||||
<CaretRightIcon className="ml-auto" />
|
||||
</MenuPrimitive.SubmenuTrigger>
|
||||
);
|
||||
}
|
||||
|
||||
function DropdownMenuSubContent({
|
||||
align = "start",
|
||||
alignOffset = -3,
|
||||
side = "right",
|
||||
sideOffset = 0,
|
||||
className,
|
||||
...props
|
||||
align = "start",
|
||||
alignOffset = -3,
|
||||
side = "right",
|
||||
sideOffset = 0,
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuContent>) {
|
||||
return (
|
||||
<DropdownMenuContent
|
||||
data-slot="dropdown-menu-sub-content"
|
||||
className={cn(
|
||||
"w-auto min-w-[96px] rounded-md 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,
|
||||
)}
|
||||
align={align}
|
||||
alignOffset={alignOffset}
|
||||
side={side}
|
||||
sideOffset={sideOffset}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
return (
|
||||
<DropdownMenuContent
|
||||
data-slot="dropdown-menu-sub-content"
|
||||
className={cn(
|
||||
"w-auto min-w-[96px] rounded-md 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,
|
||||
)}
|
||||
align={align}
|
||||
alignOffset={alignOffset}
|
||||
side={side}
|
||||
sideOffset={sideOffset}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function DropdownMenuCheckboxItem({
|
||||
className,
|
||||
children,
|
||||
checked,
|
||||
inset,
|
||||
...props
|
||||
className,
|
||||
children,
|
||||
checked,
|
||||
inset,
|
||||
...props
|
||||
}: MenuPrimitive.CheckboxItem.Props & {
|
||||
inset?: boolean;
|
||||
inset?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<MenuPrimitive.CheckboxItem
|
||||
data-slot="dropdown-menu-checkbox-item"
|
||||
data-inset={inset}
|
||||
className={cn(
|
||||
"relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground focus:**:text-accent-foreground data-inset:pl-8 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"
|
||||
>
|
||||
<MenuPrimitive.CheckboxItemIndicator>
|
||||
<CheckIcon />
|
||||
</MenuPrimitive.CheckboxItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</MenuPrimitive.CheckboxItem>
|
||||
);
|
||||
return (
|
||||
<MenuPrimitive.CheckboxItem
|
||||
data-slot="dropdown-menu-checkbox-item"
|
||||
data-inset={inset}
|
||||
className={cn(
|
||||
"relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground focus:**:text-accent-foreground data-inset:pl-8 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"
|
||||
>
|
||||
<MenuPrimitive.CheckboxItemIndicator>
|
||||
<CheckIcon />
|
||||
</MenuPrimitive.CheckboxItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</MenuPrimitive.CheckboxItem>
|
||||
);
|
||||
}
|
||||
|
||||
function DropdownMenuRadioGroup({ ...props }: MenuPrimitive.RadioGroup.Props) {
|
||||
return <MenuPrimitive.RadioGroup data-slot="dropdown-menu-radio-group" {...props} />;
|
||||
return <MenuPrimitive.RadioGroup data-slot="dropdown-menu-radio-group" {...props} />;
|
||||
}
|
||||
|
||||
function DropdownMenuRadioItem({
|
||||
className,
|
||||
children,
|
||||
inset,
|
||||
...props
|
||||
className,
|
||||
children,
|
||||
inset,
|
||||
...props
|
||||
}: MenuPrimitive.RadioItem.Props & {
|
||||
inset?: boolean;
|
||||
inset?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<MenuPrimitive.RadioItem
|
||||
data-slot="dropdown-menu-radio-item"
|
||||
data-inset={inset}
|
||||
className={cn(
|
||||
"relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground focus:**:text-accent-foreground data-inset:pl-8 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"
|
||||
>
|
||||
<MenuPrimitive.RadioItemIndicator>
|
||||
<CheckIcon />
|
||||
</MenuPrimitive.RadioItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</MenuPrimitive.RadioItem>
|
||||
);
|
||||
return (
|
||||
<MenuPrimitive.RadioItem
|
||||
data-slot="dropdown-menu-radio-item"
|
||||
data-inset={inset}
|
||||
className={cn(
|
||||
"relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground focus:**:text-accent-foreground data-inset:pl-8 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"
|
||||
>
|
||||
<MenuPrimitive.RadioItemIndicator>
|
||||
<CheckIcon />
|
||||
</MenuPrimitive.RadioItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</MenuPrimitive.RadioItem>
|
||||
);
|
||||
}
|
||||
|
||||
function DropdownMenuSeparator({ className, ...props }: MenuPrimitive.Separator.Props) {
|
||||
return (
|
||||
<MenuPrimitive.Separator
|
||||
data-slot="dropdown-menu-separator"
|
||||
className={cn("-mx-1 my-1 h-px bg-border", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
return (
|
||||
<MenuPrimitive.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}
|
||||
/>
|
||||
);
|
||||
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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export {
|
||||
DropdownMenu,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuPortal,
|
||||
DropdownMenuRadioGroup,
|
||||
DropdownMenuRadioItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuShortcut,
|
||||
DropdownMenuSub,
|
||||
DropdownMenuSubContent,
|
||||
DropdownMenuSubTrigger,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenu,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuPortal,
|
||||
DropdownMenuRadioGroup,
|
||||
DropdownMenuRadioItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuShortcut,
|
||||
DropdownMenuSub,
|
||||
DropdownMenuSubContent,
|
||||
DropdownMenuSubTrigger,
|
||||
DropdownMenuTrigger,
|
||||
};
|
||||
|
||||
+99
-99
@@ -2,14 +2,14 @@ import { useRender } from "@base-ui/react";
|
||||
import { isPlainObject } from "es-toolkit";
|
||||
import * as React from "react";
|
||||
import {
|
||||
Controller,
|
||||
type ControllerProps,
|
||||
type FieldError,
|
||||
type FieldPath,
|
||||
type FieldValues,
|
||||
FormProvider,
|
||||
useFormContext,
|
||||
useFormState,
|
||||
Controller,
|
||||
type ControllerProps,
|
||||
type FieldError,
|
||||
type FieldPath,
|
||||
type FieldValues,
|
||||
FormProvider,
|
||||
useFormContext,
|
||||
useFormState,
|
||||
} from "react-hook-form";
|
||||
|
||||
import { Label } from "@/components/ui/label";
|
||||
@@ -18,141 +18,141 @@ import { cn } from "@/utils/style";
|
||||
const Form = FormProvider;
|
||||
|
||||
type FormFieldContextValue<
|
||||
TFieldValues extends FieldValues = FieldValues,
|
||||
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
|
||||
TFieldValues extends FieldValues = FieldValues,
|
||||
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
|
||||
> = { name: TName };
|
||||
|
||||
const FormFieldContext = React.createContext<FormFieldContextValue>({} as FormFieldContextValue);
|
||||
|
||||
const FormField = <
|
||||
TFieldValues extends FieldValues = FieldValues,
|
||||
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
|
||||
TFieldValues extends FieldValues = FieldValues,
|
||||
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
|
||||
>({
|
||||
...props
|
||||
...props
|
||||
}: ControllerProps<TFieldValues, TName>) => {
|
||||
return (
|
||||
<FormFieldContext.Provider value={{ name: props.name }}>
|
||||
<Controller {...props} />
|
||||
</FormFieldContext.Provider>
|
||||
);
|
||||
return (
|
||||
<FormFieldContext.Provider value={{ name: props.name }}>
|
||||
<Controller {...props} />
|
||||
</FormFieldContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
const useFormField = () => {
|
||||
const fieldContext = React.useContext(FormFieldContext);
|
||||
const itemContext = React.useContext(FormItemContext);
|
||||
const { getFieldState } = useFormContext();
|
||||
const formState = useFormState({ name: fieldContext.name });
|
||||
const fieldState = getFieldState(fieldContext.name, formState);
|
||||
const fieldContext = React.useContext(FormFieldContext);
|
||||
const itemContext = React.useContext(FormItemContext);
|
||||
const { getFieldState } = useFormContext();
|
||||
const formState = useFormState({ name: fieldContext.name });
|
||||
const fieldState = getFieldState(fieldContext.name, formState);
|
||||
|
||||
if (!fieldContext) {
|
||||
throw new Error("useFormField should be used within <FormField>");
|
||||
}
|
||||
if (!fieldContext) {
|
||||
throw new Error("useFormField should be used within <FormField>");
|
||||
}
|
||||
|
||||
const { id } = itemContext;
|
||||
const { id } = itemContext;
|
||||
|
||||
return {
|
||||
id,
|
||||
name: fieldContext.name,
|
||||
formItemId: `${id}-form-item`,
|
||||
formDescriptionId: `${id}-form-item-description`,
|
||||
formMessageId: `${id}-form-item-message`,
|
||||
...fieldState,
|
||||
};
|
||||
return {
|
||||
id,
|
||||
name: fieldContext.name,
|
||||
formItemId: `${id}-form-item`,
|
||||
formDescriptionId: `${id}-form-item-description`,
|
||||
formMessageId: `${id}-form-item-message`,
|
||||
...fieldState,
|
||||
};
|
||||
};
|
||||
|
||||
type FormItemContextValue = {
|
||||
id: string;
|
||||
id: string;
|
||||
};
|
||||
|
||||
const FormItemContext = React.createContext<FormItemContextValue>({} as FormItemContextValue);
|
||||
|
||||
function FormItem({ className, ...props }: React.ComponentProps<"div">) {
|
||||
const id = React.useId();
|
||||
const id = React.useId();
|
||||
|
||||
return (
|
||||
<FormItemContext.Provider value={{ id }}>
|
||||
<div data-slot="form-item" className={cn("grid gap-1.5", className)} {...props} />
|
||||
</FormItemContext.Provider>
|
||||
);
|
||||
return (
|
||||
<FormItemContext.Provider value={{ id }}>
|
||||
<div data-slot="form-item" className={cn("grid gap-1.5", className)} {...props} />
|
||||
</FormItemContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
function FormLabel({ className, ...props }: React.ComponentProps<typeof Label>) {
|
||||
const { error, formItemId } = useFormField();
|
||||
const { error, formItemId } = useFormField();
|
||||
|
||||
return (
|
||||
<Label
|
||||
data-slot="form-label"
|
||||
data-error={!!error}
|
||||
className={cn("mb-0.5 data-[error=true]:text-destructive", className)}
|
||||
htmlFor={formItemId}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
return (
|
||||
<Label
|
||||
data-slot="form-label"
|
||||
data-error={!!error}
|
||||
className={cn("mb-0.5 data-[error=true]:text-destructive", className)}
|
||||
htmlFor={formItemId}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function FormControl(props: useRender.ComponentProps<"div">) {
|
||||
const { error, formItemId, formDescriptionId, formMessageId } = useFormField();
|
||||
const { error, formItemId, formDescriptionId, formMessageId } = useFormField();
|
||||
|
||||
return useRender({
|
||||
...props,
|
||||
defaultTagName: "div",
|
||||
state: { slot: "form-control" },
|
||||
props: {
|
||||
id: formItemId,
|
||||
"data-slot": "form-control",
|
||||
"aria-describedby": !error ? `${formDescriptionId}` : `${formDescriptionId} ${formMessageId}`,
|
||||
"aria-invalid": !!error,
|
||||
...props,
|
||||
},
|
||||
});
|
||||
return useRender({
|
||||
...props,
|
||||
defaultTagName: "div",
|
||||
state: { slot: "form-control" },
|
||||
props: {
|
||||
id: formItemId,
|
||||
"data-slot": "form-control",
|
||||
"aria-describedby": !error ? `${formDescriptionId}` : `${formDescriptionId} ${formMessageId}`,
|
||||
"aria-invalid": !!error,
|
||||
...props,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function FormDescription({ className, ...props }: React.ComponentProps<"p">) {
|
||||
const { formDescriptionId } = useFormField();
|
||||
const { formDescriptionId } = useFormField();
|
||||
|
||||
return (
|
||||
<p
|
||||
data-slot="form-description"
|
||||
id={formDescriptionId}
|
||||
className={cn("text-xs leading-normal text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
return (
|
||||
<p
|
||||
data-slot="form-description"
|
||||
id={formDescriptionId}
|
||||
className={cn("text-xs leading-normal text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function FormMessage({ className, ...props }: React.ComponentProps<"p">) {
|
||||
const { error, formMessageId } = useFormField();
|
||||
const { error, formMessageId } = useFormField();
|
||||
|
||||
function extractMessage(obj: FieldError | undefined): string | undefined {
|
||||
if (!obj || typeof obj !== "object") return undefined;
|
||||
function extractMessage(obj: FieldError | undefined): string | undefined {
|
||||
if (!obj || typeof obj !== "object") return undefined;
|
||||
|
||||
if (isPlainObject(obj) && "message" in obj && typeof obj.message === "string") {
|
||||
return obj.message;
|
||||
}
|
||||
if (isPlainObject(obj) && "message" in obj && typeof obj.message === "string") {
|
||||
return obj.message;
|
||||
}
|
||||
|
||||
for (const value of Object.values(obj)) {
|
||||
const found = extractMessage(value as FieldError);
|
||||
if (found) return found;
|
||||
}
|
||||
for (const value of Object.values(obj)) {
|
||||
const found = extractMessage(value as FieldError);
|
||||
if (found) return found;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const body = extractMessage(error);
|
||||
const body = extractMessage(error);
|
||||
|
||||
if (!body) return null;
|
||||
if (!body) return null;
|
||||
|
||||
return (
|
||||
<p
|
||||
id={formMessageId}
|
||||
data-error={!!error}
|
||||
data-slot="form-message"
|
||||
className={cn("line-clamp-1 text-xs", error ? "text-destructive" : "text-muted-foreground", className)}
|
||||
{...props}
|
||||
>
|
||||
{body}
|
||||
</p>
|
||||
);
|
||||
return (
|
||||
<p
|
||||
id={formMessageId}
|
||||
data-error={!!error}
|
||||
data-slot="form-message"
|
||||
className={cn("line-clamp-1 text-xs", error ? "text-destructive" : "text-muted-foreground", className)}
|
||||
{...props}
|
||||
>
|
||||
{body}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
export { Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage };
|
||||
|
||||
@@ -3,42 +3,42 @@ import { PreviewCard as PreviewCardPrimitive } from "@base-ui/react/preview-card
|
||||
import { cn } from "@/utils/style";
|
||||
|
||||
function HoverCard({ ...props }: PreviewCardPrimitive.Root.Props) {
|
||||
return <PreviewCardPrimitive.Root data-slot="hover-card" {...props} />;
|
||||
return <PreviewCardPrimitive.Root data-slot="hover-card" {...props} />;
|
||||
}
|
||||
|
||||
function HoverCardTrigger({ ...props }: PreviewCardPrimitive.Trigger.Props) {
|
||||
return <PreviewCardPrimitive.Trigger data-slot="hover-card-trigger" {...props} />;
|
||||
return <PreviewCardPrimitive.Trigger data-slot="hover-card-trigger" {...props} />;
|
||||
}
|
||||
|
||||
function HoverCardContent({
|
||||
className,
|
||||
side = "bottom",
|
||||
sideOffset = 4,
|
||||
align = "center",
|
||||
alignOffset = 4,
|
||||
...props
|
||||
className,
|
||||
side = "bottom",
|
||||
sideOffset = 4,
|
||||
align = "center",
|
||||
alignOffset = 4,
|
||||
...props
|
||||
}: PreviewCardPrimitive.Popup.Props &
|
||||
Pick<PreviewCardPrimitive.Positioner.Props, "align" | "alignOffset" | "side" | "sideOffset">) {
|
||||
return (
|
||||
<PreviewCardPrimitive.Portal data-slot="hover-card-portal">
|
||||
<PreviewCardPrimitive.Positioner
|
||||
align={align}
|
||||
alignOffset={alignOffset}
|
||||
side={side}
|
||||
sideOffset={sideOffset}
|
||||
className="isolate z-50"
|
||||
>
|
||||
<PreviewCardPrimitive.Popup
|
||||
data-slot="hover-card-content"
|
||||
className={cn(
|
||||
"z-50 w-64 origin-(--transform-origin) rounded-lg bg-popover p-4 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=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-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}
|
||||
/>
|
||||
</PreviewCardPrimitive.Positioner>
|
||||
</PreviewCardPrimitive.Portal>
|
||||
);
|
||||
Pick<PreviewCardPrimitive.Positioner.Props, "align" | "alignOffset" | "side" | "sideOffset">) {
|
||||
return (
|
||||
<PreviewCardPrimitive.Portal data-slot="hover-card-portal">
|
||||
<PreviewCardPrimitive.Positioner
|
||||
align={align}
|
||||
alignOffset={alignOffset}
|
||||
side={side}
|
||||
sideOffset={sideOffset}
|
||||
className="isolate z-50"
|
||||
>
|
||||
<PreviewCardPrimitive.Popup
|
||||
data-slot="hover-card-content"
|
||||
className={cn(
|
||||
"z-50 w-64 origin-(--transform-origin) rounded-lg bg-popover p-4 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=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-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}
|
||||
/>
|
||||
</PreviewCardPrimitive.Positioner>
|
||||
</PreviewCardPrimitive.Portal>
|
||||
);
|
||||
}
|
||||
|
||||
export { HoverCard, HoverCardContent, HoverCardTrigger };
|
||||
|
||||
+101
-101
@@ -8,132 +8,132 @@ import { Textarea } from "@/components/ui/textarea";
|
||||
import { cn } from "@/utils/style";
|
||||
|
||||
function InputGroup({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="input-group"
|
||||
role="group"
|
||||
className={cn(
|
||||
"group/input-group relative flex h-9 w-full min-w-0 items-center rounded-md border border-input shadow-xs transition-[color,box-shadow] outline-none in-data-[slot=combobox-content]:focus-within:border-inherit in-data-[slot=combobox-content]:focus-within:ring-0 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: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}
|
||||
/>
|
||||
);
|
||||
return (
|
||||
<div
|
||||
data-slot="input-group"
|
||||
role="group"
|
||||
className={cn(
|
||||
"group/input-group relative flex h-9 w-full min-w-0 items-center rounded-md border border-input shadow-xs transition-[color,box-shadow] outline-none in-data-[slot=combobox-content]:focus-within:border-inherit in-data-[slot=combobox-content]:focus-within:ring-0 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: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-1 has-[>kbd]:ml-[-0.15rem]",
|
||||
"inline-end": "order-last pr-2 has-[>button]:-mr-1 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",
|
||||
},
|
||||
},
|
||||
"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-1 has-[>kbd]:ml-[-0.15rem]",
|
||||
"inline-end": "order-last pr-2 has-[>button]:-mr-1 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
|
||||
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}
|
||||
/>
|
||||
);
|
||||
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)-5px)] px-1.5 [&>svg:not([class*='size-'])]:size-3.5",
|
||||
sm: "",
|
||||
"icon-xs": "size-6 rounded-[calc(var(--radius)-5px)] p-0 has-[>svg]:p-0",
|
||||
"icon-sm": "size-8 p-0 has-[>svg]:p-0",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
size: "xs",
|
||||
},
|
||||
variants: {
|
||||
size: {
|
||||
xs: "h-6 gap-1 rounded-[calc(var(--radius)-5px)] px-1.5 [&>svg:not([class*='size-'])]:size-3.5",
|
||||
sm: "",
|
||||
"icon-xs": "size-6 rounded-[calc(var(--radius)-5px)] p-0 has-[>svg]:p-0",
|
||||
"icon-sm": "size-8 p-0 has-[>svg]:p-0",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
size: "xs",
|
||||
},
|
||||
});
|
||||
|
||||
type InputGroupButtonProps = Omit<React.ComponentProps<typeof Button>, "size" | "type"> &
|
||||
VariantProps<typeof inputGroupButtonVariants> & {
|
||||
type?: "button" | "submit" | "reset";
|
||||
};
|
||||
VariantProps<typeof inputGroupButtonVariants> & {
|
||||
type?: "button" | "submit" | "reset";
|
||||
};
|
||||
|
||||
function InputGroupButton({
|
||||
className,
|
||||
type = "button",
|
||||
variant = "ghost",
|
||||
size = "xs",
|
||||
...props
|
||||
className,
|
||||
type = "button",
|
||||
variant = "ghost",
|
||||
size = "xs",
|
||||
...props
|
||||
}: InputGroupButtonProps) {
|
||||
return (
|
||||
<Button
|
||||
type={type}
|
||||
data-size={size}
|
||||
variant={variant}
|
||||
className={cn(inputGroupButtonVariants({ size }), className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
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}
|
||||
/>
|
||||
);
|
||||
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 aria-invalid:ring-0 dark:bg-transparent",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
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 aria-invalid:ring-0 dark: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 aria-invalid:ring-0 dark:bg-transparent",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
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 aria-invalid:ring-0 dark:bg-transparent",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export { InputGroup, InputGroupAddon, InputGroupButton, InputGroupInput, InputGroupText, InputGroupTextarea };
|
||||
|
||||
@@ -5,77 +5,77 @@ import * as React from "react";
|
||||
import { cn } from "@/utils/style";
|
||||
|
||||
function InputOTP({
|
||||
className,
|
||||
containerClassName,
|
||||
...props
|
||||
className,
|
||||
containerClassName,
|
||||
...props
|
||||
}: React.ComponentProps<typeof OTPInput> & {
|
||||
containerClassName?: string;
|
||||
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}
|
||||
/>
|
||||
);
|
||||
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-md has-aria-invalid:border-destructive has-aria-invalid:ring-[3px] has-aria-invalid:ring-destructive/20 dark:has-aria-invalid:ring-destructive/40",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
return (
|
||||
<div
|
||||
data-slot="input-otp-group"
|
||||
className={cn(
|
||||
"flex items-center rounded-md has-aria-invalid:border-destructive has-aria-invalid:ring-[3px] has-aria-invalid:ring-destructive/20 dark:has-aria-invalid:ring-destructive/40",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function InputOTPSlot({
|
||||
index,
|
||||
className,
|
||||
...props
|
||||
index,
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & {
|
||||
index: number;
|
||||
index: number;
|
||||
}) {
|
||||
const inputOTPContext = React.useContext(OTPInputContext);
|
||||
const { char, hasFakeCaret, isActive } = inputOTPContext?.slots[index] ?? {};
|
||||
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-9 items-center justify-center border-y border-r border-input text-sm shadow-xs transition-all outline-none first:rounded-l-md first:border-l last:rounded-r-md aria-invalid:border-destructive data-[active=true]:z-10 data-[active=true]:border-ring data-[active=true]:ring-[3px] data-[active=true]:ring-ring/50 data-[active=true]:aria-invalid:border-destructive data-[active=true]:aria-invalid:ring-destructive/20 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="size-4 animate-caret-blink bg-foreground duration-1000" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
return (
|
||||
<div
|
||||
data-slot="input-otp-slot"
|
||||
data-active={isActive}
|
||||
className={cn(
|
||||
"relative flex size-9 items-center justify-center border-y border-r border-input text-sm shadow-xs transition-all outline-none first:rounded-l-md first:border-l last:rounded-r-md aria-invalid:border-destructive data-[active=true]:z-10 data-[active=true]:border-ring data-[active=true]:ring-[3px] data-[active=true]:ring-ring/50 data-[active=true]:aria-invalid:border-destructive data-[active=true]:aria-invalid:ring-destructive/20 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="size-4 animate-caret-blink bg-foreground duration-1000" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function InputOTPSeparator({ ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="input-otp-separator"
|
||||
className="mx-3 flex items-center [&_svg:not([class*='size-'])]:size-4"
|
||||
role="separator"
|
||||
{...props}
|
||||
>
|
||||
<MinusIcon />
|
||||
</div>
|
||||
);
|
||||
return (
|
||||
<div
|
||||
data-slot="input-otp-separator"
|
||||
className="mx-3 flex items-center [&_svg:not([class*='size-'])]:size-4"
|
||||
role="separator"
|
||||
{...props}
|
||||
>
|
||||
<MinusIcon />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export { InputOTP, InputOTPGroup, InputOTPSeparator, InputOTPSlot };
|
||||
|
||||
+11
-11
@@ -5,17 +5,17 @@ import { Input as InputPrimitive } from "@base-ui/react/input";
|
||||
import { cn } from "@/utils/style";
|
||||
|
||||
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
|
||||
return (
|
||||
<InputPrimitive
|
||||
type={type}
|
||||
data-slot="input"
|
||||
className={cn(
|
||||
"h-9 w-full min-w-0 rounded-md border border-input bg-transparent px-2.5 py-1 text-sm shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 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: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",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
return (
|
||||
<InputPrimitive
|
||||
type={type}
|
||||
data-slot="input"
|
||||
className={cn(
|
||||
"h-9 w-full min-w-0 rounded-md border border-input bg-transparent px-2.5 py-1 text-sm shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 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: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",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export { Input };
|
||||
|
||||
+11
-11
@@ -1,20 +1,20 @@
|
||||
import { cn } from "@/utils/style";
|
||||
|
||||
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}
|
||||
/>
|
||||
);
|
||||
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} />;
|
||||
return <kbd data-slot="kbd-group" className={cn("inline-flex items-center gap-1", className)} {...props} />;
|
||||
}
|
||||
|
||||
export { Kbd, KbdGroup };
|
||||
|
||||
+10
-10
@@ -3,16 +3,16 @@ import type * as React from "react";
|
||||
import { cn } from "@/utils/style";
|
||||
|
||||
function Label({ className, ...props }: React.ComponentProps<"label">) {
|
||||
return (
|
||||
<label
|
||||
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}
|
||||
/>
|
||||
);
|
||||
return (
|
||||
<label
|
||||
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 };
|
||||
|
||||
@@ -5,60 +5,60 @@ import { Popover as PopoverPrimitive } from "@base-ui/react/popover";
|
||||
import { cn } from "@/utils/style";
|
||||
|
||||
function Popover({ ...props }: PopoverPrimitive.Root.Props) {
|
||||
return <PopoverPrimitive.Root data-slot="popover" {...props} />;
|
||||
return <PopoverPrimitive.Root data-slot="popover" {...props} />;
|
||||
}
|
||||
|
||||
function PopoverTrigger({ ...props }: PopoverPrimitive.Trigger.Props) {
|
||||
return <PopoverPrimitive.Trigger data-slot="popover-trigger" {...props} />;
|
||||
return <PopoverPrimitive.Trigger data-slot="popover-trigger" {...props} />;
|
||||
}
|
||||
|
||||
function PopoverContent({
|
||||
className,
|
||||
align = "center",
|
||||
alignOffset = 0,
|
||||
side = "bottom",
|
||||
sideOffset = 4,
|
||||
...props
|
||||
className,
|
||||
align = "center",
|
||||
alignOffset = 0,
|
||||
side = "bottom",
|
||||
sideOffset = 4,
|
||||
...props
|
||||
}: PopoverPrimitive.Popup.Props &
|
||||
Pick<PopoverPrimitive.Positioner.Props, "align" | "alignOffset" | "side" | "sideOffset">) {
|
||||
return (
|
||||
<PopoverPrimitive.Portal>
|
||||
<PopoverPrimitive.Positioner
|
||||
align={align}
|
||||
alignOffset={alignOffset}
|
||||
side={side}
|
||||
sideOffset={sideOffset}
|
||||
className="isolate z-50"
|
||||
>
|
||||
<PopoverPrimitive.Popup
|
||||
data-slot="popover-content"
|
||||
className={cn(
|
||||
"z-50 flex w-72 origin-(--transform-origin) flex-col gap-4 rounded-md bg-popover p-4 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=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-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.Positioner>
|
||||
</PopoverPrimitive.Portal>
|
||||
);
|
||||
Pick<PopoverPrimitive.Positioner.Props, "align" | "alignOffset" | "side" | "sideOffset">) {
|
||||
return (
|
||||
<PopoverPrimitive.Portal>
|
||||
<PopoverPrimitive.Positioner
|
||||
align={align}
|
||||
alignOffset={alignOffset}
|
||||
side={side}
|
||||
sideOffset={sideOffset}
|
||||
className="isolate z-50"
|
||||
>
|
||||
<PopoverPrimitive.Popup
|
||||
data-slot="popover-content"
|
||||
className={cn(
|
||||
"z-50 flex w-72 origin-(--transform-origin) flex-col gap-4 rounded-md bg-popover p-4 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=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-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.Positioner>
|
||||
</PopoverPrimitive.Portal>
|
||||
);
|
||||
}
|
||||
|
||||
function PopoverHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return <div data-slot="popover-header" className={cn("flex flex-col gap-1 text-sm", className)} {...props} />;
|
||||
return <div data-slot="popover-header" className={cn("flex flex-col gap-1 text-sm", className)} {...props} />;
|
||||
}
|
||||
|
||||
function PopoverTitle({ className, ...props }: PopoverPrimitive.Title.Props) {
|
||||
return <PopoverPrimitive.Title data-slot="popover-title" className={cn("font-medium", className)} {...props} />;
|
||||
return <PopoverPrimitive.Title data-slot="popover-title" className={cn("font-medium", className)} {...props} />;
|
||||
}
|
||||
|
||||
function PopoverDescription({ className, ...props }: PopoverPrimitive.Description.Props) {
|
||||
return (
|
||||
<PopoverPrimitive.Description
|
||||
data-slot="popover-description"
|
||||
className={cn("text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
return (
|
||||
<PopoverPrimitive.Description
|
||||
data-slot="popover-description"
|
||||
className={cn("text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export { Popover, PopoverContent, PopoverDescription, PopoverHeader, PopoverTitle, PopoverTrigger };
|
||||
|
||||
@@ -4,42 +4,42 @@ import * as ResizablePrimitive from "react-resizable-panels";
|
||||
import { cn } from "@/utils/style";
|
||||
|
||||
function ResizableGroup({ className, ...props }: React.ComponentProps<typeof ResizablePrimitive.Group>) {
|
||||
return (
|
||||
<ResizablePrimitive.Group
|
||||
data-slot="resizable-panel-group"
|
||||
className={cn("flex h-full w-full data-[panel-group-direction=vertical]:flex-col", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
return (
|
||||
<ResizablePrimitive.Group
|
||||
data-slot="resizable-panel-group"
|
||||
className={cn("flex h-full w-full data-[panel-group-direction=vertical]:flex-col", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function ResizablePanel({ className, ...props }: React.ComponentProps<typeof ResizablePrimitive.Panel>) {
|
||||
return <ResizablePrimitive.Panel data-slot="resizable-panel" className={cn("relative", className)} {...props} />;
|
||||
return <ResizablePrimitive.Panel data-slot="resizable-panel" className={cn("relative", className)} {...props} />;
|
||||
}
|
||||
|
||||
function ResizableSeparator({
|
||||
withHandle,
|
||||
className,
|
||||
...props
|
||||
withHandle,
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof ResizablePrimitive.Separator> & {
|
||||
withHandle?: boolean;
|
||||
withHandle?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<ResizablePrimitive.Separator
|
||||
data-slot="resizable-handle"
|
||||
className={cn(
|
||||
"relative flex w-px items-center justify-center bg-transparent transition-colors after:absolute after:inset-y-0 after:inset-s-1/2 after:w-1 after:-translate-x-1/2 focus-visible:outline-hidden data-[panel-group-direction=vertical]:h-px data-[panel-group-direction=vertical]:w-full data-[panel-group-direction=vertical]:after:inset-s-0 data-[panel-group-direction=vertical]:after:h-1 data-[panel-group-direction=vertical]:after:w-full data-[panel-group-direction=vertical]:after:translate-x-0 data-[panel-group-direction=vertical]:after:-translate-y-1/2 data-[separator='active']:bg-sky-700 data-[separator='hover']:bg-sky-700 [&[data-panel-group-direction=vertical]>div]:rotate-90",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{withHandle && (
|
||||
<div className="absolute z-10 flex h-4 w-3 items-center justify-center rounded-xs border bg-border">
|
||||
<DotsSixVerticalIcon className="size-2.5" />
|
||||
</div>
|
||||
)}
|
||||
</ResizablePrimitive.Separator>
|
||||
);
|
||||
return (
|
||||
<ResizablePrimitive.Separator
|
||||
data-slot="resizable-handle"
|
||||
className={cn(
|
||||
"relative flex w-px items-center justify-center bg-transparent transition-colors after:absolute after:inset-y-0 after:inset-s-1/2 after:w-1 after:-translate-x-1/2 focus-visible:outline-hidden data-[panel-group-direction=vertical]:h-px data-[panel-group-direction=vertical]:w-full data-[panel-group-direction=vertical]:after:inset-s-0 data-[panel-group-direction=vertical]:after:h-1 data-[panel-group-direction=vertical]:after:w-full data-[panel-group-direction=vertical]:after:translate-x-0 data-[panel-group-direction=vertical]:after:-translate-y-1/2 data-[separator='active']:bg-sky-700 data-[separator='hover']:bg-sky-700 [&[data-panel-group-direction=vertical]>div]:rotate-90",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{withHandle && (
|
||||
<div className="absolute z-10 flex h-4 w-3 items-center justify-center rounded-xs border bg-border">
|
||||
<DotsSixVerticalIcon className="size-2.5" />
|
||||
</div>
|
||||
)}
|
||||
</ResizablePrimitive.Separator>
|
||||
);
|
||||
}
|
||||
|
||||
export { ResizableGroup, ResizablePanel, ResizableSeparator };
|
||||
|
||||
@@ -3,35 +3,35 @@ import { ScrollArea as ScrollAreaPrimitive } from "@base-ui/react/scroll-area";
|
||||
import { cn } from "@/utils/style";
|
||||
|
||||
function ScrollArea({ className, children, ...props }: ScrollAreaPrimitive.Root.Props) {
|
||||
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>
|
||||
);
|
||||
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 }: ScrollAreaPrimitive.Scrollbar.Props) {
|
||||
return (
|
||||
<ScrollAreaPrimitive.Scrollbar
|
||||
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.Thumb data-slot="scroll-area-thumb" className="relative flex-1 rounded-full bg-border" />
|
||||
</ScrollAreaPrimitive.Scrollbar>
|
||||
);
|
||||
return (
|
||||
<ScrollAreaPrimitive.Scrollbar
|
||||
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.Thumb data-slot="scroll-area-thumb" className="relative flex-1 rounded-full bg-border" />
|
||||
</ScrollAreaPrimitive.Scrollbar>
|
||||
);
|
||||
}
|
||||
|
||||
export { ScrollArea, ScrollBar };
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user