use vite+

This commit is contained in:
Amruth Pillai
2026-03-18 22:03:24 +01:00
parent d1dac8aeca
commit 192880e416
427 changed files with 58915 additions and 58759 deletions
+313 -313
View File
@@ -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>
);
}
+60 -60
View File
@@ -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>
);
};
+101 -101
View File
@@ -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}
/>
);
}
+98 -98
View File
@@ -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>
);
};
+94 -94
View File
@@ -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>
);
};
+93 -93
View File
@@ -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>
);
}
+10 -10
View File
@@ -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>
</>
);
}
+37 -37
View File
@@ -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
View File
@@ -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>
);
}
+26 -26
View File
@@ -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>
);
}
+20 -20
View File
@@ -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>
}
/>
);
}
+72 -72
View File
@@ -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>
);
}
+145 -145
View File
@@ -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;
}
}
File diff suppressed because it is too large Load Diff
+56 -56
View File
@@ -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>
);
}
+107 -107
View File
@@ -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}
/>
);
}
+29 -29
View File
@@ -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>
);
}
+16 -16
View File
@@ -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>
);
}
+8 -8
View File
@@ -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>
);
}
+18 -18
View File
@@ -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>
);
}
+16 -16
View File
@@ -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} />;
}
+53 -53
View File
@@ -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>
);
}
+20 -20
View File
@@ -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;
};
+39 -39
View File
@@ -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]);
}
+99 -99
View File
@@ -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
View File
@@ -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>
);
}
+12 -12
View File
@@ -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>;
}
+8 -8
View File
@@ -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 }}
/>
);
}
+3 -3
View File
@@ -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} />;
}
+9 -9
View File
@@ -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>
);
}
+24 -24
View File
@@ -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>
);
}
+22 -22
View File
@@ -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>
);
}
+11 -11
View File
@@ -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>
);
}
+35 -35
View File
@@ -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);
}
+110 -110
View File
@@ -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>
);
}
+71 -71
View File
@@ -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>
);
}
+92 -92
View File
@@ -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>
);
}
+84 -84
View File
@@ -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>
);
}
+80 -80
View File
@@ -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>
);
}
+100 -100
View File
@@ -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>
);
}
+88 -88
View File
@@ -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>
);
}
+67 -67
View File
@@ -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>
);
}
+87 -87
View File
@@ -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>
);
}
+81 -81
View File
@@ -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>
);
}
+65 -65
View File
@@ -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>
);
}
+91 -91
View File
@@ -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>
);
}
+67 -67
View File
@@ -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>
);
}
+2 -2
View File
@@ -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>;
};
+15 -15
View File
@@ -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} />;
}
+25 -25
View File
@@ -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;
}
+42 -42
View File
@@ -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>
);
}
+60 -60
View File
@@ -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} />;
}
+26 -26
View File
@@ -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>
);
}
+10 -10
View File
@@ -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>;
};
File diff suppressed because it is too large Load Diff
+35 -35
View File
@@ -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
View File
@@ -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
View File
@@ -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 };
+65 -65
View File
@@ -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
View File
@@ -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 };
+17 -17
View File
@@ -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}
/>
</>
);
}
+54 -54
View File
@@ -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 };
+31 -31
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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,
};
+31 -31
View File
@@ -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>
);
}
+78 -78
View File
@@ -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
View File
@@ -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
View File
@@ -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 };
+29 -29
View File
@@ -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
View File
@@ -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 };
+57 -57
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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 };
+38 -38
View File
@@ -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 };
+28 -28
View File
@@ -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 };
+26 -26
View File
@@ -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