@@ -79,7 +82,7 @@ export function DocumentPreviewCard({
diff --git a/apps/studio/app/(main)/editor/[type]/[id]/preview/PreviewClient.tsx b/apps/studio/app/(main)/editor/[type]/[id]/preview/PreviewClient.tsx
index ece57fa..7cae30b 100644
--- a/apps/studio/app/(main)/editor/[type]/[id]/preview/PreviewClient.tsx
+++ b/apps/studio/app/(main)/editor/[type]/[id]/preview/PreviewClient.tsx
@@ -15,6 +15,7 @@ import { loadResumeById } from "@/features/resume/services/resume-service";
import { loadDocumentById } from "@/features/documents/services/document-workspace-service";
import type { DocumentType } from "@/features/documents/core/document-types";
import type { CoverLetterContent } from "@/features/cover-letter/types";
+import { getDocumentEditorPath } from "@/features/documents/core/routes";
import { CoverLetterPreview } from "@/templates/cover-letter/web";
interface PreviewClientProps {
@@ -58,7 +59,7 @@ export function PreviewClient({ documentId, type }: PreviewClientProps) {
const found = type === "RESUME" ? Boolean(routeResume) : Boolean(routeDocument);
const title =
type === "RESUME" ? resume.basics.fullName || "Untitled Resume" : routeDocument?.title;
- const routeType = type.toLowerCase();
+ const editorPath = getDocumentEditorPath(type, documentId);
const debugType = type === "COVER_LETTER" ? "cover-letter" : type.toLowerCase();
const debugTemplateId = type === "RESUME" ? resume.templateId : routeDocument?.templateId;
const canDebugPdf = type === "RESUME" || type === "COVER_LETTER";
@@ -87,7 +88,7 @@ export function PreviewClient({ documentId, type }: PreviewClientProps) {
) : null}
Back to editor
diff --git a/apps/studio/app/(main)/editor/components/EditorContentPanel.tsx b/apps/studio/app/(main)/editor/components/EditorContentPanel.tsx
deleted file mode 100644
index 70549c2..0000000
--- a/apps/studio/app/(main)/editor/components/EditorContentPanel.tsx
+++ /dev/null
@@ -1,168 +0,0 @@
-"use client";
-
-import { useState } from "react";
-import type { DragEvent } from "react";
-
-import type { ResumeSectionId } from "@/types/resume";
-
-import { useResume } from "@/features/resume/hooks/use-resume";
-
-import LinksSection from "./content/sections/LinksSection";
-import AwardsSection from "./content/sections/AwardsSection";
-import BasicsSection from "./content/sections/BasicsSection";
-import CustomSection from "./content/sections/CustomSection";
-import SkillsSection from "./content/sections/SkillsSection";
-import SummarySection from "./content/sections/SummarySection";
-import ProjectsSection from "./content/sections/ProjectsSection";
-import LanguagesSection from "./content/sections/LanguagesSection";
-import InterestsSection from "./content/sections/InterestsSection";
-import VolunteerSection from "./content/sections/VolunteerSection";
-import EducationSection from "./content/sections/EducationSection";
-import ReferencesSection from "./content/sections/ReferencesSection";
-import ExperienceSection from "./content/sections/ExperienceSection";
-import AchievementsSection from "./content/sections/AchievementsSection";
-import PublicationsSection from "./content/sections/PublicationsSection";
-import CertificationsSection from "./content/sections/CertificationsSection";
-
-const EditorContentPanel = () => {
- const { reorderSections, resume } = useResume();
-
- const [draggedSectionId, setDraggedSectionId] = useState
(null);
- const [openSectionId, setOpenSectionId] = useState("basics");
-
- function handleToggleSection(sectionId: ResumeSectionId) {
- setOpenSectionId((currentSectionId) => (currentSectionId === sectionId ? null : sectionId));
- }
-
- return (
-
-
-
- Resume Content
-
-
-
Content editor
-
-
-
- {resume.sections
- .slice()
- .sort((left, right) => left.order - right.order)
- .map((section, sectionIndex) => {
- const handleDragStart = (event: DragEvent
) => {
- setDraggedSectionId(section.id);
-
- if (event.dataTransfer) {
- event.dataTransfer.effectAllowed = "move";
- }
- };
-
- const handleDragOver = (event: DragEvent) => {
- event.preventDefault();
-
- if (event.dataTransfer) {
- event.dataTransfer.dropEffect = "move";
- }
- };
-
- const handleDrop = (event: DragEvent) => {
- event.preventDefault();
-
- if (draggedSectionId && draggedSectionId !== section.id) {
- const draggedIndex = resume.sections.findIndex(
- (item) => item.id === draggedSectionId,
- );
-
- if (draggedIndex !== -1) {
- reorderSections(draggedIndex, sectionIndex);
- }
- }
-
- setDraggedSectionId(null);
- };
-
- const handleDragEnd = () => {
- setDraggedSectionId(null);
- };
-
- const sectionProps = {
- isOpen: openSectionId === section.id,
- onDragEnd: handleDragEnd,
- onDragOver: handleDragOver,
- onDragStart: handleDragStart,
- onDrop: handleDrop,
- onToggle: handleToggleSection,
- };
-
- if (section.id === "basics") {
- return ;
- }
-
- if (section.id === "links") {
- return ;
- }
-
- if (section.id === "summary") {
- return ;
- }
-
- if (section.id === "experience") {
- return ;
- }
-
- if (section.id === "education") {
- return ;
- }
-
- if (section.id === "projects") {
- return ;
- }
-
- if (section.id === "skills") {
- return ;
- }
-
- if (section.id === "certifications") {
- return ;
- }
-
- if (section.id === "awards") {
- return ;
- }
-
- if (section.id === "publications") {
- return ;
- }
-
- if (section.id === "languages") {
- return ;
- }
-
- if (section.id === "interests") {
- return ;
- }
-
- if (section.id === "volunteer") {
- return ;
- }
-
- if (section.id === "references") {
- return ;
- }
-
- if (section.id === "achievements") {
- return ;
- }
-
- if (section.id === "custom") {
- return ;
- }
-
- return null;
- })}
-
-
- );
-};
-
-export default EditorContentPanel;
diff --git a/apps/studio/app/(main)/editor/components/EditorLayout.tsx b/apps/studio/app/(main)/editor/components/EditorLayout.tsx
deleted file mode 100644
index 1c013bd..0000000
--- a/apps/studio/app/(main)/editor/components/EditorLayout.tsx
+++ /dev/null
@@ -1,317 +0,0 @@
-"use client";
-
-import { useRouter, useSearchParams } from "next/navigation";
-import { useDeferredValue, useEffect, useRef, useState } from "react";
-
-import type { TemplateComponent } from "@/types/template";
-
-import { Card } from "@veriworkly/ui";
-
-import {
- startDocumentSyncWorker,
- hydrateCloudDocumentByIdToLocalStorage,
-} from "@/features/documents/services/document-sync";
-import {
- loadResumeById,
- createResumeWithTemplate,
-} from "@/features/resume/services/resume-service";
-import { useResume } from "@/features/resume/hooks/use-resume";
-import { loadWorkspaceSettingsFromLocalStorage } from "@/features/documents/services/workspace-settings";
-
-import { loadTemplateComponentById } from "@/templates";
-
-import Toolbar from "./Toolbar";
-import EditorModals from "./EditorModals";
-import EditorContentPanel from "./EditorContentPanel";
-import EditorSettingsPanel from "./EditorSettingsPanel";
-
-import { useUserStore } from "@/store/useUserStore";
-
-interface EditorLayoutProps {
- resumeId: string;
-}
-
-const EditorLayout = ({ resumeId }: EditorLayoutProps) => {
- const router = useRouter();
- const searchParams = useSearchParams();
-
- const hasHydratedRef = useRef(false);
-
- const isLoggedIn = useUserStore((state) => state.isLoggedIn);
-
- const { hydrateFromStorage, resume, saveToStorage, setResume } = useResume();
-
- const [panelOpen, setPanelOpen] = useState(true);
- const [activeTab, setActiveTab] = useState<"editor" | "preview">("editor");
- const [activePanel, setActivePanel] = useState<"content" | "settings">("content");
-
- const [templateComponent, setTemplateComponent] = useState(null);
-
- const [shareModalOpen, setShareModalOpen] = useState(false);
- const [deleteModalOpen, setDeleteModalOpen] = useState(false);
-
- const deferredResume = useDeferredValue(resume);
-
- const resumePreviewId = `resume-preview-${resume.id}`;
-
- const stagePaddingClass = resume.customization.pagePadding === 0 ? "p-0" : "p-3 md:p-6";
-
- useEffect(() => {
- let cancelled = false;
-
- const hydrate = async () => {
- if (resumeId === "new") {
- const template = searchParams.get("template") || "executive-clarity";
- const newResume = createResumeWithTemplate(template);
-
- router.replace(`/editor/resume/${newResume.id}`);
-
- return;
- }
-
- if (resumeId) {
- const routeResume = loadResumeById(resumeId);
-
- if (routeResume) {
- setResume(routeResume);
- hasHydratedRef.current = true;
-
- return;
- }
-
- const cloudResult = await hydrateCloudDocumentByIdToLocalStorage("RESUME", resumeId);
-
- if (!cancelled && cloudResult.ok) {
- const hydratedResume = loadResumeById(resumeId);
-
- if (hydratedResume) {
- setResume(hydratedResume);
- hasHydratedRef.current = true;
- return;
- }
- }
- }
-
- if (!cancelled) {
- hydrateFromStorage();
- hasHydratedRef.current = true;
- }
- };
-
- void hydrate();
-
- return () => {
- cancelled = true;
- };
- }, [hydrateFromStorage, resumeId, router, searchParams, setResume]);
-
- useEffect(() => {
- if (!hasHydratedRef.current) {
- return;
- }
-
- saveToStorage({ debounceMs: 300 });
- }, [resume, saveToStorage]);
-
- useEffect(() => {
- if (!hasHydratedRef.current) {
- return;
- }
-
- if (!isLoggedIn) {
- return;
- }
-
- const workspaceSettings = loadWorkspaceSettingsFromLocalStorage();
-
- startDocumentSyncWorker("RESUME", {
- enabled: isLoggedIn && workspaceSettings.autoSyncEnabled,
- idleDelayMs: 12_000,
- });
- }, [isLoggedIn, resume.updatedAt]);
-
- useEffect(() => {
- let cancelled = false;
-
- const loadTemplate = async () => {
- const nextTemplate = await loadTemplateComponentById(deferredResume.templateId);
-
- if (!cancelled) {
- setTemplateComponent(() => nextTemplate);
- }
- };
-
- void loadTemplate();
-
- return () => {
- cancelled = true;
- };
- }, [deferredResume.templateId]);
-
- return (
-
-
- setShareModalOpen(true)}
- onOpenDelete={() => setDeleteModalOpen(true)}
- />
-
-
-
setShareModalOpen(false)}
- deleteModalOpen={deleteModalOpen}
- onDeleteModalClose={() => setDeleteModalOpen(false)}
- />
-
-
-
-
-
-
-
-
- {panelOpen ? (
-
-
-
-
-
-
-
-
-
-
-
-
-
- {activePanel === "content" ? : }
-
-
-
- ) : null}
-
- {!panelOpen ? (
-
-
-
- Panels
-
-
-
-
-
-
-
- ) : null}
-
-
-
-
-
-
- Live Preview
-
-
-
- {deferredResume.basics.fullName || "Untitled Resume"}
-
-
-
-
-
-
-
- {templateComponent
- ? (() => {
- const TemplateComponent = templateComponent;
- return ;
- })()
- : null}
-
-
-
-
-
-
-
- );
-};
-
-export default EditorLayout;
diff --git a/apps/studio/app/(main)/editor/components/content/sections/EducationSection.tsx b/apps/studio/app/(main)/editor/components/content/sections/EducationSection.tsx
deleted file mode 100644
index c2ef61a..0000000
--- a/apps/studio/app/(main)/editor/components/content/sections/EducationSection.tsx
+++ /dev/null
@@ -1,181 +0,0 @@
-"use client";
-
-import { useMemo, useState } from "react";
-
-import type { BaseSectionProps } from "./section-types";
-
-import { Input } from "@veriworkly/ui";
-import { Button } from "@veriworkly/ui";
-
-import { useResume } from "@/features/resume/hooks/use-resume";
-import { validateEducation } from "@/features/resume/utils/validation";
-
-import DraggableSection from "./DraggableSection";
-import { Field, invalidClass, TextArea } from "../EditorFormPrimitives";
-
-const EducationSection = ({
- isOpen,
- onDragEnd,
- onDragOver,
- onDragStart,
- onDrop,
- onToggle,
-}: BaseSectionProps) => {
- const { addEducation, removeEducation, resume, updateEducation } = useResume();
-
- const [educationIndex, setEducationIndex] = useState(0);
-
- const safeEducationIndex = Math.min(educationIndex, Math.max(0, resume.education.length - 1));
-
- const activeEducation = resume.education[safeEducationIndex];
-
- const educationErrors = useMemo(
- () => (activeEducation ? validateEducation(activeEducation) : {}),
- [activeEducation],
- );
-
- if (!activeEducation) {
- return null;
- }
-
- return (
-
-
-
-
-
-
-
-
-
-
-
-
- updateEducation(safeEducationIndex, {
- school: event.target.value,
- })
- }
- value={activeEducation.school}
- />
-
-
-
-
- updateEducation(safeEducationIndex, {
- degree: event.target.value,
- })
- }
- value={activeEducation.degree}
- />
-
-
-
-
- updateEducation(safeEducationIndex, {
- field: event.target.value,
- })
- }
- value={activeEducation.field}
- />
-
-
-
-
- updateEducation(safeEducationIndex, {
- startDate: event.target.value.replace(/\D/g, "").slice(0, 4),
- })
- }
- pattern="[0-9]*"
- placeholder="2019"
- value={activeEducation.startDate}
- />
-
-
-
-
- updateEducation(safeEducationIndex, {
- endDate: event.target.value.replace(/\D/g, "").slice(0, 4),
- })
- }
- pattern="[0-9]*"
- placeholder="2023"
- value={activeEducation.endDate}
- />
-
-
-
-
-
-
-
-
-
-
- );
-};
-
-export default EducationSection;
diff --git a/apps/studio/app/(main)/editor/components/content/sections/SkillsSection.tsx b/apps/studio/app/(main)/editor/components/content/sections/SkillsSection.tsx
deleted file mode 100644
index 90d7a14..0000000
--- a/apps/studio/app/(main)/editor/components/content/sections/SkillsSection.tsx
+++ /dev/null
@@ -1,121 +0,0 @@
-"use client";
-
-import { useMemo, useState } from "react";
-
-import type { BaseSectionProps } from "./section-types";
-
-import { Input } from "@veriworkly/ui";
-import { Button } from "@veriworkly/ui";
-
-import { Field, invalidClass, DelimitedTextArea } from "../EditorFormPrimitives";
-
-import { useResume } from "@/features/resume/hooks/use-resume";
-import { validateSkillGroup } from "@/features/resume/utils/validation";
-
-import DraggableSection from "./DraggableSection";
-
-const SkillsSection = ({
- isOpen,
- onDragEnd,
- onDragOver,
- onDragStart,
- onDrop,
- onToggle,
-}: BaseSectionProps) => {
- const { addSkillGroup, removeSkillGroup, resume, updateSkillGroup } = useResume();
-
- const [skillIndex, setSkillIndex] = useState(0);
-
- const safeSkillIndex = Math.min(skillIndex, Math.max(0, resume.skills.length - 1));
-
- const activeSkillGroup = resume.skills[safeSkillIndex];
-
- const skillErrors = useMemo(
- () => (activeSkillGroup ? validateSkillGroup(activeSkillGroup) : {}),
- [activeSkillGroup],
- );
-
- if (!activeSkillGroup) {
- return null;
- }
-
- return (
-
-
-
-
-
-
-
-
-
-
-
- updateSkillGroup(safeSkillIndex, {
- name: event.target.value,
- })
- }
- value={activeSkillGroup.name}
- />
-
-
-
-
- updateSkillGroup(safeSkillIndex, {
- keywords: nextKeywords,
- })
- }
- />
-
-
- {activeSkillGroup.keywords.length ? (
-
- {activeSkillGroup.keywords.map((keyword) => (
-
- {keyword}
-
- ))}
-
- ) : null}
-
- );
-};
-
-export default SkillsSection;
diff --git a/apps/studio/app/(main)/editor/page.tsx b/apps/studio/app/(main)/editor/page.tsx
index bb2dfe0..27200ba 100644
--- a/apps/studio/app/(main)/editor/page.tsx
+++ b/apps/studio/app/(main)/editor/page.tsx
@@ -1,6 +1,7 @@
import { redirect } from "next/navigation";
import { getTemplateById, templateRegistry } from "@/templates";
+import { getDocumentEditorPath } from "@/features/documents/core/routes";
interface EditorEntryPageProps {
searchParams: Promise<{ [key: string]: string | string[] | undefined }>;
@@ -19,5 +20,5 @@ export default async function EditorEntryPage({ searchParams }: EditorEntryPageP
const resolvedTemplate =
(templateParam ? getTemplateById(templateParam) : null) ?? templateRegistry[0];
- redirect(`/editor/resume/new?template=${resolvedTemplate.id}`);
+ redirect(`${getDocumentEditorPath("RESUME", "new")}?template=${resolvedTemplate.id}`);
}
diff --git a/apps/studio/components/dashboard/StudioShell.tsx b/apps/studio/components/dashboard/StudioShell.tsx
index db761e8..501be83 100644
--- a/apps/studio/components/dashboard/StudioShell.tsx
+++ b/apps/studio/components/dashboard/StudioShell.tsx
@@ -22,6 +22,7 @@ import { WorkspaceSearchModal } from "@/components/dashboard/WorkspaceSearchModa
import { createResume } from "@/features/resume/services/resume-service";
import { signOutCurrentUser } from "@/features/auth/services/current-user";
+import { getDocumentEditorPath } from "@/features/documents/core/routes";
import { createDocument } from "@/features/documents/services/document-workspace-service";
import type { DocumentType } from "@/features/documents/core/document-types";
@@ -34,7 +35,7 @@ interface StudioShellProps {
mainClassName?: string;
}
-const STUDIO_VERSION = "v3.4.0";
+const STUDIO_VERSION = "v3.8.0";
const StudioShell = ({ children, mainClassName }: StudioShellProps) => {
const router = useRouter();
@@ -53,13 +54,13 @@ const StudioShell = ({ children, mainClassName }: StudioShellProps) => {
const createNewDocument = (type: DocumentType) => {
if (type === "RESUME") {
const resume = createResume();
- router.push(`/editor/resume/${resume.id}`);
+ router.push(getDocumentEditorPath("RESUME", resume.id));
return;
}
const document = createDocument(type);
- router.push(`/editor/${type.toLowerCase()}/${document.id}`);
+ router.push(getDocumentEditorPath(type, document.id));
};
const handleLogout = async () => {
@@ -238,7 +239,7 @@ const StudioShell = ({ children, mainClassName }: StudioShellProps) => {
setSearchOpen(false)}
- onOpenDocument={(id) => router.push(`/editor/resume/${id}`)}
+ onOpenDocument={(doc) => router.push(getDocumentEditorPath(doc.type, doc.id))}
/>
void;
- onOpenDocument: (id: string) => void;
+ onOpenDocument: (doc: SearchResult) => void;
}) {
const [query, setQuery] = useState("");
const inputId = useId();
@@ -39,17 +42,21 @@ export function WorkspaceSearchModal({
const results = useMemo(() => {
if (!open) return [];
- const resumes: SearchResult[] = listSavedResumes().map((resume) => ({
- id: resume.id,
- type: "RESUME",
- title: resume.title,
- subtitle: resume.role || "Resume",
- updatedAt: resume.updatedAt,
- }));
+ const documents: SearchResult[] = listDocuments().map((document) => {
+ const definition = getDocumentDefinition(document.type);
+
+ return {
+ id: document.id,
+ type: document.type,
+ title: document.title,
+ subtitle: definition.label,
+ updatedAt: document.updatedAt,
+ };
+ });
const needle = query.trim().toLowerCase();
- return resumes
+ return documents
.filter((doc) => !needle || `${doc.title} ${doc.subtitle}`.toLowerCase().includes(needle))
.sort((left, right) => Date.parse(right.updatedAt) - Date.parse(left.updatedAt))
.slice(0, 10);
@@ -113,7 +120,7 @@ export function WorkspaceSearchModal({
className="hover:bg-accent/10 focus-visible:bg-accent/10 flex w-full items-center gap-3 rounded-xl p-3 text-left outline-none"
onClick={() => {
onClose();
- onOpenDocument(doc.id);
+ onOpenDocument(doc);
}}
>
@@ -122,14 +129,14 @@ export function WorkspaceSearchModal({
{doc.title}
- Resume - {doc.subtitle}
+ {doc.subtitle}
))
) : (
No documents found
-
Try another resume title or role.
+
Try another document title.
)}
diff --git a/apps/studio/components/modals/SyncDetailsModal.tsx b/apps/studio/components/modals/SyncDetailsModal.tsx
index 342b8b3..c5e176d 100644
--- a/apps/studio/components/modals/SyncDetailsModal.tsx
+++ b/apps/studio/components/modals/SyncDetailsModal.tsx
@@ -17,6 +17,7 @@ import type { SyncTelemetry } from "@/features/documents/services/document-sync"
import type { DocumentLibraryItem } from "@/features/documents/services/document-library";
import { cn } from "@/lib/utils";
+import { getDocumentEditorPath } from "@/features/documents/core/routes";
import { Modal, Button } from "@veriworkly/ui";
@@ -45,7 +46,7 @@ const SyncDetailsModal = ({
const isSyncing = syncingDocumentId === document.id;
const isConflicted = document.sync.status === "conflicted";
- const editorHref = `/editor/${document.type.toLowerCase()}/${document.id}`;
+ const editorHref = getDocumentEditorPath(document.type, document.id);
if (!document) return null;
diff --git a/apps/studio/features/cover-letter/editor/CoverLetterEditor.tsx b/apps/studio/features/cover-letter/editor/CoverLetterEditor.tsx
index 8cb687e..b9eee6e 100644
--- a/apps/studio/features/cover-letter/editor/CoverLetterEditor.tsx
+++ b/apps/studio/features/cover-letter/editor/CoverLetterEditor.tsx
@@ -1,200 +1,53 @@
"use client";
-import Link from "next/link";
+import type { ReactNode } from "react";
+
import { toast } from "sonner";
import { useRouter } from "next/navigation";
-import { useEffect, useRef, useState } from "react";
-import { FileSearch, PanelLeftClose, PanelLeftOpen } from "lucide-react";
-
-import { Button, Card, Input, Select, TextArea } from "@veriworkly/ui";
+import { useEffect, useMemo, useState } from "react";
-import { cn } from "@/lib/utils";
+import { Button, Card } from "@veriworkly/ui";
import { useUserStore } from "@/store/useUserStore";
-import type { BaseDocument, ExportFormat } from "@/features/documents/core/types";
-import type { ResumeLinkDisplayMode, ResumeLinkItem, ResumeLinkType } from "@/types/resume";
-import type { CoverLetterAppearance, CoverLetterContent } from "@/features/cover-letter/types";
+import type { BaseDocument } from "@/features/documents/core/types";
+import type { CoverLetterContent } from "@/features/cover-letter/types";
-import {
- startDocumentSyncWorker,
- hydrateCloudDocumentByIdToLocalStorage,
-} from "@/features/documents/services/document-sync";
-import {
- saveDocument,
- deleteDocument,
- loadDocumentById,
-} from "@/features/documents/services/document-workspace-service";
import { CoverLetterPreview } from "@/templates/cover-letter/web";
import ShareDocumentModal from "@/components/modals/ShareDocumentModal";
-import { createDefaultCoverLetter } from "@/features/cover-letter/defaults";
-import ToolbarHeader from "@/app/(main)/editor/components/toolbar/ToolbarHeader";
-import { templateCatalogByType } from "@/features/documents/core/template-catalog";
-import { exportDocumentByType } from "@/features/documents/export/export-dispatcher";
-import { fontOptions, type FontFamilyId } from "@/features/documents/constants/fonts";
-import { linkTypeOptions } from "@/app/(main)/editor/components/content/editor-options";
-import ToolbarActionsMenu from "@/app/(main)/editor/components/toolbar/ToolbarActionsMenu";
-import ToolbarDownloadMenu from "@/app/(main)/editor/components/toolbar/ToolbarDownloadMenu";
+import { DocumentEditorShell } from "@/features/documents/editor/DocumentEditorShell";
+import { startDocumentSyncWorker } from "@/features/documents/services/document-sync";
+import { deleteDocument } from "@/features/documents/services/document-workspace-service";
import { loadWorkspaceSettingsFromLocalStorage } from "@/features/documents/services/workspace-settings";
-interface Props {
- documentId: string;
-}
-
-type EditorPanel = "content" | "settings";
-
-function getInitialDocument(documentId: string) {
- return loadDocumentById("COVER_LETTER", documentId) as BaseDocument
| null;
-}
+import { CoverLetterToolbar } from "./components/CoverLetterToolbar";
+import { useCoverLetterDocument } from "./hooks/useCoverLetterDocument";
+import { CoverLetterContentPanel } from "./components/CoverLetterContentPanel";
+import { CoverLetterSettingsPanel } from "./components/CoverLetterSettingsPanel";
-function Field({
- label,
- value,
- placeholder,
- onChange,
-}: {
- label: string;
- value: string;
- placeholder?: string;
- onChange: (value: string) => void;
-}) {
- return (
-
- );
-}
-
-function TextField({
- label,
- value,
- placeholder,
- className,
- onChange,
-}: {
- label: string;
- value: string;
- placeholder?: string;
- className?: string;
- onChange: (value: string) => void;
-}) {
- return (
-
- );
-}
-
-function RangeField({
- label,
- value,
- min,
- max,
- step,
- onChange,
-}: {
- label: string;
- value: number;
- min: number;
- max: number;
- step?: number;
- onChange: (value: number) => void;
-}) {
- return (
-
- );
-}
-
-function ColorField({
- label,
- value,
- onChange,
-}: {
- label: string;
- value: string;
- onChange: (value: string) => void;
-}) {
- return (
-
- );
+interface CoverLetterEditorProps {
+ documentId: string;
}
-export default function CoverLetterEditor({ documentId }: Props) {
+export default function CoverLetterEditor({ documentId }: CoverLetterEditorProps) {
const router = useRouter();
-
const isLoggedIn = useUserStore((state) => state.isLoggedIn);
-
- const [hydrated, setHydrated] = useState(false);
- const [panelOpen, setPanelOpen] = useState(true);
- const fileInputRef = useRef(null);
- const [message, setMessage] = useState("Autosave ready");
const [shareModalOpen, setShareModalOpen] = useState(false);
- const [activePanel, setActivePanel] = useState("content");
- const [activeTab, setActiveTab] = useState<"editor" | "preview">("editor");
- const [doc, setDoc] = useState | null>(null);
- const [activeDownload, setActiveDownload] = useState(null);
-
- useEffect(() => {
- let cancelled = false;
-
- const hydrate = async () => {
- const localDocument = getInitialDocument(documentId);
-
- if (!cancelled && localDocument) {
- setDoc(localDocument);
- setHydrated(true);
- return;
- }
-
- const cloudResult = await hydrateCloudDocumentByIdToLocalStorage("COVER_LETTER", documentId);
-
- if (!cancelled && cloudResult.ok) {
- setDoc(getInitialDocument(documentId));
- setHydrated(true);
- return;
- }
-
- if (cancelled) return;
- setDoc(null);
- setHydrated(true);
- };
- void hydrate();
-
- return () => {
- cancelled = true;
- };
- }, [documentId]);
+ const {
+ doc,
+ hydrated,
+ message,
+ setMessage,
+ updateDocument,
+ updateContent,
+ updateAppearance,
+ updateLinks,
+ addLink,
+ updateLink,
+ removeLink,
+ saveCurrentDocument,
+ } = useCoverLetterDocument(documentId);
useEffect(() => {
if (!hydrated || !isLoggedIn) return;
@@ -207,112 +60,33 @@ export default function CoverLetterEditor({ documentId }: Props) {
});
}, [hydrated, isLoggedIn, doc?.updatedAt]);
+ const links = useMemo(
+ () => doc?.content.links ?? { displayMode: "icon-username" as const, items: [] },
+ [doc?.content.links],
+ );
+
if (!hydrated) {
- return (
-
-
- Loading cover letter
- Preparing your editor.
-
-
- );
+ return ;
}
if (!doc) {
return (
-
-
- Cover letter not found
- Return to documents and choose another letter.
-
-
-
+
+
+
);
}
const currentDoc = doc;
- const content = currentDoc.content;
- const appearance = content.appearance;
- const links = content.links ?? { displayMode: "icon-username" as const, items: [] };
-
- function updateDocument(next: BaseDocument) {
- setDoc(next);
- saveDocument(next);
- setMessage("Saved locally");
- }
-
- function updateContent(patch: Partial) {
- updateDocument({
- ...currentDoc,
- title:
- patch.jobTitle || patch.companyName
- ? [patch.jobTitle ?? content.jobTitle, patch.companyName ?? content.companyName]
- .filter(Boolean)
- .join(" - ") || currentDoc.title
- : currentDoc.title,
- updatedAt: new Date().toISOString(),
- content: { ...content, ...patch },
- });
- }
-
- function updateAppearance(patch: Partial) {
- updateContent({ appearance: { ...appearance, ...patch } });
- }
-
- function updateLinks(patch: Partial) {
- updateContent({ links: { ...links, ...patch } });
- }
-
- function addLink() {
- const next: ResumeLinkItem = {
- id: `cover-link-${Date.now().toString(36)}`,
- type: "linkedin",
- label: "",
- url: "",
- };
- updateLinks({ items: [...links.items, next] });
- }
-
- function updateLink(index: number, patch: Partial) {
- updateLinks({
- items: links.items.map((item, itemIndex) =>
- itemIndex === index ? { ...item, ...patch } : item,
- ),
- });
- }
-
- function removeLink(index: number) {
- updateLinks({ items: links.items.filter((_, itemIndex) => itemIndex !== index) });
- }
-
- async function download(format: ExportFormat) {
- setActiveDownload(format);
- try {
- await exportDocumentByType(currentDoc, format);
- setMessage(`${format.toUpperCase()} downloaded`);
- } catch {
- setMessage(`Could not generate ${format.toUpperCase()}`);
- } finally {
- setActiveDownload(null);
- }
- }
-
- function saveCurrentDocument() {
- saveDocument(currentDoc);
- setMessage("Draft saved locally");
- }
-
- function deleteCurrentDocument() {
- const confirmed = window.confirm(`Delete "${currentDoc.title}"? This cannot be undone.`);
- if (!confirmed) return;
- deleteDocument("COVER_LETTER", currentDoc.id);
- router.push("/documents");
- }
async function importJson(file: File | undefined) {
if (!file) return;
+
try {
const parsed = JSON.parse(await file.text()) as unknown;
const imported =
@@ -320,481 +94,77 @@ export default function CoverLetterEditor({ documentId }: Props) {
? (parsed as BaseDocument)
: ({ ...currentDoc, content: parsed } as BaseDocument);
- const next = {
- ...currentDoc,
- title: imported.title || currentDoc.title,
- templateId: imported.templateId || currentDoc.templateId,
- updatedAt: new Date().toISOString(),
- content: {
- ...content,
- ...(imported.content as Partial),
- appearance: {
- ...appearance,
- ...((imported.content as Partial).appearance ?? {}),
+ updateDocument(
+ {
+ ...currentDoc,
+ title: imported.title || currentDoc.title,
+ templateId: imported.templateId || currentDoc.templateId,
+ updatedAt: new Date().toISOString(),
+ content: {
+ ...currentDoc.content,
+ ...(imported.content as Partial),
+ appearance: {
+ ...currentDoc.content.appearance,
+ ...((imported.content as Partial).appearance ?? {}),
+ },
},
},
- };
+ { flush: true },
+ );
- updateDocument(next);
toast.success("Cover letter imported");
} catch {
toast.error("Import failed. Use a valid cover letter JSON file.");
- } finally {
- if (fileInputRef.current) fileInputRef.current.value = "";
}
}
- return (
-
-
-
-
-
-
-
-
-
- {panelOpen ? (
-
-
-
-
-
-
-
-
-
-
- {activePanel === "content" ? (
-
-
- updateContent({ senderName })}
- />
- updateContent({ senderTitle })}
- />
-
- updateContent({ senderEmail })}
- />
- updateContent({ senderPhone })}
- />
-
- updateContent({ senderLocation })}
- />
- updateContent({ senderWebsite })}
- />
-
-
-
-
-
-
- {links.items.map((item, index) => (
-
-
-
- updateLink(index, { label })}
- />
-
-
updateLink(index, { url })}
- />
-
-
- ))}
-
-
-
-
-
-
- updateContent({ jobTitle })}
- />
- updateContent({ companyName })}
- />
-
- updateContent({ recipientName })}
- />
- updateContent({ recipientTitle })}
- />
-
- updateContent({ companyLocation })}
- />
- updateContent({ date })}
- />
-
-
-
- updateContent({ subject })}
- />
- updateContent({ greeting })}
- />
- updateContent({ opening })}
- />
- updateContent({ body })}
- />
- updateContent({ highlights })}
- />
-
- updateContent({ closing })}
- />
- updateContent({ signature })}
- />
-
- updateContent({ postscript })}
- />
-
-
- ) : (
-
-
-
-
-
-
-
-
- updateAppearance({ pageMargin })}
- />
- updateAppearance({ paragraphSpacing })}
- />
- updateAppearance({ lineHeight })}
- />
-
-
-
- updateAppearance({ accentColor })}
- />
- updateAppearance({ sidebarColor })}
- />
- updateAppearance({ pageColor })}
- />
- updateAppearance({ textColor })}
- />
-
-
- )}
-
-
- ) : (
-
-
- Panels
-
-
-
- )}
-
-
-
-
-
- Live Preview
-
-
- {currentDoc.title || "Cover Letter"}
-
-
-
-
-
-
+ }
+ preview={
}
+ previewTitle={currentDoc.title || "Cover Letter"}
+ />
{shareModalOpen ? (
setShareModalOpen(false)}
/>
) : null}
-
+ >
);
}
-function EditorBlock({ children, title }: { children: React.ReactNode; title: string }) {
+function CoverLetterStateCard({
+ title,
+ message,
+ children,
+}: {
+ title: string;
+ message: string;
+ children?: ReactNode;
+}) {
return (
-
+
+
+ {title}
+ {message}
+ {children}
+
+
);
}
diff --git a/apps/studio/features/cover-letter/editor/components/CoverLetterContentPanel.tsx b/apps/studio/features/cover-letter/editor/components/CoverLetterContentPanel.tsx
new file mode 100644
index 0000000..4e4da93
--- /dev/null
+++ b/apps/studio/features/cover-letter/editor/components/CoverLetterContentPanel.tsx
@@ -0,0 +1,235 @@
+"use client";
+
+import { Button, Select } from "@veriworkly/ui";
+
+import type { CoverLetterContent } from "@/features/cover-letter/types";
+import type { ResumeLinkDisplayMode, ResumeLinkItem, ResumeLinkType } from "@/types/resume";
+
+import { linkTypeOptions } from "@/features/documents/editor/link-options";
+
+import { EditorBlock, Field, TextField } from "./CoverLetterFields";
+
+interface CoverLetterContentPanelProps {
+ content: CoverLetterContent;
+ links: CoverLetterContent["links"];
+ onAddLink: () => void;
+ onRemoveLink: (index: number) => void;
+ onUpdateContent: (patch: Partial) => void;
+ onUpdateLink: (index: number, patch: Partial) => void;
+ onUpdateLinks: (patch: Partial) => void;
+}
+
+export function CoverLetterContentPanel({
+ content,
+ links,
+ onAddLink,
+ onRemoveLink,
+ onUpdateContent,
+ onUpdateLink,
+ onUpdateLinks,
+}: CoverLetterContentPanelProps) {
+ return (
+
+
+ onUpdateContent({ senderName })}
+ />
+
+ onUpdateContent({ senderTitle })}
+ />
+
+
+ onUpdateContent({ senderEmail })}
+ />
+
+ onUpdateContent({ senderPhone })}
+ />
+
+
+ onUpdateContent({ senderLocation })}
+ />
+
+ onUpdateContent({ senderWebsite })}
+ />
+
+
+
+
+
+
+ {links.items.map((item, index) => (
+
+
+
+
+ onUpdateLink(index, { label })}
+ />
+
+
+
onUpdateLink(index, { url })}
+ />
+
+
+
+ ))}
+
+
+
+
+
+
+ onUpdateContent({ jobTitle })}
+ />
+
+ onUpdateContent({ companyName })}
+ />
+
+
+ onUpdateContent({ recipientName })}
+ />
+
+ onUpdateContent({ recipientTitle })}
+ />
+
+
+ onUpdateContent({ companyLocation })}
+ />
+
+ onUpdateContent({ date })} />
+
+
+
+ onUpdateContent({ subject })}
+ />
+
+ onUpdateContent({ greeting })}
+ />
+
+ onUpdateContent({ opening })}
+ />
+
+ onUpdateContent({ body })}
+ />
+
+ onUpdateContent({ highlights })}
+ />
+
+
+ onUpdateContent({ closing })}
+ />
+
+ onUpdateContent({ signature })}
+ />
+
+
+ onUpdateContent({ postscript })}
+ />
+
+
+ );
+}
diff --git a/apps/studio/features/cover-letter/editor/components/CoverLetterFields.tsx b/apps/studio/features/cover-letter/editor/components/CoverLetterFields.tsx
new file mode 100644
index 0000000..f32afe6
--- /dev/null
+++ b/apps/studio/features/cover-letter/editor/components/CoverLetterFields.tsx
@@ -0,0 +1,123 @@
+"use client";
+
+import type { ReactNode } from "react";
+
+import { Input, TextArea } from "@veriworkly/ui";
+
+import { cn } from "@/lib/utils";
+
+export function Field({
+ label,
+ value,
+ placeholder,
+ onChange,
+}: {
+ label: string;
+ value: string;
+ placeholder?: string;
+ onChange: (value: string) => void;
+}) {
+ return (
+
+ );
+}
+
+export function TextField({
+ label,
+ value,
+ placeholder,
+ className,
+ onChange,
+}: {
+ label: string;
+ value: string;
+ placeholder?: string;
+ className?: string;
+ onChange: (value: string) => void;
+}) {
+ return (
+
+ );
+}
+
+export function RangeField({
+ label,
+ value,
+ min,
+ max,
+ step,
+ onChange,
+}: {
+ label: string;
+ value: number;
+ min: number;
+ max: number;
+ step?: number;
+ onChange: (value: number) => void;
+}) {
+ return (
+
+ );
+}
+
+export function ColorField({
+ label,
+ value,
+ onChange,
+}: {
+ label: string;
+ value: string;
+ onChange: (value: string) => void;
+}) {
+ return (
+
+ );
+}
+
+export function EditorBlock({ children, title }: { children: ReactNode; title: string }) {
+ return (
+
+ );
+}
diff --git a/apps/studio/features/cover-letter/editor/components/CoverLetterSettingsPanel.tsx b/apps/studio/features/cover-letter/editor/components/CoverLetterSettingsPanel.tsx
new file mode 100644
index 0000000..00bb299
--- /dev/null
+++ b/apps/studio/features/cover-letter/editor/components/CoverLetterSettingsPanel.tsx
@@ -0,0 +1,126 @@
+"use client";
+
+import { Select } from "@veriworkly/ui";
+
+import type { BaseDocument } from "@/features/documents/core/types";
+import type { FontFamilyId } from "@/features/documents/constants/fonts";
+import type { CoverLetterAppearance, CoverLetterContent } from "@/features/cover-letter/types";
+
+import { fontOptions } from "@/features/documents/constants/fonts";
+import { templateCatalogByType } from "@/features/documents/core/template-catalog";
+
+import { ColorField, EditorBlock, RangeField } from "./CoverLetterFields";
+
+interface CoverLetterSettingsPanelProps {
+ document: BaseDocument;
+ appearance: CoverLetterAppearance;
+ onUpdateDocument: (
+ next: BaseDocument,
+ options?: { debounceMs?: number; flush?: boolean },
+ ) => void;
+ onUpdateAppearance: (patch: Partial) => void;
+}
+
+export function CoverLetterSettingsPanel({
+ document,
+ appearance,
+ onUpdateDocument,
+ onUpdateAppearance,
+}: CoverLetterSettingsPanelProps) {
+ return (
+
+
+
+
+
+
+
+
+ onUpdateAppearance({ pageMargin })}
+ />
+
+ onUpdateAppearance({ paragraphSpacing })}
+ />
+
+ onUpdateAppearance({ lineHeight })}
+ />
+
+
+
+ onUpdateAppearance({ accentColor })}
+ />
+
+ onUpdateAppearance({ sidebarColor })}
+ />
+
+ onUpdateAppearance({ pageColor })}
+ />
+
+ onUpdateAppearance({ textColor })}
+ />
+
+
+ );
+}
diff --git a/apps/studio/features/cover-letter/editor/components/CoverLetterToolbar.tsx b/apps/studio/features/cover-letter/editor/components/CoverLetterToolbar.tsx
new file mode 100644
index 0000000..f44536e
--- /dev/null
+++ b/apps/studio/features/cover-letter/editor/components/CoverLetterToolbar.tsx
@@ -0,0 +1,133 @@
+"use client";
+
+import Link from "next/link";
+import { useRef, useState } from "react";
+import { FileSearch } from "lucide-react";
+import { useRouter } from "next/navigation";
+
+import { Button } from "@veriworkly/ui";
+
+import type { CoverLetterContent } from "@/features/cover-letter/types";
+import type { BaseDocument, ExportFormat } from "@/features/documents/core/types";
+
+import ToolbarHeader from "@/features/documents/editor/toolbar/ToolbarHeader";
+import ToolbarActionsMenu from "@/features/documents/editor/toolbar/ToolbarActionsMenu";
+import ToolbarDownloadMenu from "@/features/documents/editor/toolbar/ToolbarDownloadMenu";
+
+import { getDocumentPreviewPath } from "@/features/documents/core/routes";
+import { createDefaultCoverLetter } from "@/features/cover-letter/defaults";
+import { exportDocumentByType } from "@/features/documents/export/export-dispatcher";
+
+interface CoverLetterToolbarProps {
+ document: BaseDocument;
+ message: string;
+ onDelete: () => void;
+ onImportJson: (file: File | undefined) => Promise;
+ onOpenShare: () => void;
+ onSave: () => void;
+ onSetMessage: (message: string) => void;
+ onUpdateDocument: (
+ next: BaseDocument,
+ options?: { debounceMs?: number; flush?: boolean },
+ ) => void;
+}
+
+export function CoverLetterToolbar({
+ document,
+ message,
+ onDelete,
+ onImportJson,
+ onOpenShare,
+ onSave,
+ onSetMessage,
+ onUpdateDocument,
+}: CoverLetterToolbarProps) {
+ const router = useRouter();
+
+ const fileInputRef = useRef(null);
+ const [activeDownload, setActiveDownload] = useState(null);
+
+ async function download(format: ExportFormat) {
+ setActiveDownload(format);
+
+ try {
+ await exportDocumentByType(document, format);
+ onSetMessage(`${format.toUpperCase()} downloaded`);
+ } catch {
+ onSetMessage(`Could not generate ${format.toUpperCase()}`);
+ } finally {
+ setActiveDownload(null);
+ }
+ }
+
+ return (
+
+ );
+}
diff --git a/apps/studio/features/cover-letter/editor/hooks/useCoverLetterDocument.ts b/apps/studio/features/cover-letter/editor/hooks/useCoverLetterDocument.ts
new file mode 100644
index 0000000..9d5f985
--- /dev/null
+++ b/apps/studio/features/cover-letter/editor/hooks/useCoverLetterDocument.ts
@@ -0,0 +1,155 @@
+"use client";
+
+import { useEffect, useState } from "react";
+
+import type { ResumeLinkItem } from "@/types/resume";
+import type { BaseDocument } from "@/features/documents/core/types";
+import type { CoverLetterAppearance, CoverLetterContent } from "@/features/cover-letter/types";
+
+import {
+ saveDocument,
+ loadDocumentById,
+} from "@/features/documents/services/document-workspace-service";
+import { hydrateCloudDocumentByIdToLocalStorage } from "@/features/documents/services/document-sync";
+
+function loadCoverLetter(documentId: string) {
+ return loadDocumentById("COVER_LETTER", documentId) as BaseDocument | null;
+}
+
+export function useCoverLetterDocument(documentId: string) {
+ const [hydrated, setHydrated] = useState(false);
+ const [message, setMessage] = useState("Autosave ready");
+
+ const [doc, setDoc] = useState | null>(null);
+
+ useEffect(() => {
+ let cancelled = false;
+
+ const hydrate = async () => {
+ const localDocument = loadCoverLetter(documentId);
+
+ if (!cancelled && localDocument) {
+ setDoc(localDocument);
+ setHydrated(true);
+ return;
+ }
+
+ const cloudResult = await hydrateCloudDocumentByIdToLocalStorage("COVER_LETTER", documentId);
+
+ if (!cancelled && cloudResult.ok) {
+ setDoc(loadCoverLetter(documentId));
+ setHydrated(true);
+ return;
+ }
+
+ if (cancelled) return;
+ setDoc(null);
+ setHydrated(true);
+ };
+
+ void hydrate();
+
+ return () => {
+ cancelled = true;
+ };
+ }, [documentId]);
+
+ function updateDocument(
+ next: BaseDocument,
+ options: { debounceMs?: number; flush?: boolean } = { debounceMs: 300 },
+ ) {
+ setDoc(next);
+ saveDocument(next, options);
+ setMessage("Saved locally");
+ }
+
+ function saveCurrentDocument() {
+ if (!doc) return;
+
+ saveDocument(doc, { flush: true });
+ setMessage("Draft saved locally");
+ }
+
+ function updateContent(patch: Partial) {
+ if (!doc) return;
+
+ const content = doc.content;
+
+ updateDocument({
+ ...doc,
+ title:
+ patch.jobTitle || patch.companyName
+ ? [patch.jobTitle ?? content.jobTitle, patch.companyName ?? content.companyName]
+ .filter(Boolean)
+ .join(" - ") || doc.title
+ : doc.title,
+ updatedAt: new Date().toISOString(),
+ content: { ...content, ...patch },
+ });
+ }
+
+ function updateAppearance(patch: Partial) {
+ if (!doc) return;
+
+ updateContent({
+ appearance: {
+ ...doc.content.appearance,
+ ...patch,
+ },
+ });
+ }
+
+ function updateLinks(patch: Partial) {
+ if (!doc) return;
+
+ const links = doc.content.links ?? { displayMode: "icon-username" as const, items: [] };
+ updateContent({ links: { ...links, ...patch } });
+ }
+
+ function addLink() {
+ if (!doc) return;
+
+ const links = doc.content.links ?? { displayMode: "icon-username" as const, items: [] };
+ const next: ResumeLinkItem = {
+ id: `cover-link-${Date.now().toString(36)}`,
+ type: "linkedin",
+ label: "",
+ url: "",
+ };
+
+ updateLinks({ items: [...links.items, next] });
+ }
+
+ function updateLink(index: number, patch: Partial) {
+ if (!doc) return;
+
+ const links = doc.content.links ?? { displayMode: "icon-username" as const, items: [] };
+ updateLinks({
+ items: links.items.map((item, itemIndex) =>
+ itemIndex === index ? { ...item, ...patch } : item,
+ ),
+ });
+ }
+
+ function removeLink(index: number) {
+ if (!doc) return;
+
+ const links = doc.content.links ?? { displayMode: "icon-username" as const, items: [] };
+ updateLinks({ items: links.items.filter((_, itemIndex) => itemIndex !== index) });
+ }
+
+ return {
+ doc,
+ hydrated,
+ message,
+ setMessage,
+ updateDocument,
+ updateContent,
+ updateAppearance,
+ updateLinks,
+ addLink,
+ updateLink,
+ removeLink,
+ saveCurrentDocument,
+ };
+}
diff --git a/apps/studio/features/documents/core/registry.tsx b/apps/studio/features/documents/core/registry.tsx
index 82b4e7c..c96f5ff 100644
--- a/apps/studio/features/documents/core/registry.tsx
+++ b/apps/studio/features/documents/core/registry.tsx
@@ -15,9 +15,13 @@ import { parseCoverLetterDocument } from "@/features/cover-letter/schema";
import { defaultResume } from "@/features/resume/constants/default-resume";
import { parseResumeDataInput } from "@/features/resume/schemas/resume-storage-schema";
-const ResumeEditor = dynamic(() => import("@/app/(main)/editor/components/EditorLayout"));
+const ResumeEditor = dynamic(() => import("@/features/resume/editor/ResumeEditor"));
const CoverLetterEditor = dynamic(() => import("@/features/cover-letter/editor/CoverLetterEditor"));
+function isRecord(value: unknown): value is Record {
+ return typeof value === "object" && value !== null;
+}
+
function wrapResumeDocument(id: string): BaseDocument {
const now = new Date().toISOString();
@@ -39,18 +43,35 @@ function wrapResumeDocument(id: string): BaseDocument {
}
function parseResumeDocument(input: unknown): BaseDocument | null {
- const resume = parseResumeDataInput(input);
+ const document = isRecord(input) ? input : {};
+ const resumeInput = isRecord(document.content) ? document.content : input;
+ const resume = parseResumeDataInput(resumeInput);
if (!resume) return null;
+ const id = typeof document.id === "string" ? document.id : resume.id;
+ const templateId =
+ typeof document.templateId === "string" ? document.templateId : resume.templateId;
+ const updatedAt = typeof document.updatedAt === "string" ? document.updatedAt : resume.updatedAt;
+ const sync = isRecord(document.sync) ? { ...resume.sync, ...document.sync } : resume.sync;
+
+ const content = {
+ ...resume,
+ id,
+ templateId,
+ updatedAt,
+ sync,
+ };
+
return {
- id: resume.id,
+ id,
type: "RESUME",
- title: resume.basics.fullName || "Resume",
- templateId: resume.templateId,
- content: resume,
- updatedAt: resume.updatedAt,
- sync: resume.sync,
+ title:
+ (typeof document.title === "string" && document.title) || content.basics.fullName || "Resume",
+ templateId,
+ content,
+ updatedAt,
+ sync,
};
}
@@ -64,7 +85,7 @@ export const documentRegistry: Record = {
templates: templateCatalogByType.RESUME,
createDefault: wrapResumeDocument,
parse: parseResumeDocument,
- Editor: ({ documentId }: { documentId: string }) => ,
+ Editor: ResumeEditor,
},
COVER_LETTER: {
diff --git a/apps/studio/features/documents/core/routes.ts b/apps/studio/features/documents/core/routes.ts
new file mode 100644
index 0000000..39a3a88
--- /dev/null
+++ b/apps/studio/features/documents/core/routes.ts
@@ -0,0 +1,9 @@
+import type { DocumentType } from "./document-types";
+
+export function getDocumentEditorPath(type: DocumentType, id: string) {
+ return `/editor/${type.toLowerCase()}/${id}`;
+}
+
+export function getDocumentPreviewPath(type: DocumentType, id: string) {
+ return `${getDocumentEditorPath(type, id)}/preview`;
+}
diff --git a/apps/studio/features/documents/editor/DocumentEditorShell.tsx b/apps/studio/features/documents/editor/DocumentEditorShell.tsx
new file mode 100644
index 0000000..122c358
--- /dev/null
+++ b/apps/studio/features/documents/editor/DocumentEditorShell.tsx
@@ -0,0 +1,205 @@
+"use client";
+
+import type { ReactNode } from "react";
+
+import { useState } from "react";
+import { PanelLeftClose, PanelLeftOpen } from "lucide-react";
+
+import { Button, Card } from "@veriworkly/ui";
+
+import { cn } from "@/lib/utils";
+
+type EditorPanel = "content" | "settings";
+type MobileTab = "editor" | "preview";
+
+interface DocumentEditorShellProps {
+ toolbar: ReactNode;
+ modals?: ReactNode;
+ contentPanel: ReactNode;
+ settingsPanel: ReactNode;
+ preview: ReactNode;
+ previewTitle: string;
+ previewId?: string;
+ previewStageClassName?: string;
+ contentLabel?: string;
+ settingsLabel?: string;
+ defaultPanel?: EditorPanel;
+}
+
+export function DocumentEditorShell({
+ toolbar,
+ modals,
+ contentPanel,
+ settingsPanel,
+ preview,
+ previewTitle,
+ previewId,
+ previewStageClassName,
+ contentLabel = "Content",
+ settingsLabel = "Settings",
+ defaultPanel = "content",
+}: DocumentEditorShellProps) {
+ const [panelOpen, setPanelOpen] = useState(true);
+ const [activeTab, setActiveTab] = useState("editor");
+ const [activePanel, setActivePanel] = useState(defaultPanel);
+
+ return (
+
+
{toolbar}
+
+ {modals}
+
+
+
+
+
+
+
+
+ {panelOpen ? (
+
+
+
+
+
setActivePanel("content")}
+ />
+
+ setActivePanel("settings")}
+ />
+
+
+
+
+
+
+ {activePanel === "content" ? contentPanel : settingsPanel}
+
+
+
+ ) : (
+
+
+
+ Panels
+
+
+
+
+
+
+
+ )}
+
+
+
+
+
+ Live Preview
+
+
{previewTitle}
+
+
+
+
+
+
+
+ );
+}
+
+function PanelTabButton({
+ active,
+ label,
+ onClick,
+}: {
+ active: boolean;
+ label: string;
+ onClick: () => void;
+}) {
+ return (
+
+ );
+}
diff --git a/apps/studio/app/(main)/editor/components/content/editor-options.ts b/apps/studio/features/documents/editor/link-options.ts
similarity index 57%
rename from apps/studio/app/(main)/editor/components/content/editor-options.ts
rename to apps/studio/features/documents/editor/link-options.ts
index ceba053..8b07011 100644
--- a/apps/studio/app/(main)/editor/components/content/editor-options.ts
+++ b/apps/studio/features/documents/editor/link-options.ts
@@ -1,11 +1,3 @@
-export const proficiencyOptions = [
- "Beginner",
- "Intermediate",
- "Advanced",
- "Fluent",
- "Native",
-] as const;
-
export const linkTypeOptions = [
"github",
"linkedin",
diff --git a/apps/studio/app/(main)/editor/components/toolbar/ToolbarActionsMenu.tsx b/apps/studio/features/documents/editor/toolbar/ToolbarActionsMenu.tsx
similarity index 95%
rename from apps/studio/app/(main)/editor/components/toolbar/ToolbarActionsMenu.tsx
rename to apps/studio/features/documents/editor/toolbar/ToolbarActionsMenu.tsx
index 4e589ee..9e0fd15 100644
--- a/apps/studio/app/(main)/editor/components/toolbar/ToolbarActionsMenu.tsx
+++ b/apps/studio/features/documents/editor/toolbar/ToolbarActionsMenu.tsx
@@ -10,8 +10,7 @@ import {
FolderInput,
} from "lucide-react";
-import { Button } from "@veriworkly/ui";
-import { Menu, MenuItem, MenuSeparator } from "@veriworkly/ui";
+import { Button, Menu, MenuItem, MenuSeparator } from "@veriworkly/ui";
interface ToolbarActionsMenuProps {
onDelete: () => void;
diff --git a/apps/studio/app/(main)/editor/components/toolbar/ToolbarDownloadMenu.tsx b/apps/studio/features/documents/editor/toolbar/ToolbarDownloadMenu.tsx
similarity index 97%
rename from apps/studio/app/(main)/editor/components/toolbar/ToolbarDownloadMenu.tsx
rename to apps/studio/features/documents/editor/toolbar/ToolbarDownloadMenu.tsx
index 2410799..4ac2f96 100644
--- a/apps/studio/app/(main)/editor/components/toolbar/ToolbarDownloadMenu.tsx
+++ b/apps/studio/features/documents/editor/toolbar/ToolbarDownloadMenu.tsx
@@ -6,13 +6,12 @@ import {
FileText,
FileJson,
Download,
+ FileDown,
FileCode2,
ChevronDown,
- FileDown,
} from "lucide-react";
-import { Button } from "@veriworkly/ui";
-import { Menu, MenuItem } from "@veriworkly/ui";
+import { Button, Menu, MenuItem } from "@veriworkly/ui";
interface ToolbarDownloadMenuProps {
activeDownload: string | null;
diff --git a/apps/studio/app/(main)/editor/components/toolbar/ToolbarHeader.tsx b/apps/studio/features/documents/editor/toolbar/ToolbarHeader.tsx
similarity index 87%
rename from apps/studio/app/(main)/editor/components/toolbar/ToolbarHeader.tsx
rename to apps/studio/features/documents/editor/toolbar/ToolbarHeader.tsx
index c707711..67dacbb 100644
--- a/apps/studio/app/(main)/editor/components/toolbar/ToolbarHeader.tsx
+++ b/apps/studio/features/documents/editor/toolbar/ToolbarHeader.tsx
@@ -10,7 +10,7 @@ interface ToolbarHeaderProps {
onBack: () => void;
}
-const ToolbarHeader = ({ message, title = "Resume Editor", onBack }: ToolbarHeaderProps) => {
+const ToolbarHeader = ({ message, title = "Document Editor", onBack }: ToolbarHeaderProps) => {
return (