diff --git a/apps/blog-platform/app/[slug]/page.tsx b/apps/blog-platform/app/[slug]/page.tsx index ea5e3a7..77f4b7a 100644 --- a/apps/blog-platform/app/[slug]/page.tsx +++ b/apps/blog-platform/app/[slug]/page.tsx @@ -1,5 +1,4 @@ import type { Metadata } from "next"; -import type { ComponentType } from "react"; import Link from "next/link"; import Image from "next/image"; diff --git a/apps/server/src/middleware/rateLimit.ts b/apps/server/src/middleware/rateLimit.ts index a26e6d2..a38a230 100644 --- a/apps/server/src/middleware/rateLimit.ts +++ b/apps/server/src/middleware/rateLimit.ts @@ -22,6 +22,22 @@ function getClientKey(req: Request): string { return ip || "unknown"; } +function getSanitizedPath(path: string): string { + return path + .split("/") + .map((segment) => { + if ( + /^\d+$/.test(segment) || + /^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/i.test(segment) || + (segment.length >= 10 && /[a-zA-Z]/.test(segment) && /[0-9]/.test(segment)) + ) { + return ":id"; + } + return segment; + }) + .join("/"); +} + function getRouteLimitConfig(req: Request) { const isAuthRoute = req.path.startsWith("/api/v1/auth"); @@ -54,7 +70,8 @@ export const rateLimitMiddleware = (req: Request, res: Response, next: NextFunct const { windowMs, maxRequests } = getRouteLimitConfig(req); const key = getClientKey(req); - const redisKey = `rate-limit:${req.method}:${req.path}:${key}`; + const sanitizedPath = getSanitizedPath(req.path); + const redisKey = `rate-limit:${req.method}:${sanitizedPath}:${key}`; const checkWithFallback = async () => { try { diff --git a/apps/studio/app/(main)/(dashboard)/components/OverviewHome.tsx b/apps/studio/app/(main)/(dashboard)/components/OverviewHome.tsx index 1a893d4..82887bd 100644 --- a/apps/studio/app/(main)/(dashboard)/components/OverviewHome.tsx +++ b/apps/studio/app/(main)/(dashboard)/components/OverviewHome.tsx @@ -9,10 +9,10 @@ import { BookOpen, FolderOpen, ArrowRight, BriefcaseBusiness } from "lucide-reac import { Card } from "@veriworkly/ui"; import { - getResumeWorkspaceSnapshot, - subscribeToResumeWorkspace, - RESUME_WORKSPACE_SERVER_SNAPSHOT, -} from "@/features/documents/services/resume-workspace"; + getDocumentLibrarySnapshot, + subscribeToDocumentLibrary, + DOCUMENT_LIBRARY_SERVER_SNAPSHOT, +} from "@/features/documents/services/document-library"; import RecentCard from "./RecentCard"; import OverviewHomeHeader from "./OverviewHomeHeader"; @@ -33,18 +33,27 @@ function MiniLink({ href, icon: Icon, label }: { href: string; icon: LucideIcon; const OverviewHome = () => { const snapshot = useSyncExternalStore( - subscribeToResumeWorkspace, - () => getResumeWorkspaceSnapshot(), - () => RESUME_WORKSPACE_SERVER_SNAPSHOT, + subscribeToDocumentLibrary, + () => getDocumentLibrarySnapshot(), + () => DOCUMENT_LIBRARY_SERVER_SNAPSHOT, ); - const totalCount = snapshot.counts.RESUME; + const totalCount = + snapshot.counts.RESUME + + snapshot.counts.COVER_LETTER + + snapshot.counts.FORMAL_LETTER + + snapshot.counts.INVOICE; const resumeCount = snapshot.counts.RESUME; + const coverLetterCount = snapshot.counts.COVER_LETTER; const recentDocs = snapshot.docs.slice(0, 6); return (
- + @@ -54,11 +63,11 @@ const OverviewHome = () => {

Recently opened

-

Compact view, not another full resume page.

+

Compact view of your recent documents.

- All resumes + All documents
@@ -70,7 +79,7 @@ const OverviewHome = () => {

No files yet

- Use New Document in sidebar to create first resume. + Use New Document in sidebar to create your first file.

)} @@ -80,12 +89,14 @@ const OverviewHome = () => { diff --git a/apps/studio/app/(main)/(dashboard)/components/OverviewHomeHeader.tsx b/apps/studio/app/(main)/(dashboard)/components/OverviewHomeHeader.tsx index c94f846..91a3f6d 100644 --- a/apps/studio/app/(main)/(dashboard)/components/OverviewHomeHeader.tsx +++ b/apps/studio/app/(main)/(dashboard)/components/OverviewHomeHeader.tsx @@ -15,9 +15,11 @@ function Stat({ label, value }: { label: string; value: number }) { const OverviewHomeHeader = ({ totalCount, + coverLetterCount, resumeCount, }: { totalCount: number; + coverLetterCount: number; resumeCount: number; }) => { const user = useUserStore((state) => state.user); @@ -38,7 +40,7 @@ const OverviewHomeHeader = ({

- Recent resumes, useful references, and account shortcuts without duplicating the full + Recent documents, useful references, and account shortcuts without duplicating the full library page.

@@ -46,7 +48,7 @@ const OverviewHomeHeader = ({
- +
diff --git a/apps/studio/app/(main)/(dashboard)/components/RecentCard.tsx b/apps/studio/app/(main)/(dashboard)/components/RecentCard.tsx index 6518c98..1c40147 100644 --- a/apps/studio/app/(main)/(dashboard)/components/RecentCard.tsx +++ b/apps/studio/app/(main)/(dashboard)/components/RecentCard.tsx @@ -7,9 +7,9 @@ import { FileText } from "lucide-react"; import { Badge } from "@veriworkly/ui"; import { getDocumentDefinition } from "@/features/documents/core/registry"; -import { type ResumeWorkspaceDoc } from "@/features/documents/services/resume-workspace"; +import { type DocumentLibraryItem } from "@/features/documents/services/document-library"; -const RecentCard = ({ doc }: { doc: ResumeWorkspaceDoc }) => { +const RecentCard = ({ doc }: { doc: DocumentLibraryItem }) => { const definition = getDocumentDefinition(doc.type); return ( @@ -17,9 +17,16 @@ const RecentCard = ({ doc }: { doc: ResumeWorkspaceDoc }) => { href={`/editor/${doc.type.toLowerCase()}/${doc.id}`} className="border-border bg-background/70 group hover:border-accent/40 hover:bg-card overflow-hidden rounded-xl border transition" > -
+
{doc.previewImage ? ( - + ) : (
diff --git a/apps/studio/app/(main)/(dashboard)/documents/components/DocumentActionsMenu.tsx b/apps/studio/app/(main)/(dashboard)/documents/components/DocumentActionsMenu.tsx new file mode 100644 index 0000000..041e408 --- /dev/null +++ b/apps/studio/app/(main)/(dashboard)/documents/components/DocumentActionsMenu.tsx @@ -0,0 +1,126 @@ +"use client"; + +import { Copy, Cloud, Share2, Trash2, RefreshCw, ExternalLink, MoreHorizontal } from "lucide-react"; +import { toast } from "sonner"; + +import { Menu, MenuItem, MenuSeparator } from "@veriworkly/ui"; + +import type { DocumentLibraryItem } from "@/features/documents/services/document-library"; + +export interface DocumentActionsMenuProps { + doc: DocumentLibraryItem; + syncing: boolean; + onDeleteAction: (doc: DocumentLibraryItem) => void; + onShareAction: (doc: DocumentLibraryItem) => void; + onSyncNowAction: (id: string) => void; + onSyncDetailsAction: (id: string) => void; +} + +export function DocumentActionsMenu({ + doc, + syncing, + onDeleteAction, + onShareAction, + onSyncNowAction, + onSyncDetailsAction, +}: DocumentActionsMenuProps) { + return ( + ( + + )} + > + {({ close }) => ( + <> + { + close(); + window.location.href = `/editor/${doc.type.toLowerCase()}/${doc.id}`; + }} + > + + Open + + + { + close(); + onShareAction(doc); + }} + > + + Create public link + + + { + close(); + onSyncNowAction(doc.id); + }} + > + + {syncing ? "Syncing..." : "Sync now"} + + + { + close(); + onSyncDetailsAction(doc.id); + }} + > + + View sync details + + + { + close(); + void navigator.clipboard.writeText( + `${window.location.origin}/editor/${doc.type.toLowerCase()}/${doc.id}`, + ); + toast.success("Document link copied"); + }} + > + + Copy link + + + + + { + close(); + onDeleteAction(doc); + }} + > + + Delete + + + )} + + ); +} diff --git a/apps/studio/app/(main)/(dashboard)/documents/components/DocumentListRow.tsx b/apps/studio/app/(main)/(dashboard)/documents/components/DocumentListRow.tsx new file mode 100644 index 0000000..931f290 --- /dev/null +++ b/apps/studio/app/(main)/(dashboard)/documents/components/DocumentListRow.tsx @@ -0,0 +1,82 @@ +"use client"; + +import Link from "next/link"; + +import { Badge, Button } from "@veriworkly/ui"; + +import type { ResumeSyncTelemetry } from "@/features/resume/services/resume-sync"; +import type { DocumentLibraryItem } from "@/features/documents/services/document-library"; + +import { getDocumentDefinition } from "@/features/documents/core/registry"; +import { formatRelative } from "@/features/documents/services/document-library"; + +import { DocumentActionsMenu } from "./DocumentActionsMenu"; +import { docIconMap, getSyncLabel, getActivityLabel } from "./document-display"; + +interface DocumentListRowProps { + doc: DocumentLibraryItem; + syncing: boolean; + telemetry: ResumeSyncTelemetry | null; + onDeleteAction: (doc: DocumentLibraryItem) => void; + onShareAction: (doc: DocumentLibraryItem) => void; + onSyncNowAction: (id: string) => void; + onSyncDetailsAction: (id: string) => void; +} + +export function DocumentListRow({ + doc, + syncing, + telemetry, + onDeleteAction, + onShareAction, + onSyncNowAction, + onSyncDetailsAction, +}: DocumentListRowProps) { + const Icon = docIconMap[doc.type]; + + return ( +
+
+ +
+ +
+
+

{doc.title}

+ {getDocumentDefinition(doc.type).label} +
+ +

{doc.description}

+ +
+ {doc.templateName} + + {getSyncLabel(doc.sync)} + + + {getActivityLabel(doc.sync, telemetry)} + + + + Updated {formatRelative(doc.updatedAt)} + +
+
+ +
+ + + +
+
+ ); +} diff --git a/apps/studio/app/(main)/(dashboard)/documents/components/DocumentPreviewCard.tsx b/apps/studio/app/(main)/(dashboard)/documents/components/DocumentPreviewCard.tsx new file mode 100644 index 0000000..e2f6a59 --- /dev/null +++ b/apps/studio/app/(main)/(dashboard)/documents/components/DocumentPreviewCard.tsx @@ -0,0 +1,137 @@ +"use client"; + +import Link from "next/link"; +import Image from "next/image"; + +import { Badge } from "@veriworkly/ui"; + +import type { ResumeSyncTelemetry } from "@/features/resume/services/resume-sync"; +import type { DocumentLibraryItem } from "@/features/documents/services/document-library"; + +import { getDocumentDefinition } from "@/features/documents/core/registry"; +import { formatRelative } from "@/features/documents/services/document-library"; + +import { DocumentActionsMenu } from "./DocumentActionsMenu"; +import { docIconMap, getSyncLabel, getActivityLabel } from "./document-display"; + +interface DocumentPreviewCardProps { + doc: DocumentLibraryItem; + syncing: boolean; + telemetry: ResumeSyncTelemetry | null; + onDeleteAction: (doc: DocumentLibraryItem) => void; + onShareAction: (doc: DocumentLibraryItem) => void; + onSyncNowAction: (id: string) => void; + onSyncDetailsAction: (id: string) => void; +} + +export function DocumentPreviewCard({ + doc, + syncing, + telemetry, + onDeleteAction, + onShareAction, + onSyncNowAction, + onSyncDetailsAction, +}: DocumentPreviewCardProps) { + return ( +
+
+ +
+ +
+
+

{doc.title}

+ + + {getDocumentDefinition(doc.type).label} + +
+ +
+ {doc.description && ( +

{doc.description}

+ )} + +
+ {doc.templateName} + + {formatRelative(doc.updatedAt)} + +
+ +
+
+
Status
+
{getSyncLabel(doc.sync)}
+
+ +
+
Activity
+
{getActivityLabel(doc.sync, telemetry)}
+
+
+
+
+ + + +
+ +
+
+ ); +} + +function DocumentThumbnailPreview({ doc }: { doc: DocumentLibraryItem }) { + if (doc.previewImage) { + return ( +
+ {`${doc.templateName} +
+ ); + } + + const Icon = docIconMap[doc.type]; + + return ( +
+
+
+
+ +
+ +
+
+
+
+
+ +
+
+
+
+
+
+
+ ); +} diff --git a/apps/studio/app/(main)/(dashboard)/documents/components/ResumeWorkspaceControls.tsx b/apps/studio/app/(main)/(dashboard)/documents/components/DocumentWorkspaceControls.tsx similarity index 100% rename from apps/studio/app/(main)/(dashboard)/documents/components/ResumeWorkspaceControls.tsx rename to apps/studio/app/(main)/(dashboard)/documents/components/DocumentWorkspaceControls.tsx diff --git a/apps/studio/app/(main)/(dashboard)/documents/components/ResumeWorkspaceEmptyPanel.tsx b/apps/studio/app/(main)/(dashboard)/documents/components/DocumentWorkspaceEmptyState.tsx similarity index 56% rename from apps/studio/app/(main)/(dashboard)/documents/components/ResumeWorkspaceEmptyPanel.tsx rename to apps/studio/app/(main)/(dashboard)/documents/components/DocumentWorkspaceEmptyState.tsx index b167f2a..55ac24c 100644 --- a/apps/studio/app/(main)/(dashboard)/documents/components/ResumeWorkspaceEmptyPanel.tsx +++ b/apps/studio/app/(main)/(dashboard)/documents/components/DocumentWorkspaceEmptyState.tsx @@ -1,6 +1,6 @@ import { FileText } from "lucide-react"; -export function ResumeWorkspaceEmptyPanel({ activeTab }: { activeTab: "recent" | "shared" }) { +export function DocumentWorkspaceEmptyState({ activeTab }: { activeTab: "recent" | "shared" }) { return (
@@ -8,13 +8,13 @@ export function ResumeWorkspaceEmptyPanel({ activeTab }: { activeTab: "recent" |

- {activeTab === "shared" ? "No shared resumes yet" : "No resumes yet"} + {activeTab === "shared" ? "No shared documents yet" : "No documents yet"}

{activeTab === "shared" - ? "Shared resumes appear here after they have a cloud-backed share link." - : "Use the sidebar create button to start a resume."} + ? "Shared documents appear here after they have a cloud-backed share link." + : "Use the sidebar create button to start a resume, cover letter, formal letter, or invoice."}

); diff --git a/apps/studio/app/(main)/(dashboard)/documents/components/ResumeWorkspaceItems.tsx b/apps/studio/app/(main)/(dashboard)/documents/components/ResumeWorkspaceItems.tsx deleted file mode 100644 index eb0e605..0000000 --- a/apps/studio/app/(main)/(dashboard)/documents/components/ResumeWorkspaceItems.tsx +++ /dev/null @@ -1,342 +0,0 @@ -"use client"; - -import { - Copy, - Cloud, - Share2, - Trash2, - FileText, - RefreshCw, - ExternalLink, - MoreHorizontal, -} from "lucide-react"; -import Link from "next/link"; -import Image from "next/image"; -import { toast } from "sonner"; - -import { Badge, Button, Menu, MenuItem, MenuSeparator } from "@veriworkly/ui"; - -import type { ResumeSyncTelemetry } from "@/features/resume/services/resume-sync"; -import type { ResumeWorkspaceDoc } from "@/features/documents/services/resume-workspace"; - -import { getDocumentDefinition } from "@/features/documents/core/registry"; -import { formatRelative } from "@/features/documents/services/resume-workspace"; - -const docIconMap = { - RESUME: FileText, -} satisfies Record; - -export function ResumePreviewCard({ - doc, - syncing, - telemetry, - onDelete, - onShare, - onSyncNow, - onSyncDetails, -}: { - doc: ResumeWorkspaceDoc; - syncing: boolean; - telemetry: ResumeSyncTelemetry | null; - onDelete: (doc: ResumeWorkspaceDoc) => void; - onShare: (id: string) => void; - onSyncNow: (id: string) => void; - onSyncDetails: (id: string) => void; -}) { - return ( -
-
- - - - -
- -
-
- -
-
-
-

{doc.title}

-

{doc.description}

-
- - - {getDocumentDefinition(doc.type).label} - -
- -
- {doc.templateName} - - - {formatRelative(doc.updatedAt)} - -
- -
-
-
Status
-
{getSyncLabel(doc.sync)}
-
- -
-
Activity
-
{getActivityLabel(doc.sync, telemetry)}
-
-
-
-
- ); -} - -export function ResumeListRow({ - doc, - syncing, - telemetry, - onDelete, - onShare, - onSyncNow, - onSyncDetails, -}: { - doc: ResumeWorkspaceDoc; - syncing: boolean; - telemetry: ResumeSyncTelemetry | null; - onDelete: (doc: ResumeWorkspaceDoc) => void; - onShare: (id: string) => void; - onSyncNow: (id: string) => void; - onSyncDetails: (id: string) => void; -}) { - const Icon = docIconMap[doc.type]; - - return ( -
-
- -
- -
-
-

{doc.title}

- {getDocumentDefinition(doc.type).label} -
- -

{doc.description}

- -
- {doc.templateName} - - {getSyncLabel(doc.sync)} - - - {getActivityLabel(doc.sync, telemetry)} - - - - Updated {formatRelative(doc.updatedAt)} - -
-
- -
- - - -
-
- ); -} - -function ResumePreview({ doc }: { doc: ResumeWorkspaceDoc }) { - if (doc.previewImage) { - return ( -
- {`${doc.templateName} -
- ); - } - - const Icon = docIconMap[doc.type]; - - return ( -
-
-
-
- -
- -
-
-
-
-
- -
-
-
-
-
-
-
- ); -} - -function ResumeActionsMenu({ - doc, - syncing, - onDelete, - onShare, - onSyncNow, - onSyncDetails, -}: { - doc: ResumeWorkspaceDoc; - syncing: boolean; - onDelete: (doc: ResumeWorkspaceDoc) => void; - onShare: (id: string) => void; - onSyncNow: (id: string) => void; - onSyncDetails: (id: string) => void; -}) { - return ( - ( - - )} - > - {({ close }) => ( - <> - { - close(); - window.location.href = `/editor/${doc.type.toLowerCase()}/${doc.id}`; - }} - > - - Open - - - { - close(); - onShare(doc.id); - }} - > - - Share resume - - - { - close(); - onSyncNow(doc.id); - }} - > - - {syncing ? "Syncing..." : "Sync now"} - - - { - close(); - onSyncDetails(doc.id); - }} - > - - View sync details - - - { - close(); - void navigator.clipboard.writeText( - `${window.location.origin}/editor/${doc.type.toLowerCase()}/${doc.id}`, - ); - toast.success("Resume link copied"); - }} - > - - Copy link - - - - - { - close(); - onDelete(doc); - }} - > - - Delete - - - )} - - ); -} - -function getSyncLabel(sync: ResumeWorkspaceDoc["sync"]) { - if (!sync.enabled) return "Local only"; - if (sync.status === "synced") return "Synced"; - if (sync.status === "syncing") return "Syncing"; - if (sync.status === "conflicted") return "Conflict"; - - return "Pending"; -} - -function getActivityLabel(sync: ResumeWorkspaceDoc["sync"], telemetry: ResumeSyncTelemetry | null) { - if (sync.lastSyncedAt) return `Last synced ${formatRelative(sync.lastSyncedAt)}`; - if (telemetry?.lastAttemptAt) return `Last attempt ${formatRelative(telemetry.lastAttemptAt)}`; - - return sync.enabled ? "Sync ready" : "Stored locally"; -} diff --git a/apps/studio/app/(main)/(dashboard)/documents/components/document-display.ts b/apps/studio/app/(main)/(dashboard)/documents/components/document-display.ts new file mode 100644 index 0000000..8208222 --- /dev/null +++ b/apps/studio/app/(main)/(dashboard)/documents/components/document-display.ts @@ -0,0 +1,30 @@ +import { FileText, Mail, ReceiptText, ScrollText } from "lucide-react"; +import type { DocumentLibraryItem } from "@/features/documents/services/document-library"; +import type { ResumeSyncTelemetry } from "@/features/resume/services/resume-sync"; +import { formatRelative } from "@/features/documents/services/document-library"; + +export const docIconMap = { + RESUME: FileText, + COVER_LETTER: Mail, + FORMAL_LETTER: ScrollText, + INVOICE: ReceiptText, +} satisfies Record; + +export function getSyncLabel(sync: DocumentLibraryItem["sync"]) { + if (!sync.enabled) return "Local only"; + if (sync.status === "synced") return "Synced"; + if (sync.status === "syncing") return "Syncing"; + if (sync.status === "conflicted") return "Conflict"; + + return "Pending"; +} + +export function getActivityLabel( + sync: DocumentLibraryItem["sync"], + telemetry: ResumeSyncTelemetry | null, +) { + if (sync.lastSyncedAt) return `Last synced ${formatRelative(sync.lastSyncedAt)}`; + if (telemetry?.lastAttemptAt) return `Last attempt ${formatRelative(telemetry.lastAttemptAt)}`; + + return sync.enabled ? "Sync ready" : "Stored locally"; +} diff --git a/apps/studio/app/(main)/(dashboard)/documents/page.tsx b/apps/studio/app/(main)/(dashboard)/documents/page.tsx index 060a58b..d348e3c 100644 --- a/apps/studio/app/(main)/(dashboard)/documents/page.tsx +++ b/apps/studio/app/(main)/(dashboard)/documents/page.tsx @@ -4,7 +4,7 @@ import DocumentsWorkspace from "./workspace"; export const metadata: Metadata = { title: "Resumes", - description: "Manage saved resumes in one workspace.", + description: "Manage saved documents in one workspace.", robots: { index: false, follow: false }, }; diff --git a/apps/studio/app/(main)/(dashboard)/documents/useDocumentsWorkspace.ts b/apps/studio/app/(main)/(dashboard)/documents/useDocumentsWorkspace.ts index ac3bc06..813a7ff 100644 --- a/apps/studio/app/(main)/(dashboard)/documents/useDocumentsWorkspace.ts +++ b/apps/studio/app/(main)/(dashboard)/documents/useDocumentsWorkspace.ts @@ -4,27 +4,29 @@ import { toast } from "sonner"; import { useCallback, useEffect, useMemo, useState, useSyncExternalStore } from "react"; import { - syncResumeNow, - keepResumeLocalOnly, - getResumeSyncTelemetry, - resolveConflictUseCloud, - resolveConflictUseLocal, - getResumeSyncTelemetryByIds, -} from "@/features/resume/services/resume-sync"; + syncDocumentNow, + keepDocumentLocalOnly, + getDocumentSyncTelemetry, + resolveDocumentConflictUseCloud, + resolveDocumentConflictUseLocal, + getDocumentSyncTelemetryByDocs, +} from "@/features/documents/services/document-sync"; import { - type ResumeWorkspaceDoc, - getResumeWorkspaceSnapshot, - subscribeToResumeWorkspace, - RESUME_WORKSPACE_SERVER_SNAPSHOT, -} from "@/features/documents/services/resume-workspace"; + type DocumentLibraryItem, + getDocumentLibrarySnapshot, + subscribeToDocumentLibrary, + DOCUMENT_LIBRARY_SERVER_SNAPSHOT, +} from "@/features/documents/services/document-library"; import { DocumentApi } from "@/features/documents/services/document-api"; +import { deleteDocument } from "@/features/documents/services/document-workspace-service"; import { deleteResumeById } from "@/features/resume/services/resume-service"; -import { listResumeShareLinks } from "@/features/resume/services/share-links"; +import { listAllShareLinks } from "@/features/documents/services/share-service"; +import type { DocumentType } from "@/features/documents/core/document-types"; export type ViewMode = "grid" | "list"; export type SortMode = "updated" | "title"; export type ActiveTab = "recent" | "shared"; -export type DocumentTypeFilter = ResumeWorkspaceDoc["type"] | "ALL"; +export type DocumentTypeFilter = DocumentType | "ALL"; export function useDocumentsWorkspace() { const [viewMode, setViewMode] = useState("grid"); @@ -33,24 +35,24 @@ export function useDocumentsWorkspace() { const [activeTab, setActiveTab] = useState("recent"); const [activeType, setActiveType] = useState("ALL"); - const [shareTargetId, setShareTargetId] = useState(null); - const [sharedResumeIds, setSharedResumeIds] = useState>(new Set()); + const [shareTarget, setShareTarget] = useState(null); + const [sharedDocumentIds, setSharedDocumentIds] = useState>(new Set()); const [refreshKey, setRefreshKey] = useState(0); - const [syncingResumeId, setSyncingResumeId] = useState(null); + const [syncingDocumentId, setSyncingDocumentId] = useState(null); const [syncDetailsTargetId, setSyncDetailsTargetId] = useState(null); const [isDeleting, setIsDeleting] = useState(false); - const [deleteTarget, setDeleteTarget] = useState(null); + const [deleteTarget, setDeleteTarget] = useState(null); const snapshot = useSyncExternalStore( - subscribeToResumeWorkspace, - () => getResumeWorkspaceSnapshot(activeType, refreshKey), - () => RESUME_WORKSPACE_SERVER_SNAPSHOT, + subscribeToDocumentLibrary, + () => getDocumentLibrarySnapshot(activeType, refreshKey), + () => DOCUMENT_LIBRARY_SERVER_SNAPSHOT, ); const { docs, counts } = snapshot; - const totalCount = counts.RESUME; + const totalCount = counts.RESUME + counts.COVER_LETTER + counts.FORMAL_LETTER + counts.INVOICE; const bump = useCallback(() => setRefreshKey((key) => key + 1), []); @@ -59,7 +61,7 @@ export function useDocumentsWorkspace() { if (docs.length === 0) { queueMicrotask(() => { - if (!cancelled) setSharedResumeIds(new Set()); + if (!cancelled) setSharedDocumentIds(new Set()); }); return () => { @@ -70,7 +72,7 @@ export function useDocumentsWorkspace() { void Promise.all( docs.map(async (doc) => { try { - const links = await listResumeShareLinks(doc.id); + const links = await listAllShareLinks(doc.id); return links.length > 0 ? doc.id : null; } catch { return null; @@ -78,7 +80,7 @@ export function useDocumentsWorkspace() { }), ).then((ids) => { if (!cancelled) { - setSharedResumeIds(new Set(ids.filter((id): id is string => Boolean(id)))); + setSharedDocumentIds(new Set(ids.filter((id): id is string => Boolean(id)))); } }); @@ -89,46 +91,46 @@ export function useDocumentsWorkspace() { const visibleDocs = useMemo(() => { const tabDocs = - activeTab === "shared" ? docs.filter((doc) => sharedResumeIds.has(doc.id)) : docs; + activeTab === "shared" ? docs.filter((doc) => sharedDocumentIds.has(doc.id)) : docs; return [...tabDocs].sort((left, right) => { if (sortMode === "title") return left.title.localeCompare(right.title); return Date.parse(right.updatedAt) - Date.parse(left.updatedAt); }); - }, [activeTab, docs, sharedResumeIds, sortMode]); + }, [activeTab, docs, sharedDocumentIds, sortMode]); - const resumeTarget = useMemo( - () => docs.find((doc) => doc.id === syncDetailsTargetId)?.resume ?? null, + const syncDetailsTarget = useMemo( + () => docs.find((doc) => doc.id === syncDetailsTargetId) ?? null, [docs, syncDetailsTargetId], ); - const shareTargetTitle = useMemo( - () => docs.find((doc) => doc.id === shareTargetId)?.title, - [docs, shareTargetId], - ); + const shareTargetTitle = shareTarget?.title; - const syncTelemetryById = useMemo( - () => getResumeSyncTelemetryByIds(docs.map((doc) => doc.id)), - [docs], - ); + const syncTelemetryById = useMemo(() => getDocumentSyncTelemetryByDocs(docs), [docs]); const syncTargetTelemetry = useMemo( - () => (resumeTarget ? getResumeSyncTelemetry(resumeTarget.id) : null), - [resumeTarget], + () => + syncDetailsTarget + ? getDocumentSyncTelemetry(syncDetailsTarget.type, syncDetailsTarget.id) + : null, + [syncDetailsTarget], ); const handleSyncNow = useCallback( - async (resumeId: string) => { - setSyncingResumeId(resumeId); + async (id: string) => { + const target = docs.find((doc) => doc.id === id); + if (!target) return; - const result = await syncResumeNow(resumeId); + setSyncingDocumentId(id); + + const result = await syncDocumentNow(target.type, id); toast[result.ok ? "success" : "error"](result.message); - setSyncingResumeId(null); + setSyncingDocumentId(null); bump(); }, - [bump], + [bump, docs], ); const handleConfirmDelete = useCallback(async () => { @@ -137,9 +139,12 @@ export function useDocumentsWorkspace() { setIsDeleting(true); try { - if (deleteTarget.sync.cloudDocumentId) await DocumentApi.delete(deleteTarget.id); - - deleteResumeById(deleteTarget.id); + if (deleteTarget.type === "RESUME") { + if (deleteTarget.sync.cloudDocumentId) await DocumentApi.delete(deleteTarget.id); + deleteResumeById(deleteTarget.id); + } else { + deleteDocument(deleteTarget.type, deleteTarget.id); + } toast.success(`${deleteTarget.title} deleted`); @@ -154,42 +159,51 @@ export function useDocumentsWorkspace() { const handleKeepLocalOnly = useCallback( (id: string) => { - const result = keepResumeLocalOnly(id); + const target = docs.find((doc) => doc.id === id); + if (!target) return; + + const result = keepDocumentLocalOnly(target.type, id); toast.info(result.message); setSyncDetailsTargetId(null); bump(); }, - [bump], + [bump, docs], ); const handleResolveUseCloud = useCallback( async (id: string) => { - setSyncingResumeId(id); + const target = docs.find((doc) => doc.id === id); + if (!target) return; - const result = await resolveConflictUseCloud(id); + setSyncingDocumentId(id); + + const result = await resolveDocumentConflictUseCloud(target.type, id); toast[result.ok ? "success" : "error"](result.message); - setSyncingResumeId(null); + setSyncingDocumentId(null); bump(); }, - [bump], + [bump, docs], ); const handleResolveUseLocal = useCallback( async (id: string) => { - setSyncingResumeId(id); + const target = docs.find((doc) => doc.id === id); + if (!target) return; + + setSyncingDocumentId(id); - const result = await resolveConflictUseLocal(id); + const result = await resolveDocumentConflictUseLocal(target.type, id); toast[result.ok ? "success" : "error"](result.message); - setSyncingResumeId(null); + setSyncingDocumentId(null); bump(); }, - [bump], + [bump, docs], ); return { @@ -203,18 +217,18 @@ export function useDocumentsWorkspace() { handleResolveUseLocal, handleSyncNow, isDeleting, - resumeTarget, + syncDetailsTarget, setActiveTab, setActiveType, setDeleteTarget, - setShareTargetId, + setShareTarget, setSortMode, setSyncDetailsTargetId, setViewMode, - shareTargetId, + shareTarget, shareTargetTitle, sortMode, - syncingResumeId, + syncingDocumentId, syncTargetTelemetry, syncTelemetryById, totalCount, diff --git a/apps/studio/app/(main)/(dashboard)/documents/workspace.tsx b/apps/studio/app/(main)/(dashboard)/documents/workspace.tsx index f9eac5e..95bcf49 100644 --- a/apps/studio/app/(main)/(dashboard)/documents/workspace.tsx +++ b/apps/studio/app/(main)/(dashboard)/documents/workspace.tsx @@ -5,12 +5,13 @@ import { Grid2X2, LayoutList } from "lucide-react"; import { Card, Select } from "@veriworkly/ui"; import DestructiveModal from "@/components/modals/DestructiveModal"; -import ShareResumeModal from "@/components/modals/ShareResumeModal"; +import ShareDocumentModal from "@/components/modals/ShareDocumentModal"; import SyncDetailsModal from "@/components/modals/SyncDetailsModal"; -import { IconToggle, TabButton } from "./components/ResumeWorkspaceControls"; -import { ResumeWorkspaceEmptyPanel } from "./components/ResumeWorkspaceEmptyPanel"; -import { ResumeListRow, ResumePreviewCard } from "./components/ResumeWorkspaceItems"; +import { DocumentListRow } from "./components/DocumentListRow"; +import { DocumentPreviewCard } from "./components/DocumentPreviewCard"; +import { IconToggle, TabButton } from "./components/DocumentWorkspaceControls"; +import { DocumentWorkspaceEmptyState } from "./components/DocumentWorkspaceEmptyState"; import { type SortMode, @@ -30,19 +31,19 @@ export default function DocumentsWorkspace() { handleResolveUseLocal, isDeleting, deleteTarget, - resumeTarget, + syncDetailsTarget, setSortMode, setViewMode, setActiveTab, setActiveType, setDeleteTarget, - setShareTargetId, + setShareTarget, setSyncDetailsTargetId, sortMode, viewMode, - shareTargetId, + shareTarget, shareTargetTitle, - syncingResumeId, + syncingDocumentId, syncTelemetryById, syncTargetTelemetry, totalCount, @@ -52,19 +53,19 @@ export default function DocumentsWorkspace() { return (
-

Resumes

+

Documents

-

Resume library

+

Document library

- Saved resumes with sync state, sharing, and quick actions. + Saved resumes, cover letters, formal letters, and invoices.

- {totalCount} saved resume{totalCount === 1 ? "" : "s"} + {totalCount} saved document{totalCount === 1 ? "" : "s"}
@@ -92,8 +93,11 @@ export default function DocumentsWorkspace() { onChange={(event) => setActiveType(event.target.value as DocumentTypeFilter)} className="h-10 w-auto min-w-36 rounded-xl px-3 shadow-none" > - + + + +
) : ( activeLinks.map((link) => ( @@ -352,4 +395,4 @@ const ShareResumeModal = ({ resumeId, onClose }: ShareResumeModalProps) => { ); }; -export default ShareResumeModal; +export default ShareDocumentModal; diff --git a/apps/studio/components/modals/SyncDetailsModal.tsx b/apps/studio/components/modals/SyncDetailsModal.tsx index c9f7414..7b7aa7b 100644 --- a/apps/studio/components/modals/SyncDetailsModal.tsx +++ b/apps/studio/components/modals/SyncDetailsModal.tsx @@ -16,19 +16,19 @@ import { toast } from "sonner"; import { useRouter } from "next/navigation"; import { useEffect, useState } from "react"; -import type { ResumeListItem } from "@/features/resume/services/resume-service"; import type { ResumeSyncTelemetry } from "@/features/resume/services/resume-sync"; +import type { DocumentLibraryItem } from "@/features/documents/services/document-library"; import { cn } from "@/lib/utils"; import { Modal, Button } from "@veriworkly/ui"; -import { listResumeShareLinks } from "@/features/resume/services/share-links"; +import { listAllShareLinks } from "@/features/documents/services/share-service"; interface SyncDetailsModalProps { - resume: ResumeListItem; + document: DocumentLibraryItem; telemetry: ResumeSyncTelemetry | null; - syncingResumeId: string | null; + syncingDocumentId: string | null; onClose: () => void; onResolveUseLocal: (id: string) => void; onResolveUseCloud: (id: string) => void; @@ -37,9 +37,9 @@ interface SyncDetailsModalProps { } const SyncDetailsModal = ({ - resume, + document, telemetry, - syncingResumeId, + syncingDocumentId, onClose, onResolveUseLocal, onResolveUseCloud, @@ -48,15 +48,16 @@ const SyncDetailsModal = ({ }: SyncDetailsModalProps) => { const router = useRouter(); - const isSyncing = syncingResumeId === resume.id; - const isConflicted = resume.sync.status === "conflicted"; + const isSyncing = syncingDocumentId === document.id; + const isConflicted = document.sync.status === "conflicted"; + const editorHref = `/editor/${document.type.toLowerCase()}/${document.id}`; const [shareUrl, setShareUrl] = useState(null); useEffect(() => { let cancelled = false; - void listResumeShareLinks(resume.id) + void listAllShareLinks(document.id) .then((links) => { if (cancelled) return; const token = links[0]?.token; @@ -69,9 +70,9 @@ const SyncDetailsModal = ({ return () => { cancelled = true; }; - }, [resume.id]); + }, [document.id]); - if (!resume) return null; + if (!document) return null; const statusConfig = { synced: { @@ -108,16 +109,16 @@ const SyncDetailsModal = ({ disabled: { label: "Local only", - description: "Sync is disabled for this resume.", + description: "Sync is disabled for this document.", color: "text-zinc-500", bg: "md:bg-zinc-500/10", icon: CloudOff, }, }; - const currentStatus = !resume.sync.enabled + const currentStatus = !document.sync.enabled ? statusConfig.disabled - : statusConfig[resume.sync.status as keyof typeof statusConfig] || statusConfig.disabled; + : statusConfig[document.sync.status as keyof typeof statusConfig] || statusConfig.disabled; const StatusIcon = currentStatus.icon; @@ -176,12 +177,12 @@ const SyncDetailsModal = ({
- {resume.title} + {document.title}
); - if (item.type === "closing") return

{item.text}

; - - if (item.type === "signature") - return

{item.text}

; + if (item.type === "signoff") + return ( +
+ {item.closing ?

{item.closing}

: null} +

{item.signature}

+
+ ); return (

@@ -159,61 +175,33 @@ function renderFlowItem(item: VeriworklyFlowItem, accentColor: string) { } function renderGroupedFlowItems(items: VeriworklyFlowItem[], accentColor: string) { - const nodes: React.ReactNode[] = []; - let index = 0; - - while (index < items.length) { - const item = items[index]; - - if (item.type === "closing" || item.type === "signature") { - const group: VeriworklyFlowItem[] = []; - - while (items[index]?.type === "closing" || items[index]?.type === "signature") { - group.push(items[index]); - index += 1; - } - - nodes.push( -

- {group.map((entry) => ( -
- {renderFlowItem(entry, accentColor)} -
- ))} -
, - ); - continue; - } - - nodes.push( -
- {renderFlowItem(item, accentColor)} -
, - ); - index += 1; - } - - return nodes; + return items.map((item) => ( +
+ {renderFlowItem(item, accentColor)} +
+ )); } -function paginateMeasuredItems( - items: T[], +function paginateMeasuredItems( + items: VeriworklyFlowItem[], heights: Map, firstLimit: number, nextLimit: number, ) { - const pages: T[][] = [[]]; + const pages: VeriworklyFlowItem[][] = [[]]; let pageIndex = 0; let used = 0; - for (const item of items) { + for (let itemIndex = 0; itemIndex < items.length; itemIndex += 1) { + const item = items[itemIndex]; const height = Math.ceil(heights.get(item.id) ?? 0); + const nextItem = items[itemIndex + 1]; + const keepWithNextHeight = + item.type === "proof-heading" && nextItem ? Math.ceil(heights.get(nextItem.id) ?? 0) : 0; const limit = pageIndex === 0 ? firstLimit : nextLimit; + const effectiveLimit = item.type === "postscript" ? limit : limit + PAGE_FIT_TOLERANCE; - if (pages[pageIndex].length > 0 && used + height > limit) { + if (pages[pageIndex].length > 0 && used + height + keepWithNextHeight > effectiveLimit) { pages.push([]); pageIndex += 1; used = 0; @@ -227,12 +215,15 @@ function paginateMeasuredItems( } function getVeriworklyHtmlItemWeight(item: VeriworklyFlowItem) { - if (item.type === "body-list" || item.type === "proof-list") { + if (item.type === "body-list") { return 2 + item.items.reduce((total, listItem) => total + Math.ceil(listItem.length / 70), 0); } + if (item.type === "proof-heading") return 2; + if (item.type === "proof-item") return Math.max(1, Math.ceil(item.text.length / 70)); if (item.type === "postscript") return 2 + Math.ceil(item.text.length / 100); - if (item.type === "closing" || item.type === "signature" || item.type === "greeting") return 1; + if (item.type === "signoff") return item.closing ? 2 : 1; + if (item.type === "greeting") return 1; return Math.max(1, Math.ceil(item.text.length / 82)); } @@ -242,11 +233,15 @@ function paginateVeriworklyHtmlItems(items: VeriworklyFlowItem[]) { let pageIndex = 0; let used = 0; - for (const item of items) { - const limit = pageIndex === 0 ? 14 : 22; + for (let itemIndex = 0; itemIndex < items.length; itemIndex += 1) { + const item = items[itemIndex]; + const limit = pageIndex === 0 ? 24 : 24; const weight = getVeriworklyHtmlItemWeight(item); + const nextItem = items[itemIndex + 1]; + const keepWithNextWeight = + item.type === "proof-heading" && nextItem ? getVeriworklyHtmlItemWeight(nextItem) : 0; - if (pages[pageIndex].length > 0 && used + weight > limit) { + if (pages[pageIndex].length > 0 && used + weight + keepWithNextWeight > limit) { pages.push([]); pageIndex += 1; used = 0; @@ -265,16 +260,15 @@ function renderVeriworklyHtmlItem(item: VeriworklyFlowItem, accentColor: string) if (item.type === "body-list") { return `
    ${item.items.map((listItem) => `
  • ${escapeHtml(listItem)}
  • `).join("")}
`; } - if (item.type === "proof-list") { - return `

Selected Proof

${item.items - .map( - (proof, proofIndex) => - `
${String(proofIndex + 1).padStart(2, "0")}

${escapeHtml(proof)}

`, - ) - .join("")}
`; + if (item.type === "proof-heading") { + return `

Selected Proof

`; + } + if (item.type === "proof-item") { + return `
${String(item.index + 1).padStart(2, "0")}

${escapeHtml(item.text)}

`; + } + if (item.type === "signoff") { + return `
${item.closing ? `

${escapeHtml(item.closing)}

` : ""}

${escapeHtml(item.signature)}

`; } - if (item.type === "closing") return `

${escapeHtml(item.text)}

`; - if (item.type === "signature") return `

${escapeHtml(item.text)}

`; return `

P.S. ${escapeHtml(item.text)}

`; } @@ -470,27 +464,7 @@ export function VeriworklyCoverLetterPreview({ content }: { content: CoverLetter
-
-

- Cover Letter Continued -

- -
-

- Cover Letter -

- -

- {content.subject || content.jobTitle || "Application"} -

-
-
+
- ) : ( - <> -

- Cover Letter Continued -

- -
-

- Cover Letter -

- -

- {content.subject || content.jobTitle || "Application"} -

-
- - )} + ) : null}
${escapeHtml(content.senderName || "Cover Letter")}${pages +*{box-sizing:border-box}body{margin:0;padding:32px 16px;background:#f4f4f5;color:${appearance.textColor};font-family:${fontFamily}}.page{width:794px;height:1123px;margin:0 auto 24px;overflow:hidden;background:${appearance.pageColor};color:${appearance.textColor};box-shadow:0 0 0 1px #e4e4e7;page-break-after:always}.page:last-child{page-break-after:auto}p{margin:0 0 ${appearance.paragraphSpacing}px;line-height:${appearance.lineHeight}}.label{color:${appearance.accentColor};font-size:10px;font-weight:700;letter-spacing:.22em;text-transform:uppercase}.body{margin-top:32px;font-size:14.5px}.body-list{background:#f4f4f5;margin:16px 0;padding:14px 18px 14px 28px;line-height:${appearance.lineHeight}}.proof{border-top:1px solid #e2e8f0;margin-top:32px;padding-top:24px}.proof-label{color:#64748b;font-size:10px;font-weight:700;letter-spacing:.2em;text-transform:uppercase}.proof+.proof-item{margin-top:16px}.proof-item{display:grid;grid-template-columns:28px 1fr;gap:12px;border-bottom:1px solid #f1f5f9;padding:0 0 8px;margin-bottom:8px}.proof-item.last{border-bottom:0}.proof-item span{font-size:12px;font-weight:700;line-height:20px}.proof-item p{font-size:14px;color:#334155;line-height:1.45}.signoff{margin-top:32px}.signature{font-size:16px;font-weight:700;color:#0f172a}.postscript{border-top:1px solid #e4e4e7;padding-top:14px;color:#52525b}.veri-page{display:grid;grid-template-columns:214px 1fr}.veri-page aside{background:${appearance.sidebarColor};color:#0f172a;border-right:1px solid #e2e8f0;padding:36px 28px;display:flex;flex-direction:column}.veri-page h1{margin:16px 0 0;font-size:26px;line-height:1.08}.veri-page .muted,.veri-page .rail,.veri-page .target p{color:#475569}.veri-page .rule{width:48px;height:1px;background:${appearance.accentColor};margin-top:40px}.veri-page .rail{margin-top:32px;font-size:12px}.veri-page .rail a{display:block;color:${appearance.accentColor};font-weight:600;text-underline-offset:4px}.veri-page .target{border-top:1px solid #e2e8f0;margin-top:auto;padding-top:28px}.veri-page main{padding:${appearance.pageMargin}px;background:#fff}.veri-page .meta{display:flex;justify-content:space-between;gap:24px;border-bottom:1px solid #e2e8f0;padding-bottom:28px;color:#475569}.veri-page .subject{border-left:2px solid ${appearance.accentColor};margin-top:32px;padding:4px 0 4px 20px}.veri-page h2{margin:8px 0 0;font-size:22px}@media print{body{padding:0;background:white}.page{box-shadow:none;margin:0}}${pages .map((blocks, pageIndex) => { const first = pageIndex === 0; const body = blocks .map((item) => renderVeriworklyHtmlItem(item, appearance.accentColor)) .join(""); - return `
${first ? `
${recipient.map((line) => `

${escapeHtml(line)}

`).join("")}

${escapeHtml(content.date)}

Cover Letter

${subject}

` : `

Cover Letter Continued

Cover Letter

${subject}

`}
${body}
`; + return `
${first ? `
${recipient.map((line) => `

${escapeHtml(line)}

`).join("")}

${escapeHtml(content.date)}

Cover Letter

${subject}

` : ""}
${body}
`; }) .join("")}`; }