From 7f699aaa47d3adafbde3b40a1e33568a0807c548 Mon Sep 17 00:00:00 2001 From: Gautam25Raj Date: Wed, 20 May 2026 01:52:11 +0530 Subject: [PATCH] feat: refactor layout and navigation components for improved user experience - Updated metadata settings to prevent indexing by search engines. - Changed AdminNavbar link from "/dashboard" to "/" for better clarity. - Introduced AccountMenu component for user account management with theme toggle. - Added NewDocumentModal and NewDocumentButton for creating new documents. - Implemented StudioNavigation and StudioShell for enhanced navigation structure. - Created WorkspaceSearchModal for searching saved resumes. - Established resume workspace services for managing resume data. - Removed deprecated font stylesheet preload and font constants. - Enhanced resume data normalization and store utilities for better data handling. --- apps/server/src/auth/runtime.ts | 31 +- .../src/controllers/healthController.ts | 23 +- apps/server/src/routes/health.ts | 1 + apps/server/src/utils/prisma.ts | 18 +- .../(dashboard)/components/OverviewHome.tsx | 98 +++++ .../components/OverviewHomeHeader.tsx | 56 +++ .../components/OverviewReferenceCard.tsx | 61 ++++ .../(dashboard)/components/RecentCard.tsx | 42 +++ .../components/DashboardWorkspace.tsx | 317 ---------------- .../dashboard/components/EmptyState.tsx | 29 -- .../dashboard/components/ResumeCard.tsx | 180 --------- .../dashboard/components/ResumeCardMenu.tsx | 97 ----- .../dashboard/components/ResumeGrid.tsx | 58 --- .../dashboard/components/WorkspaceHeader.tsx | 33 -- .../dashboard/components/resume-card-utils.ts | 79 ---- .../app/(main)/(dashboard)/dashboard/page.tsx | 14 - .../components/ResumeWorkspaceControls.tsx | 54 +++ .../components/ResumeWorkspaceEmptyPanel.tsx | 21 ++ .../components/ResumeWorkspaceItems.tsx | 342 ++++++++++++++++++ .../app/(main)/(dashboard)/documents/page.tsx | 13 + .../documents/useDocumentsWorkspace.ts | 224 ++++++++++++ .../(dashboard)/documents/workspace.tsx | 200 ++++++++++ apps/studio/app/(main)/(dashboard)/layout.tsx | 17 +- apps/studio/app/(main)/(dashboard)/page.tsx | 8 +- apps/studio/app/(main)/editor/[id]/page.tsx | 32 -- .../app/(main)/editor/[id]/preview/page.tsx | 32 -- .../app/(main)/editor/[type]/[id]/page.tsx | 31 ++ .../[id]/preview/PreviewClient.tsx | 19 +- .../editor/[type]/[id]/preview/error.tsx | 39 ++ .../editor/[type]/[id]/preview/loading.tsx | 10 + .../editor/[type]/[id]/preview/not-found.tsx | 37 ++ .../editor/[type]/[id]/preview/page.tsx | 36 ++ .../(main)/editor/components/EditorLayout.tsx | 2 +- .../app/(main)/editor/components/Toolbar.tsx | 4 +- .../content/EditorFormPrimitives.tsx | 55 +++ .../content/sections/BasicsSection.tsx | 134 +++---- apps/studio/app/layout.tsx | 11 +- apps/studio/app/login/component/OtpForm.tsx | 1 + apps/studio/components/admin/AdminNavbar.tsx | 2 +- .../components/dashboard/AccountMenu.tsx | 167 +++++++++ .../components/dashboard/NewDocumentModal.tsx | 112 ++++++ .../components/dashboard/StudioNavigation.tsx | 157 ++++++++ .../components/dashboard/StudioShell.tsx | 247 +++++++++++++ .../dashboard/WorkspaceSearchModal.tsx | 139 +++++++ .../api-keys/components/ApiKeyRotateModal.tsx | 13 +- .../documents/services/resume-workspace.ts | 129 +++++++ .../ResumeFontStylesheetPreload.tsx | 13 - .../features/resume/constants/resume-fonts.ts | 119 ------ .../features/resume/hooks/use-resume.ts | 2 + .../features/resume/hooks/use-sections.ts | 1 + .../resume/store/resume-store-utils.ts | 25 ++ .../features/resume/store/resume-store.ts | 25 +- .../features/resume/utils/normalize-data.ts | 13 +- 53 files changed, 2452 insertions(+), 1171 deletions(-) create mode 100644 apps/studio/app/(main)/(dashboard)/components/OverviewHome.tsx create mode 100644 apps/studio/app/(main)/(dashboard)/components/OverviewHomeHeader.tsx create mode 100644 apps/studio/app/(main)/(dashboard)/components/OverviewReferenceCard.tsx create mode 100644 apps/studio/app/(main)/(dashboard)/components/RecentCard.tsx delete mode 100644 apps/studio/app/(main)/(dashboard)/dashboard/components/DashboardWorkspace.tsx delete mode 100644 apps/studio/app/(main)/(dashboard)/dashboard/components/EmptyState.tsx delete mode 100644 apps/studio/app/(main)/(dashboard)/dashboard/components/ResumeCard.tsx delete mode 100644 apps/studio/app/(main)/(dashboard)/dashboard/components/ResumeCardMenu.tsx delete mode 100644 apps/studio/app/(main)/(dashboard)/dashboard/components/ResumeGrid.tsx delete mode 100644 apps/studio/app/(main)/(dashboard)/dashboard/components/WorkspaceHeader.tsx delete mode 100644 apps/studio/app/(main)/(dashboard)/dashboard/components/resume-card-utils.ts delete mode 100644 apps/studio/app/(main)/(dashboard)/dashboard/page.tsx create mode 100644 apps/studio/app/(main)/(dashboard)/documents/components/ResumeWorkspaceControls.tsx create mode 100644 apps/studio/app/(main)/(dashboard)/documents/components/ResumeWorkspaceEmptyPanel.tsx create mode 100644 apps/studio/app/(main)/(dashboard)/documents/components/ResumeWorkspaceItems.tsx create mode 100644 apps/studio/app/(main)/(dashboard)/documents/page.tsx create mode 100644 apps/studio/app/(main)/(dashboard)/documents/useDocumentsWorkspace.ts create mode 100644 apps/studio/app/(main)/(dashboard)/documents/workspace.tsx delete mode 100644 apps/studio/app/(main)/editor/[id]/page.tsx delete mode 100644 apps/studio/app/(main)/editor/[id]/preview/page.tsx create mode 100644 apps/studio/app/(main)/editor/[type]/[id]/page.tsx rename apps/studio/app/(main)/editor/{ => [type]}/[id]/preview/PreviewClient.tsx (93%) create mode 100644 apps/studio/app/(main)/editor/[type]/[id]/preview/error.tsx create mode 100644 apps/studio/app/(main)/editor/[type]/[id]/preview/loading.tsx create mode 100644 apps/studio/app/(main)/editor/[type]/[id]/preview/not-found.tsx create mode 100644 apps/studio/app/(main)/editor/[type]/[id]/preview/page.tsx create mode 100644 apps/studio/components/dashboard/AccountMenu.tsx create mode 100644 apps/studio/components/dashboard/NewDocumentModal.tsx create mode 100644 apps/studio/components/dashboard/StudioNavigation.tsx create mode 100644 apps/studio/components/dashboard/StudioShell.tsx create mode 100644 apps/studio/components/dashboard/WorkspaceSearchModal.tsx create mode 100644 apps/studio/features/documents/services/resume-workspace.ts delete mode 100644 apps/studio/features/resume/components/ResumeFontStylesheetPreload.tsx delete mode 100644 apps/studio/features/resume/constants/resume-fonts.ts create mode 100644 apps/studio/features/resume/store/resume-store-utils.ts diff --git a/apps/server/src/auth/runtime.ts b/apps/server/src/auth/runtime.ts index 79ecd3b..bbbb46c 100644 --- a/apps/server/src/auth/runtime.ts +++ b/apps/server/src/auth/runtime.ts @@ -35,16 +35,29 @@ export function validateAuthRuntimeConfig(): void { export async function ensureAdminUserExists(): Promise { const email = config.admin.email; - await prisma.user.upsert({ + const existing = await prisma.user.findUnique({ where: { email }, - update: { - name: "Admin", - }, - create: { - email, - name: "Admin", - }, + select: { id: true, name: true }, }); - logger.info("Admin user ensured for auth", { email }); + if (!existing) { + await prisma.user.create({ + data: { + email, + name: "Admin", + }, + }); + + logger.info("Admin user created for auth", { email }); + return; + } + + if (existing.name !== "Admin") { + await prisma.user.update({ + where: { id: existing.id }, + data: { name: "Admin" }, + }); + + logger.info("Admin user normalized for auth", { email }); + } } diff --git a/apps/server/src/controllers/healthController.ts b/apps/server/src/controllers/healthController.ts index d51243e..3733028 100644 --- a/apps/server/src/controllers/healthController.ts +++ b/apps/server/src/controllers/healthController.ts @@ -7,15 +7,32 @@ import { createErrorResponse, createSuccessResponse } from "#utils/errors"; export class HealthController { /** - * Comprehensive health check endpoint. - * Verifies connectivity to the database and Redis. + * Lightweight liveness endpoint. + * Does not touch external dependencies, so uptime checks do not wake database compute. + */ + + static async check(_req: Request, res: Response) { + res.json( + createSuccessResponse( + { + status: "ok", + timestamp: new Date().toISOString(), + }, + "Server is healthy", + ), + ); + } + + /** + * Comprehensive readiness endpoint. + * Verifies connectivity to the database and Redis for manual/deploy-time checks. * * @param req Express request * @param res Express response * @param next Express next function */ - static async check(req: Request, res: Response) { + static async ready(_req: Request, res: Response) { try { await prisma.$queryRaw`SELECT 1`; diff --git a/apps/server/src/routes/health.ts b/apps/server/src/routes/health.ts index 1d1588c..93391f9 100644 --- a/apps/server/src/routes/health.ts +++ b/apps/server/src/routes/health.ts @@ -5,5 +5,6 @@ import { HealthController } from "#controllers/healthController"; const router = Router(); router.get("/", HealthController.check); +router.get("/ready", HealthController.ready); export default router; diff --git a/apps/server/src/utils/prisma.ts b/apps/server/src/utils/prisma.ts index b80baa8..6e8a18e 100644 --- a/apps/server/src/utils/prisma.ts +++ b/apps/server/src/utils/prisma.ts @@ -1,8 +1,22 @@ +import pg from "pg"; import { PrismaClient } from "@prisma/client"; import { PrismaPg } from "@prisma/adapter-pg"; -import pg from "pg"; -const pool = new pg.Pool({ connectionString: process.env.DATABASE_URL }); +function parsePositiveInt(value: string | undefined, fallback: number) { + const parsed = Number.parseInt(value ?? "", 10); + return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback; +} + +const isProduction = process.env.NODE_ENV === "production"; + +const pool = new pg.Pool({ + connectionString: process.env.DATABASE_URL, + max: parsePositiveInt(process.env.DB_POOL_MAX, isProduction ? 1 : 5), + idleTimeoutMillis: parsePositiveInt(process.env.DB_POOL_IDLE_TIMEOUT_MS, 10_000), + connectionTimeoutMillis: parsePositiveInt(process.env.DB_POOL_CONNECTION_TIMEOUT_MS, 10_000), + allowExitOnIdle: true, +}); + const adapter = new PrismaPg(pool); const globalForPrisma = globalThis as unknown as { prisma: PrismaClient }; diff --git a/apps/studio/app/(main)/(dashboard)/components/OverviewHome.tsx b/apps/studio/app/(main)/(dashboard)/components/OverviewHome.tsx new file mode 100644 index 0000000..1a893d4 --- /dev/null +++ b/apps/studio/app/(main)/(dashboard)/components/OverviewHome.tsx @@ -0,0 +1,98 @@ +"use client"; + +import type { LucideIcon } from "lucide-react"; + +import Link from "next/link"; +import { useSyncExternalStore } from "react"; +import { BookOpen, FolderOpen, ArrowRight, BriefcaseBusiness } from "lucide-react"; + +import { Card } from "@veriworkly/ui"; + +import { + getResumeWorkspaceSnapshot, + subscribeToResumeWorkspace, + RESUME_WORKSPACE_SERVER_SNAPSHOT, +} from "@/features/documents/services/resume-workspace"; + +import RecentCard from "./RecentCard"; +import OverviewHomeHeader from "./OverviewHomeHeader"; +import OverviewReferenceCard from "./OverviewReferenceCard"; + +function MiniLink({ href, icon: Icon, label }: { href: string; icon: LucideIcon; label: string }) { + return ( + + + {label} + + + ); +} + +const OverviewHome = () => { + const snapshot = useSyncExternalStore( + subscribeToResumeWorkspace, + () => getResumeWorkspaceSnapshot(), + () => RESUME_WORKSPACE_SERVER_SNAPSHOT, + ); + + const totalCount = snapshot.counts.RESUME; + const resumeCount = snapshot.counts.RESUME; + const recentDocs = snapshot.docs.slice(0, 6); + + return ( +
+ + + + + +
+
+
+
+

Recently opened

+

Compact view, not another full resume page.

+
+ + + All resumes + +
+ +
+ {recentDocs.length > 0 ? ( + recentDocs.map((doc) => ) + ) : ( +
+

No files yet

+ +

+ Use New Document in sidebar to create first resume. +

+
+ )} +
+
+ + +
+
+
+ ); +}; + +export default OverviewHome; diff --git a/apps/studio/app/(main)/(dashboard)/components/OverviewHomeHeader.tsx b/apps/studio/app/(main)/(dashboard)/components/OverviewHomeHeader.tsx new file mode 100644 index 0000000..c94f846 --- /dev/null +++ b/apps/studio/app/(main)/(dashboard)/components/OverviewHomeHeader.tsx @@ -0,0 +1,56 @@ +"use client"; + +import { useMemo } from "react"; + +import { useUserStore } from "@/store/useUserStore"; + +function Stat({ label, value }: { label: string; value: number }) { + return ( +
+

{value}

+

{label}

+
+ ); +} + +const OverviewHomeHeader = ({ + totalCount, + resumeCount, +}: { + totalCount: number; + resumeCount: number; +}) => { + const user = useUserStore((state) => state.user); + + const firstName = useMemo(() => { + const name = user?.name || user?.email?.split("@")[0] || "builder"; + return name.split(" ")[0] || "builder"; + }, [user]); + + return ( +
+
+
+

Overview

+ +

+ Good morning, {firstName}. +

+ +

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

+
+ +
+ + + +
+
+
+ ); +}; + +export default OverviewHomeHeader; diff --git a/apps/studio/app/(main)/(dashboard)/components/OverviewReferenceCard.tsx b/apps/studio/app/(main)/(dashboard)/components/OverviewReferenceCard.tsx new file mode 100644 index 0000000..1ae0880 --- /dev/null +++ b/apps/studio/app/(main)/(dashboard)/components/OverviewReferenceCard.tsx @@ -0,0 +1,61 @@ +import Link from "next/link"; +import { BookOpen, FolderOpen, HelpCircle, ArrowRight } from "lucide-react"; + +import { siteConfig } from "@/config/site"; + +const referenceCards = [ + { + href: `${siteConfig.links.docs}/docs`, + title: "Studio docs", + text: "Export, sharing, sync, and editor basics.", + icon: BookOpen, + }, + + { + href: "/documents", + title: "Resume library", + text: "Open saved resumes with sync, sharing, and list view.", + icon: FolderOpen, + }, + + { + href: `${siteConfig.links.main}/faq`, + title: "FAQ", + text: "Fast answers for account and document workflow.", + icon: HelpCircle, + }, +]; + +const OverviewReferenceCard = () => { + return ( +
+ {referenceCards.map((item) => { + const Icon = item.icon; + + return ( + +
+ + + + + +
+ +

{item.title}

+ +

{item.text}

+ + ); + })} +
+ ); +}; + +export default OverviewReferenceCard; diff --git a/apps/studio/app/(main)/(dashboard)/components/RecentCard.tsx b/apps/studio/app/(main)/(dashboard)/components/RecentCard.tsx new file mode 100644 index 0000000..6518c98 --- /dev/null +++ b/apps/studio/app/(main)/(dashboard)/components/RecentCard.tsx @@ -0,0 +1,42 @@ +"use client"; + +import Link from "next/link"; +import Image from "next/image"; +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"; + +const RecentCard = ({ doc }: { doc: ResumeWorkspaceDoc }) => { + const definition = getDocumentDefinition(doc.type); + + return ( + +
+ {doc.previewImage ? ( + + ) : ( +
+ +
+ )} +
+ +
+
+

{doc.title}

+ {definition.label} +
+ +

{doc.description || doc.templateName}

+
+ + ); +}; + +export default RecentCard; diff --git a/apps/studio/app/(main)/(dashboard)/dashboard/components/DashboardWorkspace.tsx b/apps/studio/app/(main)/(dashboard)/dashboard/components/DashboardWorkspace.tsx deleted file mode 100644 index a2ad610..0000000 --- a/apps/studio/app/(main)/(dashboard)/dashboard/components/DashboardWorkspace.tsx +++ /dev/null @@ -1,317 +0,0 @@ -"use client"; - -import { toast } from "sonner"; -import { useRouter } from "next/navigation"; -import { useMemo, useState, useEffect, useCallback, useSyncExternalStore } from "react"; - -import { - syncResumeNow, - keepResumeLocalOnly, - startResumeSyncWorker, - getResumeSyncTelemetry, - resolveConflictUseCloud, - resolveConflictUseLocal, - getResumeSyncTelemetryByIds, - RESUME_SYNC_OUTBOX_UPDATED_EVENT, - hydrateCloudResumesToLocalStorage, -} from "@/features/resume/services/resume-sync"; -import { - type ResumeListItem, - createResume, - deleteResumeById, -} from "@/features/resume/services/resume-service"; -import { DocumentApi } from "@/features/documents/services/document-api"; -import { listSavedResumes } from "@/features/resume/services/resume-core"; -import { trackUsageEvent } from "@/features/analytics/services/usage-metrics"; -import { RESUME_STORAGE_UPDATED_EVENT } from "@/features/resume/services/local-storage"; - -import { RESUME_ACTIVE_ID_STORAGE_KEY, RESUME_COLLECTION_STORAGE_KEY } from "@/lib/constants"; - -import { useUserStore } from "@/store/useUserStore"; - -import ResumeGrid from "./ResumeGrid"; -import WorkspaceHeader from "./WorkspaceHeader"; - -import DestructiveModal from "@/components/modals/DestructiveModal"; -import ShareResumeModal from "@/components/modals/ShareResumeModal"; -import SyncDetailsModal from "@/components/modals/SyncDetailsModal"; - -const EMPTY_RESUMES: ResumeListItem[] = []; -let resumeCache = { data: EMPTY_RESUMES, key: "" }; -let resumeItemCacheById = new Map(); -const SYNC_OUTBOX_STORAGE_KEY = "veriworkly:resume-sync-outbox"; - -const isSameResumeListItem = (left: ResumeListItem, right: ResumeListItem) => - left.id === right.id && - left.title === right.title && - left.templateId === right.templateId && - left.role === right.role && - left.updatedAt === right.updatedAt && - left.sync.enabled === right.sync.enabled && - left.sync.status === right.sync.status && - left.sync.cloudDocumentId === right.sync.cloudDocumentId && - left.sync.lastSyncedAt === right.sync.lastSyncedAt; - -const subscribe = (onStoreChange: () => void) => { - if (typeof window === "undefined") return () => {}; - - window.addEventListener("storage", onStoreChange); - window.addEventListener(RESUME_STORAGE_UPDATED_EVENT, onStoreChange); - window.addEventListener(RESUME_SYNC_OUTBOX_UPDATED_EVENT, onStoreChange); - - return () => { - window.removeEventListener("storage", onStoreChange); - window.removeEventListener(RESUME_STORAGE_UPDATED_EVENT, onStoreChange); - window.removeEventListener(RESUME_SYNC_OUTBOX_UPDATED_EVENT, onStoreChange); - }; -}; - -const getResumeSnapshot = () => { - if (typeof window === "undefined") { - return resumeCache.data; - } - - const storage = window.localStorage; - - const nextKey = [ - storage.getItem(RESUME_COLLECTION_STORAGE_KEY) ?? "", - storage.getItem(RESUME_ACTIVE_ID_STORAGE_KEY) ?? "", - storage.getItem(SYNC_OUTBOX_STORAGE_KEY) ?? "", - ].join("::"); - - if (nextKey !== resumeCache.key) { - const nextList = listSavedResumes(); - const nextItemCacheById = new Map(); - - const stabilizedList = nextList.map((item) => { - const cachedItem = resumeItemCacheById.get(item.id); - - if (cachedItem && isSameResumeListItem(cachedItem, item)) { - nextItemCacheById.set(item.id, cachedItem); - return cachedItem; - } - - nextItemCacheById.set(item.id, item); - return item; - }); - - resumeItemCacheById = nextItemCacheById; - resumeCache = { - data: stabilizedList, - key: nextKey, - }; - } - - return resumeCache.data; -}; - -const DashboardWorkspace = () => { - const router = useRouter(); - - const isLoggedIn = useUserStore((state) => state.isLoggedIn); - - const [shareTargetId, setShareTargetId] = useState(null); - const [deleteTargetId, setDeleteTargetId] = useState(null); - const [syncDetailsTargetId, setSyncDetailsTargetId] = useState(null); - - const [isDeleting, setIsDeleting] = useState(false); - const [isRefreshingCloud, setIsRefreshingCloud] = useState(false); - const [syncingResumeId, setSyncingResumeId] = useState(null); - - const resumes = useSyncExternalStore(subscribe, getResumeSnapshot, () => EMPTY_RESUMES); - - useEffect(() => { - if (!isLoggedIn) return; - - startResumeSyncWorker({ enabled: true, idleDelayMs: 12_000 }); - - void hydrateCloudResumesToLocalStorage({ minIntervalMs: 2 * 60 * 1000 }); - }, [isLoggedIn]); - - useEffect(() => { - trackUsageEvent({ event: "dashboard_opened" }); - }, []); - - const deleteTarget = useMemo( - () => resumes.find((r) => r.id === deleteTargetId), - [resumes, deleteTargetId], - ); - - const shareTarget = useMemo( - () => resumes.find((r) => r.id === shareTargetId), - [resumes, shareTargetId], - ); - - const syncTarget = useMemo( - () => resumes.find((r) => r.id === syncDetailsTargetId), - [resumes, syncDetailsTargetId], - ); - - const syncTargetTelemetry = useMemo( - () => (syncTarget ? getResumeSyncTelemetry(syncTarget.id) : null), - [syncTarget], - ); - - const syncTelemetryById = useMemo( - () => getResumeSyncTelemetryByIds(resumes.map((resume) => resume.id)), - [resumes], - ); - - const handleOpen = useCallback( - (id: string) => { - router.push(`/editor/${id}`); - }, - [router], - ); - - const handleCreate = useCallback(() => { - const nextResume = createResume(); - - trackUsageEvent({ event: "resume_created" }); - - router.push(`/editor/${nextResume.id}`); - }, [router]); - - const handleRefreshCloud = useCallback(async () => { - setIsRefreshingCloud(true); - - try { - const result = await hydrateCloudResumesToLocalStorage({ force: true }); - toast.info(result.message); - } finally { - setIsRefreshingCloud(false); - } - }, []); - - const handleConfirmDelete = useCallback(async () => { - if (!deleteTargetId) return; - - setIsDeleting(true); - - try { - if (deleteTarget?.sync.cloudDocumentId) { - await DocumentApi.delete(deleteTargetId); - } - - deleteResumeById(deleteTargetId); - trackUsageEvent({ event: "resume_deleted" }); - - toast.success("Resume deleted successfully"); - } catch { - toast.error("Failed to delete resume from cloud. Please try again."); - } finally { - setIsDeleting(false); - setDeleteTargetId(null); - } - }, [deleteTargetId, deleteTarget]); - - const handleSyncNow = useCallback(async (resumeId: string) => { - setSyncingResumeId(resumeId); - - const result = await syncResumeNow(resumeId); - - if (result.ok) { - toast.success(result.message); - } else { - toast.error(result.message); - } - - trackUsageEvent({ - event: result.ok ? "resume_sync_success" : "resume_sync_failed", - }); - - setSyncingResumeId(null); - }, []); - - const handleKeepLocalOnly = useCallback((resumeId: string) => { - const result = keepResumeLocalOnly(resumeId); - - toast.info(result.message); - - trackUsageEvent({ - event: result.ok ? "resume_sync_local_only" : "resume_sync_local_only_failed", - }); - - setSyncDetailsTargetId(null); - }, []); - - const handleResolveUseLocal = useCallback(async (resumeId: string) => { - setSyncingResumeId(resumeId); - const result = await resolveConflictUseLocal(resumeId); - - if (result.ok) { - toast.success(result.message); - } else { - toast.error(result.message); - } - - setSyncingResumeId(null); - }, []); - - const handleResolveUseCloud = useCallback(async (resumeId: string) => { - setSyncingResumeId(resumeId); - - const result = await resolveConflictUseCloud(resumeId); - - if (result.ok) { - toast.success(result.message); - } else { - toast.error(result.message); - } - - setSyncingResumeId(null); - }, []); - - return ( -
- - - - - setDeleteTargetId(null)} - loading={isDeleting} - entityName={deleteTarget?.title ?? "resume"} - /> - - {shareTargetId && ( - setShareTargetId(null)} - /> - )} - - {syncDetailsTargetId && syncTarget && ( - setSyncDetailsTargetId(null)} - /> - )} -
- ); -}; - -export default DashboardWorkspace; diff --git a/apps/studio/app/(main)/(dashboard)/dashboard/components/EmptyState.tsx b/apps/studio/app/(main)/(dashboard)/dashboard/components/EmptyState.tsx deleted file mode 100644 index dd2795e..0000000 --- a/apps/studio/app/(main)/(dashboard)/dashboard/components/EmptyState.tsx +++ /dev/null @@ -1,29 +0,0 @@ -import { Card, Button } from "@veriworkly/ui"; - -const EmptyState = ({ onCreate }: { onCreate: () => void }) => { - return ( - -
- 📄 -
- -

No resumes yet

- -

- Start by creating your first professional resume. It only takes a few minutes. -

- - -
- ); -}; - -export default EmptyState; diff --git a/apps/studio/app/(main)/(dashboard)/dashboard/components/ResumeCard.tsx b/apps/studio/app/(main)/(dashboard)/dashboard/components/ResumeCard.tsx deleted file mode 100644 index c9cd9c0..0000000 --- a/apps/studio/app/(main)/(dashboard)/dashboard/components/ResumeCard.tsx +++ /dev/null @@ -1,180 +0,0 @@ -"use client"; - -import Link from "next/link"; -import { memo } from "react"; -import { CalendarClock, FileText, Palette } from "lucide-react"; - -import type { ResumeSyncTelemetry } from "@/features/resume/services/resume-sync"; - -import { Card, Badge, Button } from "@veriworkly/ui"; - -import { templateSummaries } from "@/config/templates"; - -import { ResumeListItem } from "@/features/resume/services/resume-service"; - -import ResumeCardMenu from "./ResumeCardMenu"; -import { getSyncTone, getSyncLabel, getSyncActivityLabel } from "./resume-card-utils"; - -interface ResumeCardProps { - resume: ResumeListItem; - syncTelemetry: ResumeSyncTelemetry | null; - syncing: boolean; - onOpen: (id: string) => void; - onShare: (id: string) => void; - onSyncNow: (id: string) => void; - onSyncDetails: (id: string) => void; - onDelete: (id: string) => void; -} - -const ResumeCard = ({ - resume, - syncTelemetry, - syncing, - onOpen, - onShare, - onSyncNow, - onSyncDetails, - onDelete, -}: ResumeCardProps) => { - const template = - templateSummaries.find((t) => t.id === resume.templateId) ?? templateSummaries[0]; - - const dateObj = new Date(resume.updatedAt); - const isValidDate = !isNaN(dateObj.getTime()); - - return ( -
- - -
- -
- -
-
-
-

- {resume.title} -

- -

- {resume.role || "Role not set"} -

-
- - - {getSyncLabel(resume.sync)} - -
- -
-
- - - - Template: {template.name} - -
- -
- - - - Resume: Ready - -
-
- -
- - Activity - - -

- {getSyncActivityLabel(resume.sync, syncTelemetry)} -

-
- -

- - {isValidDate - ? dateObj.toLocaleString(undefined, { - dateStyle: "medium", - timeStyle: "short", - }) - : "Updated recently"} -

-
- - - -
- onOpen(resume.id)} - onShare={() => onShare(resume.id)} - onSyncNow={() => onSyncNow(resume.id)} - onSyncDetails={() => onSyncDetails(resume.id)} - /> -
- - {resume.sync.status === "conflicted" ? ( -
- -
- ) : null} -
- ); -}; - -const isSameTelemetry = (left: ResumeSyncTelemetry | null, right: ResumeSyncTelemetry | null) => { - if (left === right) { - return true; - } - - if (!left || !right) { - return left === right; - } - - return ( - left.lastAttemptAt === right.lastAttemptAt && - left.lastSuccessAt === right.lastSuccessAt && - left.lastErrorAt === right.lastErrorAt && - left.lastErrorMessage === right.lastErrorMessage - ); -}; - -const areResumeCardPropsEqual = (previous: ResumeCardProps, next: ResumeCardProps) => { - return ( - previous.syncing === next.syncing && - previous.resume === next.resume && - isSameTelemetry(previous.syncTelemetry, next.syncTelemetry) - ); -}; - -export default memo(ResumeCard, areResumeCardPropsEqual); diff --git a/apps/studio/app/(main)/(dashboard)/dashboard/components/ResumeCardMenu.tsx b/apps/studio/app/(main)/(dashboard)/dashboard/components/ResumeCardMenu.tsx deleted file mode 100644 index 1f0a136..0000000 --- a/apps/studio/app/(main)/(dashboard)/dashboard/components/ResumeCardMenu.tsx +++ /dev/null @@ -1,97 +0,0 @@ -"use client"; - -import { Eye, MoreVertical, RefreshCw, Share2, Trash2 } from "lucide-react"; - -import { Menu, MenuItem, MenuSeparator, Button } from "@veriworkly/ui"; - -interface ResumeCardMenuProps { - resumeTitle: string; - resumeId: string; - syncing: boolean; - hasConflict: boolean; - onOpen: () => void; - onShare: () => void; - onSyncNow: () => void; - onSyncDetails: () => void; - onDelete: (id: string) => void; -} - -const ResumeCardMenu = ({ - resumeTitle, - resumeId, - syncing, - hasConflict, - onOpen, - onShare, - onSyncNow, - onSyncDetails, - onDelete, -}: ResumeCardMenuProps) => { - return ( - ( - - )} - > - {({ close }) => { - const handleAction = (action: () => void) => (event: React.MouseEvent) => { - event.preventDefault(); - close(); - action(); - }; - - return ( - <> - - - {hasConflict ? "Resolve Conflict" : "Open Resume"} - - - - - Share Resume - - - - - {syncing ? "Syncing..." : "Sync Now"} - - - - - View Sync Details - - - - - onDelete(resumeId))} - > - - Delete - - - ); - }} - - ); -}; - -export default ResumeCardMenu; diff --git a/apps/studio/app/(main)/(dashboard)/dashboard/components/ResumeGrid.tsx b/apps/studio/app/(main)/(dashboard)/dashboard/components/ResumeGrid.tsx deleted file mode 100644 index d9df680..0000000 --- a/apps/studio/app/(main)/(dashboard)/dashboard/components/ResumeGrid.tsx +++ /dev/null @@ -1,58 +0,0 @@ -"use client"; - -import { memo } from "react"; - -import type { ResumeSyncTelemetry } from "@/features/resume/services/resume-sync"; - -import ResumeCard from "./ResumeCard"; -import EmptyState from "./EmptyState"; - -import { ResumeListItem } from "@/features/resume/services/resume-service"; - -interface GridProps { - resumes: ResumeListItem[]; - syncTelemetryById: Record; - onOpen: (id: string) => void; - onShare: (id: string) => void; - onSyncNow: (id: string) => void; - onSyncDetails: (id: string) => void; - syncingResumeId: string | null; - onDelete: (id: string) => void; - onCreate: () => void; -} - -const ResumeGrid = ({ - resumes, - syncTelemetryById, - onOpen, - onShare, - onSyncNow, - onSyncDetails, - syncingResumeId, - onDelete, - onCreate, -}: GridProps) => { - if (resumes.length === 0) { - return ; - } - - return ( -
- {resumes.map((resume) => ( - - ))} -
- ); -}; - -export default memo(ResumeGrid); diff --git a/apps/studio/app/(main)/(dashboard)/dashboard/components/WorkspaceHeader.tsx b/apps/studio/app/(main)/(dashboard)/dashboard/components/WorkspaceHeader.tsx deleted file mode 100644 index c40f0c4..0000000 --- a/apps/studio/app/(main)/(dashboard)/dashboard/components/WorkspaceHeader.tsx +++ /dev/null @@ -1,33 +0,0 @@ -import { Card, Button } from "@veriworkly/ui"; - -interface HeaderProps { - onCreate: () => void; - onRefresh: () => void; - refreshing: boolean; -} - -const WorkspaceHeader = ({ onCreate, onRefresh, refreshing }: HeaderProps) => ( - -
-

- Resume Workspace -

- -

Your resumes

- -

Create, share, and manage your resume drafts.

-
- -
- - - -
-
-); - -export default WorkspaceHeader; diff --git a/apps/studio/app/(main)/(dashboard)/dashboard/components/resume-card-utils.ts b/apps/studio/app/(main)/(dashboard)/dashboard/components/resume-card-utils.ts deleted file mode 100644 index db8ad24..0000000 --- a/apps/studio/app/(main)/(dashboard)/dashboard/components/resume-card-utils.ts +++ /dev/null @@ -1,79 +0,0 @@ -import type { ResumeSyncTelemetry } from "@/features/resume/services/resume-sync"; - -import { ResumeListItem } from "@/features/resume/services/resume-service"; - -export function formatRelativeSyncTime(value: string | null): string { - if (!value) return "none"; - - const date = new Date(value); - - if (Number.isNaN(date.getTime())) return "unknown"; - - const elapsedMs = Date.now() - date.getTime(); - const elapsedMinutes = Math.floor(elapsedMs / 60_000); - - if (elapsedMinutes < 1) return "just now"; - if (elapsedMinutes < 60) return `${elapsedMinutes}m ago`; - - const elapsedHours = Math.floor(elapsedMinutes / 60); - if (elapsedHours < 24) return `${elapsedHours}h ago`; - - const elapsedDays = Math.floor(elapsedHours / 24); - - return `${elapsedDays}d ago`; -} - -export function getSyncActivityLabel( - sync: ResumeListItem["sync"], - telemetry: ResumeSyncTelemetry | null, -): string { - if (!sync.enabled) return "Local only"; - - if (sync.status === "syncing") { - return `Syncing now${telemetry?.lastAttemptAt ? ` · ${formatRelativeSyncTime(telemetry.lastAttemptAt)}` : ""}`; - } - - if (sync.status === "conflicted") { - return `Conflict · ${telemetry?.lastErrorAt ? formatRelativeSyncTime(telemetry.lastErrorAt) : "needs review"}`; - } - - if (sync.status === "pending") { - return `Pending · ${telemetry?.lastAttemptAt ? formatRelativeSyncTime(telemetry.lastAttemptAt) : "queued"}`; - } - - if (sync.status === "synced") { - return `Synced · ${telemetry?.lastSuccessAt ? formatRelativeSyncTime(telemetry.lastSuccessAt) : "unknown"}`; - } - - return telemetry?.lastSuccessAt - ? `Last success ${formatRelativeSyncTime(telemetry.lastSuccessAt)}` - : "Ready to sync"; -} - -export function getSyncLabel(sync: ResumeListItem["sync"]): string { - if (!sync.enabled) return "Local only"; - - if (sync.status === "synced") return "Cloud synced"; - - if (sync.status === "syncing") return "Syncing"; - - if (sync.status === "conflicted") return "Sync conflict"; - - return "Sync pending"; -} - -export function getSyncTone(sync: ResumeListItem["sync"]): string { - if (!sync.enabled) - return "bg-zinc-500/10 text-zinc-700 border-zinc-300 dark:text-zinc-300 dark:border-zinc-700"; - - if (sync.status === "synced") - return "bg-emerald-500/10 text-emerald-700 border-emerald-300 dark:text-emerald-300 dark:border-emerald-700"; - - if (sync.status === "syncing") - return "bg-sky-500/10 text-sky-700 border-sky-300 dark:text-sky-300 dark:border-sky-700"; - - if (sync.status === "conflicted") - return "bg-red-500/10 text-red-700 border-red-300 dark:text-red-300 dark:border-red-700"; - - return "bg-amber-500/10 text-amber-700 border-amber-300 dark:text-amber-300 dark:border-amber-700"; -} diff --git a/apps/studio/app/(main)/(dashboard)/dashboard/page.tsx b/apps/studio/app/(main)/(dashboard)/dashboard/page.tsx deleted file mode 100644 index a7545a5..0000000 --- a/apps/studio/app/(main)/(dashboard)/dashboard/page.tsx +++ /dev/null @@ -1,14 +0,0 @@ -import type { Metadata } from "next"; -import { redirect } from "next/navigation"; - -export const metadata: Metadata = { - title: "Dashboard Redirect", - description: "Legacy dashboard route redirect.", - robots: { index: false, follow: false }, -}; - -const DashboardRouteCompatibilityPage = () => { - redirect("/"); -}; - -export default DashboardRouteCompatibilityPage; diff --git a/apps/studio/app/(main)/(dashboard)/documents/components/ResumeWorkspaceControls.tsx b/apps/studio/app/(main)/(dashboard)/documents/components/ResumeWorkspaceControls.tsx new file mode 100644 index 0000000..18c6b9b --- /dev/null +++ b/apps/studio/app/(main)/(dashboard)/documents/components/ResumeWorkspaceControls.tsx @@ -0,0 +1,54 @@ +"use client"; + +import type { ReactNode } from "react"; + +export function TabButton({ + active, + children, + onClick, +}: { + active: boolean; + children: ReactNode; + onClick: () => void; +}) { + return ( + + ); +} + +export function IconToggle({ + active, + label, + children, + onClick, +}: { + active: boolean; + label: string; + children: ReactNode; + onClick: () => void; +}) { + return ( + + ); +} diff --git a/apps/studio/app/(main)/(dashboard)/documents/components/ResumeWorkspaceEmptyPanel.tsx b/apps/studio/app/(main)/(dashboard)/documents/components/ResumeWorkspaceEmptyPanel.tsx new file mode 100644 index 0000000..b167f2a --- /dev/null +++ b/apps/studio/app/(main)/(dashboard)/documents/components/ResumeWorkspaceEmptyPanel.tsx @@ -0,0 +1,21 @@ +import { FileText } from "lucide-react"; + +export function ResumeWorkspaceEmptyPanel({ activeTab }: { activeTab: "recent" | "shared" }) { + return ( +
+
+ +
+ +

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

+ +

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

+
+ ); +} diff --git a/apps/studio/app/(main)/(dashboard)/documents/components/ResumeWorkspaceItems.tsx b/apps/studio/app/(main)/(dashboard)/documents/components/ResumeWorkspaceItems.tsx new file mode 100644 index 0000000..eb0e605 --- /dev/null +++ b/apps/studio/app/(main)/(dashboard)/documents/components/ResumeWorkspaceItems.tsx @@ -0,0 +1,342 @@ +"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/page.tsx b/apps/studio/app/(main)/(dashboard)/documents/page.tsx new file mode 100644 index 0000000..060a58b --- /dev/null +++ b/apps/studio/app/(main)/(dashboard)/documents/page.tsx @@ -0,0 +1,13 @@ +import type { Metadata } from "next"; + +import DocumentsWorkspace from "./workspace"; + +export const metadata: Metadata = { + title: "Resumes", + description: "Manage saved resumes in one workspace.", + robots: { index: false, follow: false }, +}; + +export default function DocumentsPage() { + return ; +} diff --git a/apps/studio/app/(main)/(dashboard)/documents/useDocumentsWorkspace.ts b/apps/studio/app/(main)/(dashboard)/documents/useDocumentsWorkspace.ts new file mode 100644 index 0000000..ac3bc06 --- /dev/null +++ b/apps/studio/app/(main)/(dashboard)/documents/useDocumentsWorkspace.ts @@ -0,0 +1,224 @@ +"use client"; + +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"; +import { + type ResumeWorkspaceDoc, + getResumeWorkspaceSnapshot, + subscribeToResumeWorkspace, + RESUME_WORKSPACE_SERVER_SNAPSHOT, +} from "@/features/documents/services/resume-workspace"; +import { DocumentApi } from "@/features/documents/services/document-api"; +import { deleteResumeById } from "@/features/resume/services/resume-service"; +import { listResumeShareLinks } from "@/features/resume/services/share-links"; + +export type ViewMode = "grid" | "list"; +export type SortMode = "updated" | "title"; +export type ActiveTab = "recent" | "shared"; +export type DocumentTypeFilter = ResumeWorkspaceDoc["type"] | "ALL"; + +export function useDocumentsWorkspace() { + const [viewMode, setViewMode] = useState("grid"); + const [sortMode, setSortMode] = useState("updated"); + + const [activeTab, setActiveTab] = useState("recent"); + const [activeType, setActiveType] = useState("ALL"); + + const [shareTargetId, setShareTargetId] = useState(null); + const [sharedResumeIds, setSharedResumeIds] = useState>(new Set()); + + const [refreshKey, setRefreshKey] = useState(0); + const [syncingResumeId, setSyncingResumeId] = useState(null); + const [syncDetailsTargetId, setSyncDetailsTargetId] = useState(null); + + const [isDeleting, setIsDeleting] = useState(false); + const [deleteTarget, setDeleteTarget] = useState(null); + + const snapshot = useSyncExternalStore( + subscribeToResumeWorkspace, + () => getResumeWorkspaceSnapshot(activeType, refreshKey), + () => RESUME_WORKSPACE_SERVER_SNAPSHOT, + ); + + const { docs, counts } = snapshot; + const totalCount = counts.RESUME; + + const bump = useCallback(() => setRefreshKey((key) => key + 1), []); + + useEffect(() => { + let cancelled = false; + + if (docs.length === 0) { + queueMicrotask(() => { + if (!cancelled) setSharedResumeIds(new Set()); + }); + + return () => { + cancelled = true; + }; + } + + void Promise.all( + docs.map(async (doc) => { + try { + const links = await listResumeShareLinks(doc.id); + return links.length > 0 ? doc.id : null; + } catch { + return null; + } + }), + ).then((ids) => { + if (!cancelled) { + setSharedResumeIds(new Set(ids.filter((id): id is string => Boolean(id)))); + } + }); + + return () => { + cancelled = true; + }; + }, [docs]); + + const visibleDocs = useMemo(() => { + const tabDocs = + activeTab === "shared" ? docs.filter((doc) => sharedResumeIds.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]); + + const resumeTarget = useMemo( + () => docs.find((doc) => doc.id === syncDetailsTargetId)?.resume ?? null, + [docs, syncDetailsTargetId], + ); + + const shareTargetTitle = useMemo( + () => docs.find((doc) => doc.id === shareTargetId)?.title, + [docs, shareTargetId], + ); + + const syncTelemetryById = useMemo( + () => getResumeSyncTelemetryByIds(docs.map((doc) => doc.id)), + [docs], + ); + + const syncTargetTelemetry = useMemo( + () => (resumeTarget ? getResumeSyncTelemetry(resumeTarget.id) : null), + [resumeTarget], + ); + + const handleSyncNow = useCallback( + async (resumeId: string) => { + setSyncingResumeId(resumeId); + + const result = await syncResumeNow(resumeId); + + toast[result.ok ? "success" : "error"](result.message); + + setSyncingResumeId(null); + bump(); + }, + [bump], + ); + + const handleConfirmDelete = useCallback(async () => { + if (!deleteTarget) return; + + setIsDeleting(true); + + try { + if (deleteTarget.sync.cloudDocumentId) await DocumentApi.delete(deleteTarget.id); + + deleteResumeById(deleteTarget.id); + + toast.success(`${deleteTarget.title} deleted`); + + setDeleteTarget(null); + bump(); + } catch { + toast.error("Delete failed. Please try again."); + } finally { + setIsDeleting(false); + } + }, [bump, deleteTarget]); + + const handleKeepLocalOnly = useCallback( + (id: string) => { + const result = keepResumeLocalOnly(id); + + toast.info(result.message); + + setSyncDetailsTargetId(null); + bump(); + }, + [bump], + ); + + const handleResolveUseCloud = useCallback( + async (id: string) => { + setSyncingResumeId(id); + + const result = await resolveConflictUseCloud(id); + + toast[result.ok ? "success" : "error"](result.message); + + setSyncingResumeId(null); + bump(); + }, + [bump], + ); + + const handleResolveUseLocal = useCallback( + async (id: string) => { + setSyncingResumeId(id); + + const result = await resolveConflictUseLocal(id); + + toast[result.ok ? "success" : "error"](result.message); + + setSyncingResumeId(null); + bump(); + }, + [bump], + ); + + return { + activeTab, + activeType, + counts, + deleteTarget, + handleConfirmDelete, + handleKeepLocalOnly, + handleResolveUseCloud, + handleResolveUseLocal, + handleSyncNow, + isDeleting, + resumeTarget, + setActiveTab, + setActiveType, + setDeleteTarget, + setShareTargetId, + setSortMode, + setSyncDetailsTargetId, + setViewMode, + shareTargetId, + shareTargetTitle, + sortMode, + syncingResumeId, + syncTargetTelemetry, + syncTelemetryById, + totalCount, + viewMode, + visibleDocs, + }; +} diff --git a/apps/studio/app/(main)/(dashboard)/documents/workspace.tsx b/apps/studio/app/(main)/(dashboard)/documents/workspace.tsx new file mode 100644 index 0000000..f9eac5e --- /dev/null +++ b/apps/studio/app/(main)/(dashboard)/documents/workspace.tsx @@ -0,0 +1,200 @@ +"use client"; + +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 SyncDetailsModal from "@/components/modals/SyncDetailsModal"; + +import { IconToggle, TabButton } from "./components/ResumeWorkspaceControls"; +import { ResumeWorkspaceEmptyPanel } from "./components/ResumeWorkspaceEmptyPanel"; +import { ResumeListRow, ResumePreviewCard } from "./components/ResumeWorkspaceItems"; + +import { + type SortMode, + type DocumentTypeFilter, + useDocumentsWorkspace, +} from "./useDocumentsWorkspace"; + +export default function DocumentsWorkspace() { + const { + counts, + activeTab, + activeType, + handleSyncNow, + handleConfirmDelete, + handleKeepLocalOnly, + handleResolveUseCloud, + handleResolveUseLocal, + isDeleting, + deleteTarget, + resumeTarget, + setSortMode, + setViewMode, + setActiveTab, + setActiveType, + setDeleteTarget, + setShareTargetId, + setSyncDetailsTargetId, + sortMode, + viewMode, + shareTargetId, + shareTargetTitle, + syncingResumeId, + syncTelemetryById, + syncTargetTelemetry, + totalCount, + visibleDocs, + } = useDocumentsWorkspace(); + + return ( +
+
+

Resumes

+ +
+
+

Resume library

+ +

+ Saved resumes with sync state, sharing, and quick actions. +

+
+ +
+ {totalCount} saved resume{totalCount === 1 ? "" : "s"} +
+
+
+ + +
+
+ setActiveTab("recent")}> + Recently opened + + + setActiveTab("shared")}> + Shared files + +
+ +
+ + + + + + + + +
+ setViewMode("grid")} + > + + + + setViewMode("list")} + > + + +
+
+
+ + {visibleDocs.length > 0 ? ( + viewMode === "grid" ? ( +
+ {visibleDocs.map((doc) => ( + + ))} +
+ ) : ( +
+ {visibleDocs.map((doc) => ( + + ))} +
+ ) + ) : ( + + )} +
+ + setDeleteTarget(null)} + entityName={deleteTarget?.title ?? "resume"} + /> + + {shareTargetId ? ( + setShareTargetId(null)} + /> + ) : null} + + {resumeTarget ? ( + setSyncDetailsTargetId(null)} + /> + ) : null} +
+ ); +} diff --git a/apps/studio/app/(main)/(dashboard)/layout.tsx b/apps/studio/app/(main)/(dashboard)/layout.tsx index 306f10d..3ed2ca3 100644 --- a/apps/studio/app/(main)/(dashboard)/layout.tsx +++ b/apps/studio/app/(main)/(dashboard)/layout.tsx @@ -1,22 +1,9 @@ import type { ReactNode } from "react"; -import { Container } from "@veriworkly/ui"; - -import Footer from "@/components/layout/Footer"; -import Navbar from "@/components/layout/Navbar"; +import StudioShell from "@/components/dashboard/StudioShell"; const DashboardLayout = ({ children }: { children: ReactNode }) => { - return ( -
- - - - {children} - - -
-
- ); + return {children}; }; export default DashboardLayout; diff --git a/apps/studio/app/(main)/(dashboard)/page.tsx b/apps/studio/app/(main)/(dashboard)/page.tsx index 9113e41..ed5e794 100644 --- a/apps/studio/app/(main)/(dashboard)/page.tsx +++ b/apps/studio/app/(main)/(dashboard)/page.tsx @@ -1,15 +1,15 @@ import type { Metadata } from "next"; -import DashboardWorkspace from "./dashboard/components/DashboardWorkspace"; +import OverviewHome from "./components/OverviewHome"; export const metadata: Metadata = { - title: "Dashboard", - description: "Manage your resumes, edit drafts, and export professional documents in seconds.", + title: "Overview", + description: "Studio overview for recent work and document actions.", robots: { index: false, follow: false }, }; const StudioDashboardHomePage = () => { - return ; + return ; }; export default StudioDashboardHomePage; diff --git a/apps/studio/app/(main)/editor/[id]/page.tsx b/apps/studio/app/(main)/editor/[id]/page.tsx deleted file mode 100644 index 68ee36e..0000000 --- a/apps/studio/app/(main)/editor/[id]/page.tsx +++ /dev/null @@ -1,32 +0,0 @@ -import type { Metadata } from "next"; -import { notFound } from "next/navigation"; - -import EditorLayout from "@/app/(main)/editor/components/EditorLayout"; - -function isValidResumeRouteId(id: string) { - return id.length > 0 && /^[a-z0-9-]+$/i.test(id); -} - -export async function generateMetadata({ - params, -}: { - params: Promise<{ id: string }>; -}): Promise { - const { id } = await params; - - return { - title: `Editor - ${id}`, - description: "Edit resume content, formatting, and section settings in VeriWorkly Studio.", - robots: { index: false, follow: false }, - }; -} - -export default async function EditorByIdPage({ params }: { params: Promise<{ id: string }> }) { - const { id } = await params; - - if (!isValidResumeRouteId(id)) { - notFound(); - } - - return ; -} diff --git a/apps/studio/app/(main)/editor/[id]/preview/page.tsx b/apps/studio/app/(main)/editor/[id]/preview/page.tsx deleted file mode 100644 index c4c07b2..0000000 --- a/apps/studio/app/(main)/editor/[id]/preview/page.tsx +++ /dev/null @@ -1,32 +0,0 @@ -import type { Metadata } from "next"; -import { notFound } from "next/navigation"; - -import { PreviewClient } from "./PreviewClient"; - -function isValidResumeRouteId(id: string) { - return id.length > 0 && /^[a-z0-9-]+$/i.test(id); -} - -export async function generateMetadata({ - params, -}: { - params: Promise<{ id: string }>; -}): Promise { - const { id } = await params; - - return { - title: `Preview - ${id}`, - description: "Preview your resume in print-ready format before export or sharing.", - robots: { index: false, follow: false }, - }; -} - -export default async function EditorPreviewPage({ params }: { params: Promise<{ id: string }> }) { - const { id } = await params; - - if (!isValidResumeRouteId(id)) { - notFound(); - } - - return ; -} diff --git a/apps/studio/app/(main)/editor/[type]/[id]/page.tsx b/apps/studio/app/(main)/editor/[type]/[id]/page.tsx new file mode 100644 index 0000000..046c60e --- /dev/null +++ b/apps/studio/app/(main)/editor/[type]/[id]/page.tsx @@ -0,0 +1,31 @@ +import type { Metadata } from "next"; + +import { notFound } from "next/navigation"; + +import { getDocumentDefinition } from "@/features/documents/core/registry"; + +interface Params { + type: string; + id: string; +} + +export async function generateMetadata({ params }: { params: Promise }): Promise { + const { type, id } = await params; + return { + title: `Editor - ${type} - ${id}`, + description: "Edit documents in VeriWorkly Studio.", + robots: { index: false, follow: false }, + }; +} + +export default async function EditorByTypePage({ params }: { params: Promise }) { + const { type, id } = await params; + const normalizedType = type.toUpperCase(); + + if (normalizedType !== "RESUME") { + notFound(); + } + + const Editor = getDocumentDefinition("RESUME").Editor; + return ; +} diff --git a/apps/studio/app/(main)/editor/[id]/preview/PreviewClient.tsx b/apps/studio/app/(main)/editor/[type]/[id]/preview/PreviewClient.tsx similarity index 93% rename from apps/studio/app/(main)/editor/[id]/preview/PreviewClient.tsx rename to apps/studio/app/(main)/editor/[type]/[id]/preview/PreviewClient.tsx index dfa2975..742b6cb 100644 --- a/apps/studio/app/(main)/editor/[id]/preview/PreviewClient.tsx +++ b/apps/studio/app/(main)/editor/[type]/[id]/preview/PreviewClient.tsx @@ -23,10 +23,7 @@ export function PreviewClient({ resumeId }: PreviewClientProps) { const routeResume = useMemo(() => loadResumeById(resumeId), [resumeId]); useEffect(() => { - if (!routeResume) { - return; - } - + if (!routeResume) return; setResume(routeResume); }, [routeResume, setResume]); @@ -35,9 +32,7 @@ export function PreviewClient({ resumeId }: PreviewClientProps) { const nextTemplate = loadTemplateComponentById(resume.templateId); queueMicrotask(() => { - if (!cancelled) { - setTemplateComponent(() => nextTemplate); - } + if (!cancelled) setTemplateComponent(() => nextTemplate); }); return () => { @@ -54,7 +49,6 @@ export function PreviewClient({ resumeId }: PreviewClientProps) {

Resume Preview

-

{resume.basics.fullName || "Untitled Resume"}

@@ -62,14 +56,13 @@ export function PreviewClient({ resumeId }: PreviewClientProps) {
Back to editor - Dashboard @@ -80,15 +73,13 @@ export function PreviewClient({ resumeId }: PreviewClientProps) { {!routeResume ? (

Resume not found

-

This resume may have been deleted. Return to dashboard to pick another one.

-
Go to dashboard diff --git a/apps/studio/app/(main)/editor/[type]/[id]/preview/error.tsx b/apps/studio/app/(main)/editor/[type]/[id]/preview/error.tsx new file mode 100644 index 0000000..625f387 --- /dev/null +++ b/apps/studio/app/(main)/editor/[type]/[id]/preview/error.tsx @@ -0,0 +1,39 @@ +"use client"; + +import { Button } from "@veriworkly/ui"; + +export default function EditorPreviewError({ + reset, +}: { + error: Error & { digest?: string }; + reset: () => void; +}) { + return ( +
+
+

+ Resume Preview +

+ +

+ We could not render this preview +

+ +

+ Try rendering preview again or return to editor route. +

+ +
+ + +
+
+ +
+
+
+
+ ); +} diff --git a/apps/studio/app/(main)/editor/[type]/[id]/preview/loading.tsx b/apps/studio/app/(main)/editor/[type]/[id]/preview/loading.tsx new file mode 100644 index 0000000..3be1e6b --- /dev/null +++ b/apps/studio/app/(main)/editor/[type]/[id]/preview/loading.tsx @@ -0,0 +1,10 @@ +export default function EditorPreviewLoading() { + return ( +
+
+
+
+
+
+ ); +} diff --git a/apps/studio/app/(main)/editor/[type]/[id]/preview/not-found.tsx b/apps/studio/app/(main)/editor/[type]/[id]/preview/not-found.tsx new file mode 100644 index 0000000..3149ab3 --- /dev/null +++ b/apps/studio/app/(main)/editor/[type]/[id]/preview/not-found.tsx @@ -0,0 +1,37 @@ +import Link from "next/link"; + +import { Button } from "@veriworkly/ui"; + +export default function EditorPreviewNotFound() { + return ( +
+
+

+ Resume Preview +

+ +

+ Preview not available +

+ +

+ This preview link is invalid or resume is no longer available in local storage. +

+ +
+ + + +
+
+ +
+
+
+
+ ); +} diff --git a/apps/studio/app/(main)/editor/[type]/[id]/preview/page.tsx b/apps/studio/app/(main)/editor/[type]/[id]/preview/page.tsx new file mode 100644 index 0000000..cc871dd --- /dev/null +++ b/apps/studio/app/(main)/editor/[type]/[id]/preview/page.tsx @@ -0,0 +1,36 @@ +import type { Metadata } from "next"; +import { notFound } from "next/navigation"; + +import { PreviewClient } from "./PreviewClient"; + +function isValidRouteId(id: string) { + return id.length > 0 && /^[a-z0-9-]+$/i.test(id); +} + +export async function generateMetadata({ + params, +}: { + params: Promise<{ type: string; id: string }>; +}): Promise { + const { type, id } = await params; + + return { + title: `Preview - ${type} - ${id}`, + description: "Preview your document before export or sharing.", + robots: { index: false, follow: false }, + }; +} + +export default async function EditorPreviewPage({ + params, +}: { + params: Promise<{ type: string; id: string }>; +}) { + const { type, id } = await params; + + if (!isValidRouteId(id)) notFound(); + + if (type.toLowerCase() !== "resume") notFound(); + + return ; +} diff --git a/apps/studio/app/(main)/editor/components/EditorLayout.tsx b/apps/studio/app/(main)/editor/components/EditorLayout.tsx index 855b6c3..bb85d58 100644 --- a/apps/studio/app/(main)/editor/components/EditorLayout.tsx +++ b/apps/studio/app/(main)/editor/components/EditorLayout.tsx @@ -103,7 +103,7 @@ const EditorLayout = ({ resumeId }: EditorLayoutProps) => { return () => { cancelled = true; }; - }, [hydrateFromStorage, resumeId, setResume]); + }, [hydrateFromStorage, resumeId, router, searchParams, setResume]); useEffect(() => { if (!hasHydratedRef.current) { diff --git a/apps/studio/app/(main)/editor/components/Toolbar.tsx b/apps/studio/app/(main)/editor/components/Toolbar.tsx index babf126..fdc765c 100644 --- a/apps/studio/app/(main)/editor/components/Toolbar.tsx +++ b/apps/studio/app/(main)/editor/components/Toolbar.tsx @@ -6,8 +6,8 @@ import { useRouter } from "next/navigation"; import ToolbarHeader from "@/app/(main)/editor/components/toolbar/ToolbarHeader"; import ToolbarActionsMenu from "@/app/(main)/editor/components/toolbar/ToolbarActionsMenu"; import ToolbarDownloadMenu from "@/app/(main)/editor/components/toolbar/ToolbarDownloadMenu"; -import ToolbarSecondaryActions from "@/app/(main)/editor/components/toolbar/ToolbarSecondaryActions"; import { useToolbarDownloads } from "@/app/(main)/editor/components/toolbar/useToolbarDownloads"; +import ToolbarSecondaryActions from "@/app/(main)/editor/components/toolbar/ToolbarSecondaryActions"; import { useResume } from "@/features/resume/hooks/use-resume"; import { saveResume, importResumeFromFile } from "@/features/resume/services/resume-service"; @@ -67,7 +67,7 @@ const Toolbar = ({ resumeId, resumePreviewId, onOpenShare, onOpenDelete }: Toolb return (
- router.push("/dashboard")} /> + router.push("/")} />
, "onChange" | "value"> & { + error?: string; + label: string; + onValueChange: (value: string) => void; + value: string; +}) { + return ( + + onValueChange(event.target.value)} + value={value} + /> + + ); +} + +export function CheckboxField({ + checked, + children, + className, + onCheckedChange, +}: { + checked: boolean; + children: ReactNode; + className?: string; + onCheckedChange: (checked: boolean) => void; +}) { + return ( + + ); +} + export function invalidClass(error?: string) { return error ? "border-red-500 focus:border-red-500 focus:ring-red-200" : undefined; } diff --git a/apps/studio/app/(main)/editor/components/content/sections/BasicsSection.tsx b/apps/studio/app/(main)/editor/components/content/sections/BasicsSection.tsx index acd01c3..1d27f5e 100644 --- a/apps/studio/app/(main)/editor/components/content/sections/BasicsSection.tsx +++ b/apps/studio/app/(main)/editor/components/content/sections/BasicsSection.tsx @@ -2,13 +2,11 @@ import type { BaseSectionProps } from "./section-types"; -import { Input } from "@veriworkly/ui"; - import { useResume } from "@/features/resume/hooks/use-resume"; import { validateBasics } from "@/features/resume/utils/validation"; import DraggableSection from "./DraggableSection"; -import { Field, invalidClass } from "../EditorFormPrimitives"; +import { CheckboxField, TextInputField } from "../EditorFormPrimitives"; const BasicsSection = ({ isOpen, @@ -33,90 +31,74 @@ const BasicsSection = ({ onDragStart={onDragStart} >
- - updateBasics({ fullName: event.target.value })} - value={resume.basics.fullName} - /> - + updateBasics({ fullName })} + value={resume.basics.fullName} + /> - - updateBasics({ role: event.target.value })} - value={resume.basics.role} - /> - + updateBasics({ role })} + value={resume.basics.role} + /> - - updateBasics({ headline: event.target.value })} - value={resume.basics.headline} - /> - + updateBasics({ headline })} + value={resume.basics.headline} + /> - - updateBasics({ email: event.target.value })} - type="email" - value={resume.basics.email} - /> - + updateBasics({ email })} + type="email" + value={resume.basics.email} + /> - - - updateBasics({ phone: event.target.value.replace(/\D/g, "").slice(0, 10) }) - } - pattern="[0-9]*" - placeholder="1234567890" - value={resume.basics.phone} - /> - + updateBasics({ phone: phone.replace(/\D/g, "").slice(0, 10) })} + pattern="[0-9]*" + placeholder="1234567890" + value={resume.basics.phone} + /> - - updateBasics({ location: event.target.value })} - value={resume.basics.location} - /> - + updateBasics({ location })} + value={resume.basics.location} + /> - + - + - +
); diff --git a/apps/studio/app/layout.tsx b/apps/studio/app/layout.tsx index a30f78b..b476563 100644 --- a/apps/studio/app/layout.tsx +++ b/apps/studio/app/layout.tsx @@ -6,9 +6,10 @@ import { siteConfig } from "@/config/site"; import { globalFontVariables } from "@veriworkly/ui"; -import { ThemeProvider } from "@/providers/theme-provider"; import { Toaster } from "@/components/Toaster"; +import { ThemeProvider } from "@/providers/theme-provider"; + export const metadata: Metadata = { metadataBase: new URL(siteConfig.url), @@ -63,11 +64,11 @@ export const metadata: Metadata = { }, robots: { - index: true, - follow: true, + index: false, + follow: false, googleBot: { - index: true, - follow: true, + index: false, + follow: false, "max-video-preview": -1, "max-image-preview": "large", "max-snippet": -1, diff --git a/apps/studio/app/login/component/OtpForm.tsx b/apps/studio/app/login/component/OtpForm.tsx index c1c3ec9..a4bc194 100644 --- a/apps/studio/app/login/component/OtpForm.tsx +++ b/apps/studio/app/login/component/OtpForm.tsx @@ -40,6 +40,7 @@ const OtpForm = ({ const { error: authError } = await authClient.signIn.emailOtp({ email: sentTo, otp: otp, + name: "Veriworkly User", }); if (authError) { diff --git a/apps/studio/components/admin/AdminNavbar.tsx b/apps/studio/components/admin/AdminNavbar.tsx index b052855..06594e4 100644 --- a/apps/studio/components/admin/AdminNavbar.tsx +++ b/apps/studio/components/admin/AdminNavbar.tsx @@ -4,7 +4,7 @@ const navItems = [ { href: "/admin", label: "Home" }, { href: "/admin/roadmap", label: "Roadmap" }, { href: "/admin/roadmap/new", label: "Create Item" }, - { href: "/dashboard", label: "Dashboard" }, + { href: "/", label: "Overview" }, ]; const AdminNavbar = () => { diff --git a/apps/studio/components/dashboard/AccountMenu.tsx b/apps/studio/components/dashboard/AccountMenu.tsx new file mode 100644 index 0000000..5d367a1 --- /dev/null +++ b/apps/studio/components/dashboard/AccountMenu.tsx @@ -0,0 +1,167 @@ +"use client"; + +import { useEffect, useRef, useState } from "react"; +import { ChevronDown, LogOut, Moon, Settings, Sun, User } from "lucide-react"; + +import { cn } from "@/lib/utils"; +import { useTheme } from "next-themes"; + +export function AccountMenu({ + collapsed, + displayName, + email, + version, + onProfile, + onSettings, + onLogout, +}: { + collapsed: boolean; + displayName: string; + email: string; + version: string; + onProfile: () => void; + onSettings: () => void; + onLogout: () => void; +}) { + const { resolvedTheme, setTheme } = useTheme(); + + const themeLabel = resolvedTheme === "dark" ? "Light mode" : "Dark mode"; + + const onToggleTheme = () => { + setTheme(resolvedTheme === "dark" ? "light" : "dark"); + }; + + const [open, setOpen] = useState(false); + const rootRef = useRef(null); + + useEffect(() => { + const handlePointerDown = (event: MouseEvent) => { + if (!rootRef.current?.contains(event.target as Node)) setOpen(false); + }; + + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === "Escape") setOpen(false); + }; + + document.addEventListener("mousedown", handlePointerDown); + document.addEventListener("keydown", handleKeyDown); + + return () => { + document.removeEventListener("mousedown", handlePointerDown); + document.removeEventListener("keydown", handleKeyDown); + }; + }, []); + + const close = () => setOpen(false); + + return ( +
+ {open ? ( +
+
+ + {displayName.slice(0, 1).toUpperCase()} + + + {displayName} + {email} + +
+ + { + close(); + onProfile(); + }} + /> + { + close(); + onSettings(); + }} + /> + { + close(); + onToggleTheme(); + }} + /> + { + close(); + await onLogout(); + }} + /> + +
+ {version} - Terms +
+
+ ) : null} + + +
+ ); +} + +function AccountMenuItem({ + danger, + icon: Icon, + label, + onClick, +}: { + danger?: boolean; + icon: typeof User; + label: string; + onClick: () => void; +}) { + return ( + + ); +} diff --git a/apps/studio/components/dashboard/NewDocumentModal.tsx b/apps/studio/components/dashboard/NewDocumentModal.tsx new file mode 100644 index 0000000..8bfc744 --- /dev/null +++ b/apps/studio/components/dashboard/NewDocumentModal.tsx @@ -0,0 +1,112 @@ +"use client"; + +import { FileText, Mail, Plus, ReceiptText, ScrollText, X } from "lucide-react"; + +import { Button, Modal } from "@veriworkly/ui"; + +import { getDocumentDefinition } from "@/features/documents/core/registry"; +import { DOCUMENT_TYPES, type DocumentType } from "@/features/documents/core/document-types"; + +const iconMap = { + RESUME: FileText, + COVER_LETTER: Mail, + FORMAL_LETTER: ScrollText, + INVOICE: ReceiptText, +} satisfies Record; + +export function NewDocumentButton({ + collapsed, + compact, + onClick, +}: { + collapsed?: boolean; + compact?: boolean; + onClick: () => void; +}) { + return ( + + ); +} + +export function NewDocumentModal({ + open, + onClose, + onCreate, +}: { + open: boolean; + onClose: () => void; + onCreate: (type: DocumentType) => void; +}) { + if (!open) return null; + + return ( + + +
+
+

Create

+ +

+ New document +

+ +

+ Choose document type. Template comes next in editor. +

+
+ + +
+ + + {DOCUMENT_TYPES.map((type) => { + const Icon = iconMap[type]; + const definition = getDocumentDefinition(type); + + return ( + + ); + })} + +
+
+ ); +} diff --git a/apps/studio/components/dashboard/StudioNavigation.tsx b/apps/studio/components/dashboard/StudioNavigation.tsx new file mode 100644 index 0000000..74e5022 --- /dev/null +++ b/apps/studio/components/dashboard/StudioNavigation.tsx @@ -0,0 +1,157 @@ +"use client"; + +import type { LucideIcon } from "lucide-react"; +import Link from "next/link"; +import { + BookOpen, + FileText, + FolderOpen, + HelpCircle, + Home, + KeyRound, + Newspaper, + Settings, +} from "lucide-react"; + +import { siteConfig } from "@/config/site"; +import { cn } from "@/lib/utils"; + +export interface StudioNavItem { + href: string; + label: string; + icon: LucideIcon; + external?: boolean; + match: (pathname: string) => boolean; +} + +export const mainNav: StudioNavItem[] = [ + { + href: "/", + label: "Overview", + icon: Home, + match: (pathname) => pathname === "/", + }, + + { + href: "/documents", + label: "Documents", + icon: FolderOpen, + match: (pathname) => pathname.startsWith("/documents"), + }, + + { + href: `${siteConfig.links.main}/templates`, + label: "Templates", + icon: FileText, + external: true, + match: () => false, + }, +]; + +export const supportNav: StudioNavItem[] = [ + { + href: siteConfig.links.docs, + label: "Docs", + icon: BookOpen, + external: true, + match: () => false, + }, + + { + href: siteConfig.links.blog, + label: "Blog", + icon: Newspaper, + external: true, + match: () => false, + }, +]; + +export const bottomNav: StudioNavItem[] = [ + { + href: `${siteConfig.links.main}/faq`, + label: "FAQ", + icon: HelpCircle, + external: true, + match: () => false, + }, + + { + href: "/api-keys", + label: "API keys", + icon: KeyRound, + match: (pathname) => pathname.startsWith("/api-keys"), + }, + + { + href: "/settings", + label: "Settings", + icon: Settings, + match: (pathname) => pathname.startsWith("/settings"), + }, +]; + +export function NavGroup({ + items, + pathname, + collapsed, + label, +}: { + items: StudioNavItem[]; + pathname: string; + collapsed: boolean; + label?: string; +}) { + return ( +
+ {label && !collapsed ? ( +

+ {label} +

+ ) : null} + + {items.map((item) => ( + + ))} +
+ ); +} + +export function StudioNavLink({ + item, + active, + collapsed, + onNavigate, +}: { + item: StudioNavItem; + active: boolean; + collapsed?: boolean; + onNavigate?: () => void; +}) { + const Icon = item.icon; + + return ( + +
{loadingDetails ? ( -
- -
+
+ +
) : detailError ? ( -
{detailError}
+
{detailError}
) : (
diff --git a/apps/studio/features/documents/services/resume-workspace.ts b/apps/studio/features/documents/services/resume-workspace.ts new file mode 100644 index 0000000..4c3d742 --- /dev/null +++ b/apps/studio/features/documents/services/resume-workspace.ts @@ -0,0 +1,129 @@ +"use client"; + +import { templateSummaries } from "@/config/templates"; +import { RESUME_ACTIVE_ID_STORAGE_KEY, RESUME_COLLECTION_STORAGE_KEY } from "@/lib/constants"; +import { type ResumeListItem } from "@/features/resume/services/resume-service"; +import { listSavedResumes } from "@/features/resume/services/resume-core"; +import { RESUME_STORAGE_UPDATED_EVENT } from "@/features/resume/services/local-storage"; +import { RESUME_SYNC_OUTBOX_UPDATED_EVENT } from "@/features/resume/services/resume-sync"; + +export type ResumeWorkspaceDoc = { + source: "resume"; + id: string; + type: "RESUME"; + title: string; + description: string; + templateId: string; + templateName: string; + templateDescription: string; + previewImage: string; + updatedAt: string; + sync: ResumeListItem["sync"]; + resume: ResumeListItem; +}; + +export type ResumeWorkspaceSnapshot = { + docs: ResumeWorkspaceDoc[]; + counts: Record; + key: string; +}; + +const EMPTY_COUNTS: Record = { RESUME: 0 }; + +export const RESUME_WORKSPACE_SERVER_SNAPSHOT: ResumeWorkspaceSnapshot = { + docs: [], + counts: EMPTY_COUNTS, + key: "server", +}; + +let snapshotCache: ResumeWorkspaceSnapshot = { + docs: [], + counts: EMPTY_COUNTS, + key: "", +}; + +export function subscribeToResumeWorkspace(onStoreChange: () => void) { + if (typeof window === "undefined") return () => {}; + + window.addEventListener("storage", onStoreChange); + window.addEventListener(RESUME_STORAGE_UPDATED_EVENT, onStoreChange); + window.addEventListener(RESUME_SYNC_OUTBOX_UPDATED_EVENT, onStoreChange); + + return () => { + window.removeEventListener("storage", onStoreChange); + window.removeEventListener(RESUME_STORAGE_UPDATED_EVENT, onStoreChange); + window.removeEventListener(RESUME_SYNC_OUTBOX_UPDATED_EVENT, onStoreChange); + }; +} + +export function getResumeWorkspaceSnapshot( + activeType: ResumeWorkspaceDoc["type"] | "ALL" = "ALL", + refreshKey = 0, +): ResumeWorkspaceSnapshot { + if (typeof window === "undefined") { + return RESUME_WORKSPACE_SERVER_SNAPSHOT; + } + + const storage = window.localStorage; + const storageKey = [ + storage.getItem(RESUME_COLLECTION_STORAGE_KEY) ?? "", + storage.getItem(RESUME_ACTIVE_ID_STORAGE_KEY) ?? "", + refreshKey.toString(), + ].join("::"); + const nextKey = `${activeType}::${storageKey}`; + + if (nextKey !== snapshotCache.key) { + const allDocs = listSavedResumes() + .map(mapResumeToWorkspaceDoc) + .sort((left, right) => Date.parse(right.updatedAt) - Date.parse(left.updatedAt)); + + const counts: Record = { + RESUME: allDocs.length, + }; + + snapshotCache = { + docs: activeType === "ALL" ? allDocs : allDocs.filter((doc) => doc.type === activeType), + counts, + key: nextKey, + }; + } + + return snapshotCache; +} + +export function mapResumeToWorkspaceDoc(resume: ResumeListItem): ResumeWorkspaceDoc { + const template = + templateSummaries.find((item) => item.id === resume.templateId) ?? templateSummaries[0]; + + return { + source: "resume", + id: resume.id, + type: "RESUME", + title: resume.title, + description: resume.role || "Role not set", + templateId: resume.templateId, + templateName: template?.name ?? "Resume template", + templateDescription: template?.description ?? "Resume layout", + previewImage: template?.previewImage ?? "", + updatedAt: resume.updatedAt, + sync: resume.sync, + resume, + }; +} + +export function formatRelative(value: string) { + const date = new Date(value); + if (Number.isNaN(date.getTime())) return "recently"; + + const diffMs = Date.now() - date.getTime(); + const minute = 60 * 1000; + const hour = 60 * minute; + const day = 24 * hour; + + if (diffMs < minute) return "just now"; + if (diffMs < hour) return `${Math.max(1, Math.round(diffMs / minute))}m ago`; + if (diffMs < day) return `${Math.round(diffMs / hour)}h ago`; + if (diffMs < 7 * day) return `${Math.round(diffMs / day)}d ago`; + + return date.toLocaleDateString(undefined, { month: "short", day: "numeric", year: "numeric" }); +} diff --git a/apps/studio/features/resume/components/ResumeFontStylesheetPreload.tsx b/apps/studio/features/resume/components/ResumeFontStylesheetPreload.tsx deleted file mode 100644 index 5920f4e..0000000 --- a/apps/studio/features/resume/components/ResumeFontStylesheetPreload.tsx +++ /dev/null @@ -1,13 +0,0 @@ -import { RESUME_EDITOR_FONT_STYLESHEET_HREFS } from "@/features/resume/constants/resume-fonts"; - -export function ResumeFontStylesheetPreload() { - return ( - <> - - - {RESUME_EDITOR_FONT_STYLESHEET_HREFS.map((href) => ( - - ))} - - ); -} diff --git a/apps/studio/features/resume/constants/resume-fonts.ts b/apps/studio/features/resume/constants/resume-fonts.ts deleted file mode 100644 index 374ddba..0000000 --- a/apps/studio/features/resume/constants/resume-fonts.ts +++ /dev/null @@ -1,119 +0,0 @@ -export const RESUME_FONT_IDS = ["geist", "modern", "inter"] as const; - -export type ResumeFontFamilyId = (typeof RESUME_FONT_IDS)[number]; - -type ResumeFontScope = "editor" | "on-demand"; - -type ResumePdfFontFace = { - src: string; - fontWeight: number; -}; - -export interface ResumeFontRegistryEntry { - id: ResumeFontFamilyId; - label: string; - primaryFamily: string; - fallbackStack: string; - stylesheetHref: string; - scope: ResumeFontScope; - pdfFonts: ResumePdfFontFace[]; -} - -const resumeFontDefinitions: ResumeFontRegistryEntry[] = [ - { - id: "geist", - label: "Geist Sans", - primaryFamily: "Geist", - fallbackStack: "Inter, 'Segoe UI', Arial, sans-serif", - stylesheetHref: - "https://fonts.googleapis.com/css2?family=Geist:wght@300;400;500;600;700;800&display=swap", - scope: "editor", - pdfFonts: [ - { src: "/fonts/geist/Geist-Regular.ttf", fontWeight: 400 }, - { src: "/fonts/geist/Geist-Bold.ttf", fontWeight: 700 }, - ], - }, - { - id: "modern", - label: "Manrope Grotesk", - primaryFamily: "Manrope", - fallbackStack: "'Segoe UI', 'Helvetica Neue', Arial, sans-serif", - stylesheetHref: - "https://fonts.googleapis.com/css2?family=Manrope:wght@400;500;600;700;800&display=swap", - scope: "editor", - pdfFonts: [ - { src: "/fonts/manrope/Manrope-Regular.ttf", fontWeight: 400 }, - { src: "/fonts/manrope/Manrope-Bold.ttf", fontWeight: 700 }, - ], - }, - { - id: "inter", - label: "Inter", - primaryFamily: "Inter", - fallbackStack: "'Segoe UI', Arial, sans-serif", - stylesheetHref: - "https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800&display=swap", - scope: "editor", - pdfFonts: [ - { src: "/fonts/inter/Inter_18pt-Regular.ttf", fontWeight: 400 }, - { src: "/fonts/inter/Inter_18pt-Bold.ttf", fontWeight: 700 }, - ], - }, -]; - -const RESUME_FONT_ALIAS_MAP: Record = { - mono: "geist", -}; - -const RESUME_FONT_ID_SET = new Set( - resumeFontDefinitions.map((font) => font.id), -); - -export const DEFAULT_RESUME_FONT_FAMILY: ResumeFontFamilyId = "geist"; - -export const RESUME_FONT_REGISTRY: Record = - Object.fromEntries(resumeFontDefinitions.map((font) => [font.id, font])) as Record< - ResumeFontFamilyId, - ResumeFontRegistryEntry - >; - -export function isResumeFontFamilyId(value: string): value is ResumeFontFamilyId { - return RESUME_FONT_ID_SET.has(value as ResumeFontFamilyId); -} - -export function normalizeResumeFontFamilyId(value: string | null | undefined): ResumeFontFamilyId { - const normalized = (value ?? "").trim().toLowerCase(); - - if (isResumeFontFamilyId(normalized)) { - return normalized; - } - - if (normalized in RESUME_FONT_ALIAS_MAP) { - return RESUME_FONT_ALIAS_MAP[normalized]; - } - - return DEFAULT_RESUME_FONT_FAMILY; -} - -export const resumeFontOptions: Array<{ value: ResumeFontFamilyId; label: string }> = - resumeFontDefinitions.map((font) => ({ - value: font.id, - label: font.label, - })); - -function toFontFamilyValue(font: ResumeFontRegistryEntry) { - return `'${font.primaryFamily}', ${font.fallbackStack}`; -} - -export const RESUME_FONT_FAMILY_MAP: Record = Object.fromEntries( - resumeFontDefinitions.map((font) => [font.id, toFontFamilyValue(font)]), -) as Record; - -export function getResumeFontStylesheetHref(fontFamily: string | null | undefined) { - const normalized = normalizeResumeFontFamilyId(fontFamily); - return RESUME_FONT_REGISTRY[normalized].stylesheetHref; -} - -export const RESUME_EDITOR_FONT_STYLESHEET_HREFS = resumeFontDefinitions - .filter((font) => font.scope === "editor") - .map((font) => font.stylesheetHref); diff --git a/apps/studio/features/resume/hooks/use-resume.ts b/apps/studio/features/resume/hooks/use-resume.ts index 9089bde..93e2d63 100644 --- a/apps/studio/features/resume/hooks/use-resume.ts +++ b/apps/studio/features/resume/hooks/use-resume.ts @@ -3,5 +3,7 @@ import { useResumeStore } from "@/features/resume/store/resume-store"; export function useResume() { + // Convenience hook for editor modules that need several store actions together. + // Prefer narrow selectors in new high-frequency components. return useResumeStore(); } diff --git a/apps/studio/features/resume/hooks/use-sections.ts b/apps/studio/features/resume/hooks/use-sections.ts index a04a390..d00fb0c 100644 --- a/apps/studio/features/resume/hooks/use-sections.ts +++ b/apps/studio/features/resume/hooks/use-sections.ts @@ -8,6 +8,7 @@ export function useSections() { const sections = useResumeStore((state) => state.resume.sections); const selectedSection = useResumeStore((state) => state.selectedSection); + // Store keeps user order as data. Hook returns sorted view for nav consumers. const orderedSections = useMemo( () => [...sections].sort((left, right) => left.order - right.order), [sections], diff --git a/apps/studio/features/resume/store/resume-store-utils.ts b/apps/studio/features/resume/store/resume-store-utils.ts new file mode 100644 index 0000000..042bd9f --- /dev/null +++ b/apps/studio/features/resume/store/resume-store-utils.ts @@ -0,0 +1,25 @@ +import type { ResumeData } from "@/types/resume"; + +export function reorderItems(items: T[], fromIndex: number, toIndex: number) { + if ( + fromIndex < 0 || + toIndex < 0 || + fromIndex >= items.length || + toIndex >= items.length || + fromIndex === toIndex + ) { + return items; + } + + const nextItems = [...items]; + const [moved] = nextItems.splice(fromIndex, 1); + nextItems.splice(toIndex, 0, moved); + return nextItems; +} + +export function withTimestamp(resume: ResumeData) { + return { + ...resume, + updatedAt: new Date().toISOString(), + }; +} diff --git a/apps/studio/features/resume/store/resume-store.ts b/apps/studio/features/resume/store/resume-store.ts index a0fbc9a..2c97e7c 100644 --- a/apps/studio/features/resume/store/resume-store.ts +++ b/apps/studio/features/resume/store/resume-store.ts @@ -34,6 +34,7 @@ import { } from "@/features/resume/services/local-storage"; import { defaultResume } from "@/features/resume/constants/default-resume"; import { normalizeResumeData } from "@/features/resume/utils/normalize-data"; +import { reorderItems, withTimestamp } from "@/features/resume/store/resume-store-utils"; interface ResumeStoreState { resume: ResumeData; @@ -83,30 +84,6 @@ interface ResumeStoreState { reorderProjects: (fromIndex: number, toIndex: number) => void; } -function reorderItems(items: T[], fromIndex: number, toIndex: number) { - if ( - fromIndex < 0 || - toIndex < 0 || - fromIndex >= items.length || - toIndex >= items.length || - fromIndex === toIndex - ) { - return items; - } - - const nextItems = [...items]; - const [moved] = nextItems.splice(fromIndex, 1); - nextItems.splice(toIndex, 0, moved); - return nextItems; -} - -function withTimestamp(resume: ResumeData) { - return { - ...resume, - updatedAt: new Date().toISOString(), - }; -} - export const useResumeStore = create((set, get) => ({ resume: defaultResume, selectedSection: "basics", diff --git a/apps/studio/features/resume/utils/normalize-data.ts b/apps/studio/features/resume/utils/normalize-data.ts index 3a29921..7d0553e 100644 --- a/apps/studio/features/resume/utils/normalize-data.ts +++ b/apps/studio/features/resume/utils/normalize-data.ts @@ -148,10 +148,15 @@ function normalizeMonthDate(value: string | undefined) { } export function normalizeResumeData(value: Partial | null | undefined): ResumeData { - const templateAliases: Record = { - faang: "compact-ats", - ats: "compact-ats", - modern: "clean-professional", + // Migration map: forwards old/legacy template IDs to the current canonical slugs. + // Old IDs are kept here permanently so existing stored resumes are silently upgraded. + const templateAliases: Record = { + // v1 internal names → v2 canonical slugs + "clean-professional": "executive-clarity", + modern: "executive-clarity", + "compact-ats": "precision-ats", + faang: "precision-ats", + ats: "precision-ats", }; const normalizedTemplateId = value?.templateId ? (templateAliases[value.templateId] ?? value.templateId)