mirror of
https://github.com/AmruthPillai/Reactive-Resume.git
synced 2026-10-02 17:54:22 +10:00
feat(web): let content arrive instead of popping in
Loaded content now settles in: library cards and rows, checker issue rows, assistant suggestions and past conversations rise in with a short stagger on first appearance only; new assistant messages and "Thinking…" rise in while history stays put; a newly made document fades up from 96% with its accent ring fading in. Resume thumbnails keep the last drawn page of the same resume up while a new one renders, so edits and return visits no longer drop to a placeholder. The builder's first preview render paints hidden under the placeholder pages and fades in; the PDF viewer reserves an A4 slot and holds its overlay until a page has painted. The ATS checker ring fills on first reveal, ring colour blends across the threshold, and report and insights bars grow from their baseline. Hand-rolled pulsing placeholders become the static Skeleton, shaped like the view that is loading (documents grid or list, applications views, insights, API keys). ENTER_CLASS and stagger() live in libs/motion.ts.
This commit is contained in:
@@ -6,7 +6,9 @@ import { useQuery } from "@tanstack/react-query";
|
||||
import { useRef } from "react";
|
||||
import { Button } from "@reactive-resume/ui/components/button";
|
||||
import { Icon } from "@reactive-resume/ui/components/icon";
|
||||
import { Skeleton } from "@reactive-resume/ui/components/skeleton";
|
||||
import { toast } from "@reactive-resume/ui/components/toast";
|
||||
import { stagger } from "@/libs/motion";
|
||||
import { orpc } from "@/libs/orpc/client";
|
||||
import { computeInsights, computeOutcomes, computeTimeline } from "../insights";
|
||||
import { getStageColor, getStageLabel } from "../stages";
|
||||
@@ -18,6 +20,20 @@ const appliedDate = (app: Application) =>
|
||||
[...app.activity].sort(byNewest).find((entry) => entry.type === "stage" && entry.stage === "applied")?.at ??
|
||||
app.appliedAt;
|
||||
|
||||
/** The page's shape while stats load: the funnel, the two figures, then the charts. */
|
||||
export function InsightsSkeleton() {
|
||||
return (
|
||||
<div className="flex max-w-4xl flex-col gap-4">
|
||||
<Skeleton className="h-56 rounded-xl" />
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<Skeleton className="h-[102px] rounded-xl" />
|
||||
<Skeleton className="h-[102px] rounded-xl" />
|
||||
</div>
|
||||
<Skeleton className="h-80 rounded-xl" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ApplicationInsights({ applications }: { applications: Application[] }) {
|
||||
const { data } = useQuery(orpc.applications.stats.queryOptions({}));
|
||||
const { data: documents } = useQuery(orpc.documents.list.queryOptions({ input: { trashed: false } }));
|
||||
@@ -36,7 +52,7 @@ export function ApplicationInsights({ applications }: { applications: Applicatio
|
||||
);
|
||||
const maxWeek = Math.max(1, ...timeline.map((bucket) => bucket.count));
|
||||
|
||||
if (!data) return <div className="h-40 animate-pulse rounded-xl bg-sunken" />;
|
||||
if (!data) return <InsightsSkeleton />;
|
||||
|
||||
const insights = computeInsights(data.byStage);
|
||||
const maxSource = Math.max(1, ...data.bySource.map((s) => s.count));
|
||||
@@ -57,7 +73,7 @@ export function ApplicationInsights({ applications }: { applications: Applicatio
|
||||
<Trans>How far applications get</Trans>
|
||||
</h2>
|
||||
<ol className="grid gap-2">
|
||||
{outcomes.funnel.map((row) => (
|
||||
{outcomes.funnel.map((row, index) => (
|
||||
<li key={row.status} className="grid grid-cols-[92px_minmax(0,1fr)_32px] items-center gap-3 text-sm">
|
||||
<span className="flex items-center gap-1.5 text-ink-2">
|
||||
<span
|
||||
@@ -69,8 +85,12 @@ export function ApplicationInsights({ applications }: { applications: Applicatio
|
||||
</span>
|
||||
<span className="h-5 overflow-hidden rounded bg-sunken">
|
||||
<span
|
||||
className="block h-full rounded"
|
||||
style={{ width: `${(row.reached / widest) * 100}%`, background: getStageColor(row.status) }}
|
||||
className="block h-full origin-left starting:scale-x-0 rounded transition-[scale] delay-(--stagger) duration-emphasized ease-enter motion-reduce:delay-0 rtl:origin-right"
|
||||
style={{
|
||||
...stagger(index),
|
||||
width: `${(row.reached / widest) * 100}%`,
|
||||
background: getStageColor(row.status),
|
||||
}}
|
||||
/>
|
||||
</span>
|
||||
<span className="text-end font-display text-base tabular-nums">{row.reached}</span>
|
||||
@@ -132,13 +152,16 @@ export function ApplicationInsights({ applications }: { applications: Applicatio
|
||||
<Trans>Applications sent per week (last 8 weeks)</Trans>
|
||||
</p>
|
||||
<div className="mt-4 flex items-end gap-2">
|
||||
{timeline.map((bucket) => (
|
||||
{timeline.map((bucket, index) => (
|
||||
<div key={bucket.label} className="flex flex-1 flex-col items-center gap-1">
|
||||
<div className="flex h-28 w-full flex-col justify-end">
|
||||
<span className="mb-1 text-center text-[10px] text-ink-3 tabular-nums">{bucket.count || ""}</span>
|
||||
<div
|
||||
className="w-full rounded-t bg-accent"
|
||||
style={{ height: `${bucket.count ? Math.max((bucket.count / maxWeek) * 100, 8) : 0}%` }}
|
||||
className="w-full origin-bottom starting:scale-y-0 rounded-t bg-accent transition-[scale] delay-(--stagger) duration-emphasized ease-enter motion-reduce:delay-0"
|
||||
style={{
|
||||
...stagger(index),
|
||||
height: `${bucket.count ? Math.max((bucket.count / maxWeek) * 100, 8) : 0}%`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-[10px] text-ink-3 tabular-nums">{bucket.label}</span>
|
||||
@@ -161,13 +184,13 @@ export function ApplicationInsights({ applications }: { applications: Applicatio
|
||||
<Trans>No source data yet.</Trans>
|
||||
</p>
|
||||
) : (
|
||||
data.bySource.map((row) => (
|
||||
data.bySource.map((row, index) => (
|
||||
<div key={row.source} className="flex items-center gap-3 text-xs">
|
||||
<span className="w-28 shrink-0 truncate font-medium">{row.source}</span>
|
||||
<div className="h-2.5 flex-1 overflow-hidden rounded-full bg-sunken">
|
||||
<div
|
||||
className="h-full rounded-full bg-ink-2"
|
||||
style={{ width: `${Math.max((row.count / maxSource) * 100, 3)}%` }}
|
||||
className="h-full origin-left starting:scale-x-0 rounded-full bg-ink-2 transition-[scale] delay-(--stagger) duration-emphasized ease-enter motion-reduce:delay-0 rtl:origin-right"
|
||||
style={{ ...stagger(index), width: `${Math.max((row.count / maxSource) * 100, 3)}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className="w-6 text-right text-ink-3">{row.count}</span>
|
||||
|
||||
@@ -26,6 +26,7 @@ import { formatVersionTime } from "@/features/resume/share/format";
|
||||
import { useHasUsableAiProvider } from "@/features/settings/integrations/hooks/use-has-usable-ai-provider";
|
||||
import { useConfirm } from "@/hooks/use-confirm";
|
||||
import { getOrpcErrorMessage } from "@/libs/error-message";
|
||||
import { ENTER_CLASS, stagger } from "@/libs/motion";
|
||||
import { client, orpc } from "@/libs/orpc/client";
|
||||
import { Composer, Conversation } from "./conversation";
|
||||
import { ProviderSetup } from "./provider-setup";
|
||||
@@ -260,7 +261,8 @@ function ConversationLoader({ threadId, document, ...props }: ConversationLoader
|
||||
);
|
||||
if (!thread.data || !thread.isFetchedAfterMount)
|
||||
return (
|
||||
<div className="grid flex-1 place-items-center">
|
||||
// Hidden for the first 150ms, so a quick refetch never flashes a spinner.
|
||||
<div className="grid flex-1 place-items-center starting:opacity-0 transition-opacity delay-150 duration-standard ease-enter">
|
||||
<Spinner />
|
||||
</div>
|
||||
);
|
||||
@@ -352,8 +354,8 @@ function EmptyState({ document, disabled, onPick }: EmptyStateProps) {
|
||||
)}
|
||||
</div>
|
||||
<ul className="grid gap-1">
|
||||
{suggestionsFor(document, prepare).map((suggestion) => (
|
||||
<li key={suggestion.label}>
|
||||
{suggestionsFor(document, prepare).map((suggestion, index) => (
|
||||
<li key={suggestion.label} style={stagger(index)} className={ENTER_CLASS}>
|
||||
<button
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
@@ -434,9 +436,10 @@ function PastConversations({ threads, document, currentId, onOpen }: PastConvers
|
||||
</h3>
|
||||
{mine.length > 0 ? (
|
||||
<ul className="grid">
|
||||
{mine.map((thread) => (
|
||||
{mine.map((thread, index) => (
|
||||
<ConversationRow
|
||||
key={thread.id}
|
||||
index={index}
|
||||
thread={thread}
|
||||
current={thread.id === currentId}
|
||||
detail={`${formatVersionTime(new Date(thread.lastMessageAt), i18n.locale)} · ${outcome(thread)}`}
|
||||
@@ -457,9 +460,10 @@ function PastConversations({ threads, document, currentId, onOpen }: PastConvers
|
||||
<Trans>Other documents · {others.length}</Trans>
|
||||
</h3>
|
||||
<ul className="grid">
|
||||
{others.map((thread) => (
|
||||
{others.map((thread, index) => (
|
||||
<ConversationRow
|
||||
key={thread.id}
|
||||
index={mine.length + index}
|
||||
thread={thread}
|
||||
current={false}
|
||||
detail={`${formatVersionTime(new Date(thread.lastMessageAt), i18n.locale)} · ${outcome(thread)} · ${thread.coverLetterName ?? thread.resumeName ?? ""}`}
|
||||
@@ -478,13 +482,22 @@ type ConversationRowProps = {
|
||||
thread: ThreadSummary;
|
||||
current: boolean;
|
||||
detail: string;
|
||||
index: number;
|
||||
onOpen: () => void;
|
||||
onDelete: () => void;
|
||||
};
|
||||
|
||||
function ConversationRow({ thread, current, detail, onOpen, onDelete }: ConversationRowProps) {
|
||||
function ConversationRow({ thread, current, detail, index, onOpen, onDelete }: ConversationRowProps) {
|
||||
return (
|
||||
<li className="group/row flex items-center gap-1 rounded-lg transition-colors hover:bg-hover">
|
||||
<li
|
||||
style={stagger(index)}
|
||||
// cn keeps the last transition list, so the hover colour is named again after ENTER_CLASS's.
|
||||
className={cn(
|
||||
"group/row flex items-center gap-1 rounded-lg hover:bg-hover",
|
||||
ENTER_CLASS,
|
||||
"transition-[opacity,translate,background-color]",
|
||||
)}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
aria-current={current ? "true" : undefined}
|
||||
|
||||
@@ -18,6 +18,7 @@ import { cn } from "@reactive-resume/utils/style";
|
||||
import { ChangeSet } from "@/features/resume/editor/proposals/proposal-list";
|
||||
import { useEditorStore } from "@/features/resume/editor/store";
|
||||
import { getOrpcErrorMessage } from "@/libs/error-message";
|
||||
import { ENTER_CLASS } from "@/libs/motion";
|
||||
import { client, orpc } from "@/libs/orpc/client";
|
||||
import { attachmentPart, fileToBase64, transcriptOf, useAssistantChat } from "./chat";
|
||||
import { AssistantMarkdown } from "./markdown";
|
||||
@@ -49,6 +50,8 @@ export function Conversation(props: ConversationProps) {
|
||||
const [statuses, setStatuses] = useState<ReadonlyMap<string, EditStatus>>(new Map());
|
||||
const [stopped, setStopped] = useState(false);
|
||||
const scroller = useRef<HTMLDivElement>(null);
|
||||
// Messages already in the thread when it opens are history; only new ones rise in.
|
||||
const [initialIds] = useState(() => new Set(props.initialMessages.map((message) => message.id)));
|
||||
|
||||
const refresh = () => void queryClient.invalidateQueries({ queryKey: orpc.agent.threads.list.key() });
|
||||
const { messages, sendMessage, status, error, clearError, regenerate, stop, addToolOutput } = useAssistantChat({
|
||||
@@ -158,6 +161,7 @@ export function Conversation(props: ConversationProps) {
|
||||
key={message.id}
|
||||
message={message}
|
||||
streaming={streaming && index === messages.length - 1}
|
||||
enter={!initialIds.has(message.id)}
|
||||
readOnly={readOnly}
|
||||
statuses={statuses}
|
||||
document={document}
|
||||
@@ -167,14 +171,14 @@ export function Conversation(props: ConversationProps) {
|
||||
))}
|
||||
|
||||
{status === "submitted" && (
|
||||
<p className="flex items-center gap-2 text-ink-3 text-sm">
|
||||
<p className={cn("flex items-center gap-2 text-ink-3 text-sm", ENTER_CLASS)}>
|
||||
<Spinner decorative className="size-3.5" />
|
||||
<Trans>Thinking…</Trans>
|
||||
</p>
|
||||
)}
|
||||
|
||||
{stopped && !streaming && !proposedInLast && (
|
||||
<p className="flex items-center gap-2 text-ink-2 text-sm">
|
||||
<p className="flex items-center gap-2 text-ink-2 text-sm starting:opacity-0 transition-opacity duration-standard ease-enter">
|
||||
<Icon name="stop_circle" size={18} className="text-ink-3" />
|
||||
<Trans>Stopped. No edits were proposed.</Trans>
|
||||
<button
|
||||
@@ -188,7 +192,10 @@ export function Conversation(props: ConversationProps) {
|
||||
)}
|
||||
|
||||
{error && !streaming && (
|
||||
<div role="alert" className="grid gap-2 rounded-xl bg-danger-soft p-3 text-[13px] text-danger-text">
|
||||
<div
|
||||
role="alert"
|
||||
className="grid gap-2 rounded-xl bg-danger-soft p-3 text-[13px] text-danger-text starting:opacity-0 transition-opacity duration-standard ease-enter"
|
||||
>
|
||||
<span className="flex gap-2">
|
||||
<Icon name="error" size={18} className="shrink-0" />
|
||||
<Trans>
|
||||
@@ -209,7 +216,7 @@ export function Conversation(props: ConversationProps) {
|
||||
)}
|
||||
|
||||
{!streaming && messages.length > 0 && (
|
||||
<div className="flex justify-end">
|
||||
<div className="flex justify-end starting:opacity-0 transition-opacity duration-standard ease-enter">
|
||||
<Button size="sm" variant="ghost" className="text-ink-3" onClick={() => void copyTranscript(messages)}>
|
||||
<Icon name="content_copy" size={16} />
|
||||
<Trans>Copy transcript</Trans>
|
||||
@@ -253,6 +260,7 @@ function toProposals(
|
||||
type MessageViewProps = {
|
||||
message: UIMessage;
|
||||
streaming: boolean;
|
||||
enter: boolean;
|
||||
readOnly: boolean;
|
||||
statuses: ReadonlyMap<string, EditStatus>;
|
||||
document: AssistantDocument;
|
||||
@@ -260,12 +268,21 @@ type MessageViewProps = {
|
||||
onRecord: (message: UIMessage, part: ToolPart, proposals: readonly Proposal[], status: EditStatus) => void;
|
||||
};
|
||||
|
||||
function MessageView({ message, streaming, readOnly, statuses, document, onAnswer, onRecord }: MessageViewProps) {
|
||||
function MessageView({
|
||||
message,
|
||||
streaming,
|
||||
enter,
|
||||
readOnly,
|
||||
statuses,
|
||||
document,
|
||||
onAnswer,
|
||||
onRecord,
|
||||
}: MessageViewProps) {
|
||||
if (message.role === "user") {
|
||||
const text = message.parts.flatMap((part) => (part.type === "text" ? [part.text] : [])).join("\n");
|
||||
const files = message.parts.filter((part) => part.type === "file");
|
||||
return (
|
||||
<div className="ms-8 grid justify-items-end gap-1">
|
||||
<div className={cn("ms-8 grid justify-items-end gap-1", enter && ENTER_CLASS)}>
|
||||
{text && <p className="whitespace-pre-wrap rounded-[12px_12px_4px_12px] bg-sunken px-3 py-2 text-sm">{text}</p>}
|
||||
{files.map((file, index) => (
|
||||
<span
|
||||
@@ -286,7 +303,7 @@ function MessageView({ message, streaming, readOnly, statuses, document, onAnswe
|
||||
const lastTextIndex = message.parts.findLastIndex((part) => part.type === "text");
|
||||
|
||||
return (
|
||||
<div className="grid gap-3 text-sm">
|
||||
<div className={cn("grid gap-3 text-sm", enter && ENTER_CLASS)}>
|
||||
{message.parts.map((part, index) => {
|
||||
const key = `${message.id}-${index}`;
|
||||
if (part.type === "text")
|
||||
@@ -333,7 +350,7 @@ function MessageView({ message, streaming, readOnly, statuses, document, onAnswe
|
||||
);
|
||||
}
|
||||
|
||||
type ToolPartViewProps = Omit<MessageViewProps, "streaming"> & { part: ToolPart };
|
||||
type ToolPartViewProps = Omit<MessageViewProps, "streaming" | "enter"> & { part: ToolPart };
|
||||
|
||||
function ToolPartView({ part, message, readOnly, statuses, document, onAnswer, onRecord }: ToolPartViewProps) {
|
||||
const working = part.state === "input-streaming" || part.state === "input-available";
|
||||
|
||||
@@ -16,6 +16,7 @@ import { cn } from "@reactive-resume/utils/style";
|
||||
import { ImportError, readResumeFile } from "@/features/resume/import/read-file";
|
||||
import { PdfViewer } from "@/features/resume/public/pdf-viewer";
|
||||
import { getOrpcErrorMessage } from "@/libs/error-message";
|
||||
import { ENTER_CLASS, stagger } from "@/libs/motion";
|
||||
import { client } from "@/libs/orpc/client";
|
||||
import { PdfPasswordRequiredError, PdfTooLargeError, PdfUnreadableError } from "./extract-client";
|
||||
import { getPdfCategoryLabel, getPdfFindingMessage } from "./messages";
|
||||
@@ -334,7 +335,7 @@ function Result({ result, file, onFix, onReset }: ResultProps) {
|
||||
<div
|
||||
role="img"
|
||||
aria-label={t`Readability score: ${report.score} out of 100`}
|
||||
className="score-ring grid size-[84px] shrink-0 place-items-center rounded-full"
|
||||
className="score-ring score-ring-reveal grid size-[84px] shrink-0 place-items-center rounded-full"
|
||||
style={
|
||||
{
|
||||
"--ring-value": `${report.score}%`,
|
||||
@@ -370,7 +371,11 @@ function Result({ result, file, onFix, onReset }: ResultProps) {
|
||||
{rows.map((row, index) => {
|
||||
const expanded = open === row.key;
|
||||
return (
|
||||
<div key={row.key} className={cn(index > 0 && "border-line border-t")}>
|
||||
<div
|
||||
key={row.key}
|
||||
style={stagger(index)}
|
||||
className={cn(ENTER_CLASS, index > 0 && "border-line border-t")}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
aria-expanded={expanded}
|
||||
|
||||
@@ -29,10 +29,11 @@ export function ScoreHeader({ report }: ScoreHeaderProps) {
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Fills from empty when the report first shows. The starting --fill needs !important to beat the inline one; it's derived through --fill so RTL still fills from the right. */}
|
||||
<div className="h-1.5 overflow-hidden rounded-full bg-sunken">
|
||||
<div
|
||||
className={cn(
|
||||
"h-full translate-x-(--fill) rounded-full transition-[translate] duration-standard ease-enter rtl:-translate-x-(--fill)",
|
||||
"h-full translate-x-(--fill) rounded-full transition-[translate,background-color] duration-emphasized ease-enter starting:[--fill:-100%]! rtl:-translate-x-(--fill)",
|
||||
tone.bar,
|
||||
)}
|
||||
style={{ "--fill": `${report.score - 100}%` } as CSSProperties}
|
||||
|
||||
@@ -10,6 +10,7 @@ import { Icon } from "@reactive-resume/ui/components/icon";
|
||||
import { IconButton } from "@reactive-resume/ui/components/icon-button";
|
||||
import { cn } from "@reactive-resume/utils/style";
|
||||
import { formatRelativeTime } from "@/libs/locale";
|
||||
import { ENTER_CLASS, stagger } from "@/libs/motion";
|
||||
import { DocumentMenuContent, useDocumentActions } from "./document-actions";
|
||||
import { daysLeftInTrash } from "./filter";
|
||||
import { useNewDocumentsStore } from "./new-documents";
|
||||
@@ -19,6 +20,8 @@ export type DocumentItemProps = {
|
||||
document: DocumentSummary;
|
||||
onTags: (document: DocumentSummary) => void;
|
||||
onLink: (document: DocumentSummary) => void;
|
||||
/** Position in the library's first appearance, which staggers in; undefined afterwards and in Trash. */
|
||||
introIndex?: number | undefined;
|
||||
};
|
||||
|
||||
/** "Resume · Edited 2h ago", or the days left for a document in Trash. */
|
||||
@@ -103,7 +106,7 @@ function LetterThumbnail({ name }: { name: string }) {
|
||||
}
|
||||
|
||||
/** A 204px card: the real first page, title with ⋯, "Resume · Edited 2h ago" and the linked application. */
|
||||
export function DocumentCard({ document, onTags, onLink }: DocumentItemProps) {
|
||||
export function DocumentCard({ document, onTags, onLink, introIndex }: DocumentItemProps) {
|
||||
const openDocument = useOpenDocument();
|
||||
const [renaming, setRenaming] = useState(false);
|
||||
const isNew = useNewDocumentsStore((state) => state.ids.includes(document.id)) && !document.trashedAt;
|
||||
@@ -119,7 +122,17 @@ export function DocumentCard({ document, onTags, onLink }: DocumentItemProps) {
|
||||
return (
|
||||
<ContextMenu>
|
||||
<ContextMenuTrigger
|
||||
render={<article className={cn("group/card grid gap-2", document.trashedAt && "opacity-70")} />}
|
||||
render={
|
||||
<article
|
||||
style={introIndex === undefined ? undefined : stagger(introIndex)}
|
||||
className={cn(
|
||||
"group/card grid gap-2",
|
||||
introIndex !== undefined && ENTER_CLASS,
|
||||
isNew && "animate-arrive",
|
||||
document.trashedAt && "opacity-70",
|
||||
)}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<OpenLink
|
||||
document={document}
|
||||
@@ -127,7 +140,7 @@ export function DocumentCard({ document, onTags, onLink }: DocumentItemProps) {
|
||||
className={cn(
|
||||
"relative block aspect-page overflow-hidden rounded-[6px] shadow-[0_0_0_1px_var(--line),var(--shadow-1)] transition-[translate,scale,box-shadow] duration-quick ease-enter hover:-translate-y-0.5 hover:shadow-[0_0_0_1px_var(--line),var(--shadow-2)] active:scale-[0.98]",
|
||||
isNew &&
|
||||
"shadow-[0_0_0_2px_var(--accent),var(--shadow-1)] hover:shadow-[0_0_0_2px_var(--accent),var(--shadow-2)]",
|
||||
"shadow-[0_0_0_2px_var(--accent),var(--shadow-1)] starting:shadow-[0_0_0_2px_transparent,var(--shadow-1)] hover:shadow-[0_0_0_2px_var(--accent),var(--shadow-2)]",
|
||||
document.trashedAt && "pointer-events-none",
|
||||
)}
|
||||
>
|
||||
@@ -189,7 +202,7 @@ export function DocumentCard({ document, onTags, onLink }: DocumentItemProps) {
|
||||
}
|
||||
|
||||
/** The list view's row: Name, Type, Application, Edited, ⋯. */
|
||||
export function DocumentRow({ document, onTags, onLink }: DocumentItemProps) {
|
||||
export function DocumentRow({ document, onTags, onLink, introIndex }: DocumentItemProps) {
|
||||
const { i18n } = useLingui();
|
||||
const openDocument = useOpenDocument();
|
||||
const [renaming, setRenaming] = useState(false);
|
||||
@@ -208,8 +221,10 @@ export function DocumentRow({ document, onTags, onLink }: DocumentItemProps) {
|
||||
<ContextMenuTrigger
|
||||
render={
|
||||
<tr
|
||||
style={introIndex === undefined ? undefined : stagger(introIndex)}
|
||||
className={cn(
|
||||
"border-line border-b transition-colors duration-quick hover:bg-hover",
|
||||
introIndex !== undefined && ENTER_CLASS,
|
||||
document.trashedAt && "opacity-70",
|
||||
)}
|
||||
/>
|
||||
|
||||
@@ -9,6 +9,7 @@ import { Icon } from "@reactive-resume/ui/components/icon";
|
||||
import { Kbd } from "@reactive-resume/ui/components/kbd";
|
||||
import { NativeSelect } from "@reactive-resume/ui/components/native-select";
|
||||
import { SegmentedControl, SegmentedControlItem } from "@reactive-resume/ui/components/segmented-control";
|
||||
import { Skeleton } from "@reactive-resume/ui/components/skeleton";
|
||||
import { Tabs, TabsCount, TabsList, TabsTrigger } from "@reactive-resume/ui/components/tabs";
|
||||
import { cn } from "@reactive-resume/utils/style";
|
||||
import { useDialogStore } from "@/dialogs/store";
|
||||
@@ -44,6 +45,13 @@ export function DocumentsPage({ search, onSearchChange }: DocumentsPageProps) {
|
||||
// Grid and list animate in only after a switch, never on the page's first render.
|
||||
const [viewSwitched, setViewSwitched] = useState(false);
|
||||
const { data: documents, isPending } = useQuery(orpc.documents.list.queryOptions({ input: { trashed: false } }));
|
||||
// The library staggers in on its first appearance only; cards that appear later (filters, a new document) don't.
|
||||
const [intro, setIntro] = useState(true);
|
||||
useEffect(() => {
|
||||
if (isPending) return;
|
||||
const timeout = setTimeout(() => setIntro(false), INTRO_MS);
|
||||
return () => clearTimeout(timeout);
|
||||
}, [isPending]);
|
||||
const view = search.view ?? readStoredView();
|
||||
const setView = (next: "grid" | "list") => {
|
||||
setViewSwitched(true);
|
||||
@@ -174,14 +182,25 @@ export function DocumentsPage({ search, onSearchChange }: DocumentsPageProps) {
|
||||
)}
|
||||
|
||||
{isPending ? (
|
||||
<div className="grid grid-cols-[repeat(auto-fill,minmax(180px,204px))] gap-x-7 gap-y-[22px] max-sm:grid-cols-2 max-sm:gap-4">
|
||||
{Array.from({ length: 6 }, (_, index) => (
|
||||
<div key={index} className="grid gap-2">
|
||||
<div className="aspect-page animate-pulse rounded-[6px] bg-sunken" />
|
||||
<div className="h-4 w-3/4 animate-pulse rounded bg-sunken" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
view === "list" ? (
|
||||
<div className="grid">
|
||||
<div className="h-10 border-line border-b" />
|
||||
{Array.from({ length: 6 }, (_, index) => (
|
||||
<div key={index} className="flex h-[45px] items-center border-line border-b ps-3">
|
||||
<Skeleton className="h-4 w-1/3" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-[repeat(auto-fill,minmax(180px,204px))] gap-x-7 gap-y-[22px] max-sm:grid-cols-2 max-sm:gap-4">
|
||||
{Array.from({ length: 6 }, (_, index) => (
|
||||
<div key={index} className="grid gap-2">
|
||||
<Skeleton className="aspect-page rounded-[6px]" />
|
||||
<Skeleton className="h-4 w-3/4 rounded" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
) : shown.length === 0 ? (
|
||||
<div className="grid justify-items-center gap-2 py-16 text-center">
|
||||
<Icon name="search_off" size={28} className="text-ink-3" />
|
||||
@@ -229,8 +248,13 @@ export function DocumentsPage({ search, onSearchChange }: DocumentsPageProps) {
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{shown.map((document) => (
|
||||
<DocumentRow key={`${document.type}:${document.id}`} document={document} {...itemProps} />
|
||||
{shown.map((document, index) => (
|
||||
<DocumentRow
|
||||
key={`${document.type}:${document.id}`}
|
||||
document={document}
|
||||
introIndex={intro ? index : undefined}
|
||||
{...itemProps}
|
||||
/>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
@@ -241,8 +265,13 @@ export function DocumentsPage({ search, onSearchChange }: DocumentsPageProps) {
|
||||
viewEnter,
|
||||
)}
|
||||
>
|
||||
{shown.map((document) => (
|
||||
<DocumentCard key={`${document.type}:${document.id}`} document={document} {...itemProps} />
|
||||
{shown.map((document, index) => (
|
||||
<DocumentCard
|
||||
key={`${document.type}:${document.id}`}
|
||||
document={document}
|
||||
introIndex={intro ? index : undefined}
|
||||
{...itemProps}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
@@ -257,6 +286,8 @@ export function DocumentsPage({ search, onSearchChange }: DocumentsPageProps) {
|
||||
}
|
||||
|
||||
const VIEW_KEY = "documents-view";
|
||||
// Longest stagger (150ms) plus the D2 entrance (200ms), with room to spare: then the intro classes come off.
|
||||
const INTRO_MS = 400;
|
||||
|
||||
// The last view picked on this device; storage can be unavailable, and then it's the grid.
|
||||
function readStoredView(): "grid" | "list" {
|
||||
@@ -280,7 +311,7 @@ function FirstRun({ onChooseFile }: { onChooseFile: () => void }) {
|
||||
const { startBlank, trySample, creating } = useStartDocument();
|
||||
|
||||
return (
|
||||
<section className="grid max-w-xl gap-4 pt-4">
|
||||
<section className="grid max-w-xl starting:translate-y-2 gap-4 pt-4 starting:opacity-0 transition-[opacity,translate] duration-emphasized ease-enter">
|
||||
<h2 className="font-display font-medium text-[26px] leading-8">
|
||||
<Trans>Let's start with what you have</Trans>
|
||||
</h2>
|
||||
|
||||
@@ -239,7 +239,11 @@ export function NewDocumentDialog({ data }: { data?: NewDocumentDialogData | und
|
||||
notes={[t`file read`, t`${step.sections} sections`, t`${step.entries} entries`]}
|
||||
/>
|
||||
<p role="status" className="flex gap-2 text-[13px] leading-[19px]">
|
||||
<Icon name="check_circle" size={20} className="text-accent-text" />
|
||||
<Icon
|
||||
name="check_circle"
|
||||
size={20}
|
||||
className="starting:scale-80 text-accent-text starting:opacity-0 transition-[opacity,scale] duration-standard ease-enter"
|
||||
/>
|
||||
<span>
|
||||
<Trans>
|
||||
{step.sections} sections and {step.entries} entries found.
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import type { ResumeData } from "@reactive-resume/schema/resume/data";
|
||||
import type { QueryClient } from "@tanstack/react-query";
|
||||
import type { RefObject } from "react";
|
||||
import type { ResumeThumbnailSize } from "@/features/resume/preview/resume-thumbnail.shared";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useInView } from "motion/react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { Icon } from "@reactive-resume/ui/components/icon";
|
||||
import { Skeleton } from "@reactive-resume/ui/components/skeleton";
|
||||
import { createResumePdfBlob } from "@/features/resume/export/pdf-document";
|
||||
import { createPdfFirstPageImageUrl, releaseThumbnailUrls } from "@/features/resume/preview/pdf-thumbnail";
|
||||
import { getResumeThumbnailCacheKey, getResumeThumbnailSize } from "@/features/resume/preview/resume-thumbnail.shared";
|
||||
@@ -46,7 +48,7 @@ function useThumbnailSize(containerRef: RefObject<HTMLDivElement | null>, enable
|
||||
let media: MediaQueryList;
|
||||
const syncSize = () => {
|
||||
clearTimeout(timeout);
|
||||
// Coalesce sidebar animations and continuous viewport resizing.
|
||||
// Coalesce continuous resizing (window drags, zoom, the grid reflowing).
|
||||
timeout = setTimeout(() => {
|
||||
if (measured.width <= 0 || measured.height <= 0) return;
|
||||
const next = getResumeThumbnailSize(measured, window.devicePixelRatio);
|
||||
@@ -79,6 +81,19 @@ function useThumbnailSize(containerRef: RefObject<HTMLDivElement | null>, enable
|
||||
return size;
|
||||
}
|
||||
|
||||
// The newest thumbnail already drawn for this resume (an earlier edit, or another size), shown until the current
|
||||
// one is ready, so an edit or a return visit never drops the card back to a placeholder.
|
||||
const findLatestThumbnail = (queryClient: QueryClient, resumePrefix: string) => {
|
||||
let latest: { url: string; at: number } | undefined;
|
||||
for (const query of queryClient.getQueryCache().findAll({ queryKey: ["resume-thumbnail"] })) {
|
||||
const key = query.queryKey[1];
|
||||
const url = query.state.data;
|
||||
if (typeof key !== "string" || !key.startsWith(resumePrefix) || typeof url !== "string") continue;
|
||||
if (!latest || query.state.dataUpdatedAt > latest.at) latest = { url, at: query.state.dataUpdatedAt };
|
||||
}
|
||||
return latest?.url;
|
||||
};
|
||||
|
||||
function useResumeThumbnail(
|
||||
data: ResumeData | undefined,
|
||||
cacheKey: string,
|
||||
@@ -86,23 +101,12 @@ function useResumeThumbnail(
|
||||
enabled: boolean,
|
||||
): ThumbnailState {
|
||||
const queryClient = useQueryClient();
|
||||
const resumePrefix = `${cacheKey.slice(0, cacheKey.lastIndexOf(":"))}:`;
|
||||
|
||||
useEffect(() => {
|
||||
releaseThumbnailUrls(queryClient, "resume-thumbnail");
|
||||
}, [queryClient]);
|
||||
|
||||
useEffect(() => {
|
||||
const queryCache = queryClient.getQueryCache();
|
||||
const resumePrefix = `${cacheKey.slice(0, cacheKey.lastIndexOf(":"))}:`;
|
||||
|
||||
for (const query of queryCache.findAll({ queryKey: ["resume-thumbnail"] })) {
|
||||
const queryCacheKey = query.queryKey[1];
|
||||
if (typeof queryCacheKey === "string" && queryCacheKey.startsWith(resumePrefix) && queryCacheKey !== cacheKey) {
|
||||
queryCache.remove(query);
|
||||
}
|
||||
}
|
||||
}, [cacheKey, queryClient]);
|
||||
|
||||
const {
|
||||
data: thumbnailData,
|
||||
error: thumbnailError,
|
||||
@@ -114,12 +118,30 @@ function useResumeThumbnail(
|
||||
return createResumeThumbnailUrl(data, size, signal);
|
||||
},
|
||||
enabled: Boolean(enabled && data && size.width && size.height),
|
||||
placeholderData: (previous, query) => (query?.queryKey[1] === cacheKey ? previous : undefined),
|
||||
placeholderData: (previous) => previous ?? findLatestThumbnail(queryClient, resumePrefix),
|
||||
staleTime: Number.POSITIVE_INFINITY,
|
||||
gcTime: THUMBNAIL_CACHE_TIME,
|
||||
});
|
||||
const previousThumbnailData = useRef<string | undefined>(undefined);
|
||||
|
||||
// Drops this resume's thumbnails from earlier edits, except the one still on screen: it goes (and its URL is
|
||||
// revoked) once its replacement is what's shown.
|
||||
useEffect(() => {
|
||||
const queryCache = queryClient.getQueryCache();
|
||||
|
||||
for (const query of queryCache.findAll({ queryKey: ["resume-thumbnail"] })) {
|
||||
const queryCacheKey = query.queryKey[1];
|
||||
if (
|
||||
typeof queryCacheKey === "string" &&
|
||||
queryCacheKey.startsWith(resumePrefix) &&
|
||||
queryCacheKey !== cacheKey &&
|
||||
query.state.data !== thumbnailData
|
||||
) {
|
||||
queryCache.remove(query);
|
||||
}
|
||||
}
|
||||
}, [cacheKey, queryClient, resumePrefix, thumbnailData]);
|
||||
|
||||
useEffect(() => {
|
||||
if (thumbnailError) console.error("Failed to generate resume thumbnail", thumbnailError);
|
||||
}, [thumbnailError]);
|
||||
@@ -136,9 +158,9 @@ function useResumeThumbnail(
|
||||
previousThumbnailData.current = thumbnailData;
|
||||
}, [queryClient, thumbnailData]);
|
||||
|
||||
if (!data || !cacheKey) return { status: "idle" };
|
||||
if (thumbnailIsError) return { status: "error" };
|
||||
if (thumbnailData) return { status: "ready", url: thumbnailData };
|
||||
if (!data || !cacheKey) return { status: "idle" };
|
||||
|
||||
return { status: "loading" };
|
||||
}
|
||||
@@ -165,7 +187,7 @@ export function ResumeThumbnail({ resume }: ResumeThumbnailProps) {
|
||||
{thumbnail.status === "ready" ? (
|
||||
<div
|
||||
aria-hidden
|
||||
className="absolute inset-0 bg-center bg-contain bg-white bg-no-repeat"
|
||||
className="absolute inset-0 bg-center bg-contain bg-white bg-no-repeat starting:opacity-0 transition-opacity duration-standard ease-enter"
|
||||
style={{ backgroundImage: `url(${thumbnail.url})` }}
|
||||
/>
|
||||
) : hasFailed ? (
|
||||
@@ -174,7 +196,7 @@ export function ResumeThumbnail({ resume }: ResumeThumbnailProps) {
|
||||
</div>
|
||||
) : (
|
||||
// Loading: a sunken placeholder at the page's real size, so nothing jumps.
|
||||
<div className="absolute inset-0 animate-pulse bg-sunken" />
|
||||
<Skeleton className="absolute inset-0 rounded-none" />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -40,19 +40,14 @@ const EXITING_TRANSITION = { duration: 0.1, delay: 0.18 };
|
||||
// the old one at once, and no half-faded page is ever on screen.
|
||||
const INSTANT_TRANSITION = { duration: 0 };
|
||||
|
||||
const createPreviewPdf = (
|
||||
file: Blob,
|
||||
id: number,
|
||||
hasExistingPreview: boolean,
|
||||
template: Template,
|
||||
pageMap: PageMap | undefined,
|
||||
): PreviewPdf => ({
|
||||
const createPreviewPdf = (file: Blob, id: number, template: Template, pageMap: PageMap | undefined): PreviewPdf => ({
|
||||
file,
|
||||
id,
|
||||
pageMap,
|
||||
numPages: 0,
|
||||
pageSizes: {},
|
||||
phase: hasExistingPreview ? "staged" : "active",
|
||||
// Every render, the first one included, paints hidden and fades in once all its pages are drawn.
|
||||
phase: "staged",
|
||||
renderedPages: [],
|
||||
template,
|
||||
});
|
||||
@@ -158,13 +153,7 @@ export function ResumePreviewClient({
|
||||
);
|
||||
|
||||
if (!cancelled && requestId === requestIdRef.current) {
|
||||
const nextPdf = createPreviewPdf(
|
||||
blob,
|
||||
pdfIdRef.current++,
|
||||
hasPreviewRef.current,
|
||||
resumeData.metadata.template,
|
||||
pageMap,
|
||||
);
|
||||
const nextPdf = createPreviewPdf(blob, pdfIdRef.current++, resumeData.metadata.template, pageMap);
|
||||
|
||||
hasPreviewRef.current = true;
|
||||
setPreviewLayers((current) => addPreviewLayer(current, nextPdf));
|
||||
@@ -203,26 +192,28 @@ export function ResumePreviewClient({
|
||||
const visiblePdf = getActivePreviewLayer(previewLayers);
|
||||
const resolvedPageGap = getResumePreviewGapValue(pageGap);
|
||||
|
||||
if (!visiblePdf) {
|
||||
return (
|
||||
<>
|
||||
<ResumeAccessibleText data={resumeData} />
|
||||
<ResumePreviewLoader
|
||||
pageCount={getResumePreviewPageCount(resumeData)}
|
||||
pageClassName={pageClassName}
|
||||
pageGap={pageGap}
|
||||
pageLayout={pageLayout}
|
||||
pageScale={pageScale}
|
||||
showPageNumbers={showPageNumbers}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn("grid", className)}>
|
||||
<ResumeAccessibleText data={resumeData} />
|
||||
<AnimatePresence initial={false}>
|
||||
{!visiblePdf && (
|
||||
// Holds under the first render until it's opaque, then drops out, like any replaced layer.
|
||||
<m.div
|
||||
key="loader"
|
||||
className="col-start-1 row-start-1"
|
||||
exit={{ opacity: 0 }}
|
||||
transition={reducedMotion ? INSTANT_TRANSITION : EXITING_TRANSITION}
|
||||
>
|
||||
<ResumePreviewLoader
|
||||
pageCount={getResumePreviewPageCount(resumeData)}
|
||||
pageClassName={pageClassName}
|
||||
pageGap={pageGap}
|
||||
pageLayout={pageLayout}
|
||||
pageScale={pageScale}
|
||||
showPageNumbers={showPageNumbers}
|
||||
/>
|
||||
</m.div>
|
||||
)}
|
||||
{previewLayers.map((visiblePdf) => (
|
||||
<m.div
|
||||
key={visiblePdf.id}
|
||||
|
||||
@@ -66,7 +66,7 @@ function pdfViewerReducer(state: PdfViewerState, action: PdfViewerAction): PdfVi
|
||||
? { ...state, viewerHeight: action.height }
|
||||
: state;
|
||||
case "ready":
|
||||
return { ...state, isReady: true };
|
||||
return state.isReady ? state : { ...state, isReady: true };
|
||||
case "error":
|
||||
return { ...state, error: true, isReady: false };
|
||||
}
|
||||
@@ -161,6 +161,12 @@ export function PdfViewer({
|
||||
});
|
||||
};
|
||||
|
||||
// The overlay stays until a page has actually painted, so it never lifts onto a blank viewer.
|
||||
const reveal = () => {
|
||||
syncViewerHeight();
|
||||
dispatch({ type: "ready" });
|
||||
};
|
||||
|
||||
const setInitialScale = () => {
|
||||
if (!isCancelled && pdfViewer) {
|
||||
pdfViewer.currentScaleValue = "page-width";
|
||||
@@ -170,7 +176,7 @@ export function PdfViewer({
|
||||
|
||||
eventBus.on("pagesinit", setInitialScale);
|
||||
eventBus.on("pagesloaded", syncViewerHeight);
|
||||
eventBus.on("pagerendered", syncViewerHeight);
|
||||
eventBus.on("pagerendered", reveal);
|
||||
viewer.replaceChildren();
|
||||
dispatch({ type: "viewerLoading" });
|
||||
resizeObserver = new ResizeObserver(syncViewerHeight);
|
||||
@@ -208,7 +214,6 @@ export function PdfViewer({
|
||||
pdfViewer.setDocument(pdfDocument);
|
||||
linkService.setDocument(pdfDocument);
|
||||
syncViewerHeight();
|
||||
dispatch({ type: "ready" });
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -224,7 +229,7 @@ export function PdfViewer({
|
||||
isCancelled = true;
|
||||
eventBus.off("pagesinit", setInitialScale);
|
||||
eventBus.off("pagesloaded", syncViewerHeight);
|
||||
eventBus.off("pagerendered", syncViewerHeight);
|
||||
eventBus.off("pagerendered", reveal);
|
||||
abortController.abort();
|
||||
window.cancelAnimationFrame(animationFrameId);
|
||||
resizeObserver?.disconnect();
|
||||
@@ -237,7 +242,8 @@ export function PdfViewer({
|
||||
return (
|
||||
<div
|
||||
ref={rootRef}
|
||||
className={cn("pdf-viewer relative bg-sunken", viewerHeight ? "min-h-0" : "min-h-48", className)}
|
||||
// Until the real height is known, reserve an A4 page so the viewer doesn't grow from 192px.
|
||||
className={cn("pdf-viewer relative bg-sunken", viewerHeight ? "min-h-0" : "aspect-[210/297]", className)}
|
||||
style={viewerHeight ? { height: viewerHeight } : undefined}
|
||||
>
|
||||
<div ref={containerRef} className="absolute inset-0 overflow-visible">
|
||||
@@ -248,8 +254,14 @@ export function PdfViewer({
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-bg px-6 text-center text-ink-3 text-sm">
|
||||
Unable to display PDF preview.
|
||||
</div>
|
||||
) : isReady ? null : (
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-bg">
|
||||
) : (
|
||||
<div
|
||||
aria-hidden={isReady}
|
||||
className={cn(
|
||||
"absolute inset-0 flex items-center justify-center bg-bg transition-[opacity,visibility] ease-enter",
|
||||
isReady ? "invisible opacity-0 duration-[calc(var(--d2)*0.7)]" : "duration-standard",
|
||||
)}
|
||||
>
|
||||
<Spinner className="size-6" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
import { Icon } from "@reactive-resume/ui/components/icon";
|
||||
import { Input } from "@reactive-resume/ui/components/input";
|
||||
import { Label } from "@reactive-resume/ui/components/label";
|
||||
import { Skeleton } from "@reactive-resume/ui/components/skeleton";
|
||||
import { toast } from "@reactive-resume/ui/components/toast";
|
||||
import { cn } from "@reactive-resume/utils/style";
|
||||
import { authClient } from "@/libs/auth/client";
|
||||
@@ -89,12 +90,14 @@ export function ApiKeysSection() {
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
{isLoading ? null : keys.length === 0 ? (
|
||||
<p className="rounded-xl border border-line-2 border-dashed p-4 text-ink-2 text-sm">
|
||||
{isLoading ? (
|
||||
<Skeleton className="h-14 rounded-xl" />
|
||||
) : keys.length === 0 ? (
|
||||
<p className="rounded-xl border border-line-2 border-dashed p-4 text-ink-2 text-sm starting:opacity-0 transition-opacity duration-standard ease-enter">
|
||||
<Trans>No keys yet.</Trans>
|
||||
</p>
|
||||
) : (
|
||||
<table className="w-full overflow-hidden rounded-xl text-[13px] outline outline-line max-sm:block">
|
||||
<table className="w-full overflow-hidden rounded-xl text-[13px] starting:opacity-0 outline outline-line transition-opacity duration-standard ease-enter max-sm:block">
|
||||
<thead className="bg-bg text-ink-3 text-xs max-sm:hidden">
|
||||
<tr className="h-9 text-start">
|
||||
<th scope="col" className="ps-3.5 text-start font-medium">
|
||||
|
||||
+37
-1
@@ -7,9 +7,45 @@
|
||||
initial-value: 0%;
|
||||
}
|
||||
|
||||
/* Registered so crossing 80 blends accent and warn instead of snapping. */
|
||||
@property --ring-color {
|
||||
syntax: "<color>";
|
||||
inherits: false;
|
||||
initial-value: transparent;
|
||||
}
|
||||
|
||||
@utility score-ring {
|
||||
background: conic-gradient(var(--ring-color) 0 var(--ring-value), var(--sunken) 0);
|
||||
transition: --ring-value var(--d3) var(--ease);
|
||||
transition:
|
||||
--ring-value var(--d3) var(--ease),
|
||||
--ring-color var(--d3) var(--ease);
|
||||
}
|
||||
|
||||
/*
|
||||
* The free ATS checker's first reveal: the arc fills from 0 once per checked file. 480ms is over the UI budget on
|
||||
* purpose: it happens once per file and the fill is what explains the number. A keyframe (not @starting-style)
|
||||
* because it must run on the element's first style; the reduced-motion rule in globals.css makes it instant.
|
||||
*/
|
||||
@utility score-ring-reveal {
|
||||
animation: score-ring-reveal 480ms var(--ease) backwards;
|
||||
}
|
||||
|
||||
@keyframes score-ring-reveal {
|
||||
from {
|
||||
--ring-value: 0%;
|
||||
}
|
||||
}
|
||||
|
||||
@theme {
|
||||
/* A document made on this device lands on the grid: a fade up from 96%, once per mount. */
|
||||
--animate-arrive: arrive var(--d2) var(--ease) backwards;
|
||||
|
||||
@keyframes arrive {
|
||||
from {
|
||||
opacity: 0;
|
||||
scale: 0.96;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* A wavy warn underline under a line of the page that has a Check issue. */
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import type { CSSProperties } from "react";
|
||||
|
||||
// Motion (JS) mirrors of the CSS motion tokens in packages/ui/src/styles/globals.css (--d1/--d2/--d3, --ease,
|
||||
// --ease-in-out-strong). Durations are in seconds, Motion's unit; multiply by 1000 for APIs that take ms (dnd-kit).
|
||||
|
||||
@@ -30,3 +32,13 @@ export function switchModeInstantly(change: () => void) {
|
||||
change();
|
||||
requestAnimationFrame(() => requestAnimationFrame(() => list?.removeAttribute("data-instant")));
|
||||
}
|
||||
|
||||
/**
|
||||
* Something entering from nothing on its first appearance: a 4px rise and fade over D2. In a list, pair it with
|
||||
* `stagger(index)` on the same element; without it the delay is 0.
|
||||
*/
|
||||
export const ENTER_CLASS =
|
||||
"starting:translate-y-1 starting:opacity-0 transition-[opacity,translate] delay-(--stagger) duration-standard ease-enter motion-reduce:delay-0";
|
||||
|
||||
/** The index-th item of a list's first appearance: 30ms apart, capped at 150ms (the first six are staggered). */
|
||||
export const stagger = (index: number) => ({ "--stagger": `${Math.min(index, 5) * 30}ms` }) as CSSProperties;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { lazy, Suspense } from "react";
|
||||
import { Skeleton } from "@reactive-resume/ui/components/skeleton";
|
||||
import { SectionBase } from "../shared/section-base";
|
||||
|
||||
const StylesheetEditorShell = lazy(() => import("@/features/resume/stylesheet/editor"));
|
||||
@@ -6,9 +7,7 @@ const StylesheetEditorShell = lazy(() => import("@/features/resume/stylesheet/ed
|
||||
export function CustomStylesSectionBuilder() {
|
||||
return (
|
||||
<SectionBase type="styles" className="space-y-4">
|
||||
<Suspense
|
||||
fallback={<div role="status" className="h-72 animate-pulse rounded-md bg-sunken" aria-label="Loading editor" />}
|
||||
>
|
||||
<Suspense fallback={<Skeleton role="status" aria-label="Loading editor" className="h-72" />}>
|
||||
<StylesheetEditorShell />
|
||||
</Suspense>
|
||||
</SectionBase>
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
} from "@reactive-resume/ui/components/dropdown-menu";
|
||||
import { Icon } from "@reactive-resume/ui/components/icon";
|
||||
import { Input } from "@reactive-resume/ui/components/input";
|
||||
import { Skeleton } from "@reactive-resume/ui/components/skeleton";
|
||||
import { Tabs, TabsList, TabsTrigger } from "@reactive-resume/ui/components/tabs";
|
||||
import { useBreakpoint } from "@reactive-resume/ui/hooks/use-breakpoint";
|
||||
import { cn } from "@reactive-resume/utils/style";
|
||||
@@ -24,7 +25,7 @@ import { ApplicationBoard } from "@/features/applications/components/board";
|
||||
import { ApplicationCalendar } from "@/features/applications/components/calendar-view";
|
||||
import { ExportApplicationsSheet } from "@/features/applications/components/export-applications-sheet";
|
||||
import { ImportApplicationsSheet } from "@/features/applications/components/import-applications-sheet";
|
||||
import { ApplicationInsights } from "@/features/applications/components/insights-view";
|
||||
import { ApplicationInsights, InsightsSkeleton } from "@/features/applications/components/insights-view";
|
||||
import { ApplicationList } from "@/features/applications/components/list-view";
|
||||
import { getNextStep } from "@/features/applications/next-step";
|
||||
import { applicationsListQueryOptions } from "@/features/applications/queries";
|
||||
@@ -197,7 +198,9 @@ function RouteComponent() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{noMatches ? (
|
||||
{isPending ? (
|
||||
<ViewSkeleton view={shown} />
|
||||
) : noMatches ? (
|
||||
<div className="grid justify-items-center gap-2 py-16 text-center">
|
||||
<p className="font-medium text-sm">
|
||||
<Trans>No applications match “{query.trim()}”.</Trans>
|
||||
@@ -210,8 +213,10 @@ function RouteComponent() {
|
||||
<div
|
||||
key={shown}
|
||||
className={cn(
|
||||
viewSwitched &&
|
||||
"starting:translate-y-1 starting:opacity-0 transition-[opacity,translate] duration-standard ease-enter",
|
||||
viewSwitched
|
||||
? "starting:translate-y-1 starting:opacity-0 transition-[opacity,translate] duration-standard ease-enter"
|
||||
: // The list fades in on its first appearance, after its skeleton.
|
||||
shown === "list" && "starting:opacity-0 transition-opacity duration-standard ease-enter",
|
||||
)}
|
||||
>
|
||||
{shown === "list" && (
|
||||
@@ -331,7 +336,7 @@ function FollowUpNudge({ applications, onOpen }: { applications: Application[];
|
||||
|
||||
function EmptyState({ onAdd, onImport }: { onAdd: () => void; onImport: () => void }) {
|
||||
return (
|
||||
<div className="grid justify-items-center gap-3 py-20 text-center">
|
||||
<div className="grid starting:translate-y-2 justify-items-center gap-3 py-20 text-center starting:opacity-0 transition-[opacity,translate] duration-emphasized ease-enter">
|
||||
<span className="grid size-12 place-items-center rounded-xl bg-sunken text-ink-2">
|
||||
<Icon name="work" size={26} />
|
||||
</span>
|
||||
@@ -353,3 +358,17 @@ function EmptyState({ onAdd, onImport }: { onAdd: () => void; onImport: () => vo
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Each view's shape while applications load, so the first rows land where the placeholders were. */
|
||||
function ViewSkeleton({ view }: { view: View }) {
|
||||
if (view === "insights") return <InsightsSkeleton />;
|
||||
if (view === "list")
|
||||
return (
|
||||
<div className="grid gap-2 pt-10">
|
||||
{Array.from({ length: 6 }, (_, index) => (
|
||||
<Skeleton key={index} className="h-[52px]" />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
return <Skeleton className="h-[calc(100svh-230px)] min-h-96 rounded-xl" />;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user