+
+
+
+ Resume Branches
+
-
- {common.header.nav.home}
- {common.header.nav.dashboard}
- {common.header.nav.blog}
+
+
+
+ Home
+
+
+ Dashboard
+
+
+ Docs
+
-
-
{common.header.actions.signIn}
+
+
+
+ Sign In
+
+
+ Get Started
+
diff --git a/apps/webapp/src/components/cv/CVTree.tsx b/apps/webapp/src/components/cv/CVTree.tsx
new file mode 100644
index 0000000..630591f
--- /dev/null
+++ b/apps/webapp/src/components/cv/CVTree.tsx
@@ -0,0 +1,197 @@
+'use client';
+
+import { useState } from 'react';
+import { CVTreeNode } from '@/types/cv';
+
+interface CVTreeProps {
+ treeData: CVTreeNode;
+ selectedNodeId?: string;
+ onNodeSelect: (nodeId: string) => void;
+ onCreateBranch: (parentId: string) => void;
+ onCreateSubmission: (branchId: string) => void;
+}
+
+const NODE_COLORS = {
+ root: 'bg-blue-100 border-blue-300 text-blue-900',
+ branch: 'bg-green-100 border-green-300 text-green-900',
+ submission: 'bg-yellow-100 border-yellow-300 text-yellow-900',
+};
+
+const STATUS_COLORS = {
+ draft: 'bg-gray-100 text-gray-700',
+ submitted: 'bg-yellow-100 text-yellow-700',
+ interviewing: 'bg-blue-100 text-blue-700',
+ offer: 'bg-green-100 text-green-700',
+ rejected: 'bg-red-100 text-red-700',
+ closed: 'bg-gray-100 text-gray-700',
+};
+
+function TreeNode({
+ node,
+ level = 0,
+ selectedNodeId,
+ onNodeSelect,
+ onCreateBranch,
+ onCreateSubmission
+}: {
+ node: CVTreeNode;
+ level?: number;
+ selectedNodeId?: string;
+ onNodeSelect: (nodeId: string) => void;
+ onCreateBranch: (parentId: string) => void;
+ onCreateSubmission: (branchId: string) => void;
+}) {
+ const [isExpanded, setIsExpanded] = useState(true);
+ const hasChildren = node.children.length > 0;
+ const isSelected = selectedNodeId === node.id;
+
+ const handleNodeClick = () => {
+ onNodeSelect(node.id);
+ };
+
+ const handleCreateBranch = (e: React.MouseEvent) => {
+ e.stopPropagation();
+ onCreateBranch(node.id);
+ };
+
+ const handleCreateSubmission = (e: React.MouseEvent) => {
+ e.stopPropagation();
+ onCreateSubmission(node.id);
+ };
+
+ const toggleExpanded = (e: React.MouseEvent) => {
+ e.stopPropagation();
+ setIsExpanded(!isExpanded);
+ };
+
+ return (
+
+
+ {hasChildren && (
+
+
+
+
+
+ )}
+
+ {!hasChildren &&
}
+
+
+
+ {node.label}
+ {node.metadata?.isPublic && (
+
+ Public
+
+ )}
+ {node.metadata?.status && (
+
+ {node.metadata.status}
+
+ )}
+
+
+ {node.metadata?.companyName && (
+
+ {node.metadata.companyName} • {node.metadata.roleTitle}
+
+ )}
+
+ {node.metadata?.lastModified && (
+
+ Updated {new Date(node.metadata.lastModified).toLocaleDateString()}
+
+ )}
+
+
+
+ {(node.type === 'root' || node.type === 'branch') && (
+
+
+
+
+
+ )}
+
+ {node.type === 'branch' && (
+
+
+
+
+
+ )}
+
+
+
+ {hasChildren && isExpanded && (
+
+ {node.children.map((child) => (
+
+ ))}
+
+ )}
+
+ );
+}
+
+export default function CVTree({
+ treeData,
+ selectedNodeId,
+ onNodeSelect,
+ onCreateBranch,
+ onCreateSubmission
+}: CVTreeProps) {
+ return (
+
+
+
CV Versions
+
+ {treeData.children.reduce((acc, branch) => acc + branch.children.length + 1, 1)} versions
+
+
+
+
+
+
+
+ );
+}
\ No newline at end of file
diff --git a/apps/webapp/src/components/cv/DiffViewer.tsx b/apps/webapp/src/components/cv/DiffViewer.tsx
new file mode 100644
index 0000000..20b7ef1
--- /dev/null
+++ b/apps/webapp/src/components/cv/DiffViewer.tsx
@@ -0,0 +1,180 @@
+'use client';
+
+import { useState } from 'react';
+import { PatchDiff } from '@/types/cv';
+
+interface DiffViewerProps {
+ patches: PatchDiff[];
+ title?: string;
+ className?: string;
+}
+
+function DiffLine({
+ diff,
+ isExpanded,
+ onToggle
+}: {
+ diff: PatchDiff;
+ isExpanded: boolean;
+ onToggle: () => void;
+}) {
+ const getTypeColor = (type: string) => {
+ switch (type) {
+ case 'added': return 'bg-green-50 border-green-200';
+ case 'removed': return 'bg-red-50 border-red-200';
+ case 'changed': return 'bg-yellow-50 border-yellow-200';
+ default: return 'bg-gray-50 border-gray-200';
+ }
+ };
+
+ const getTypeIcon = (type: string) => {
+ switch (type) {
+ case 'added':
+ return (
+
+ );
+ case 'removed':
+ return (
+
+ );
+ case 'changed':
+ return (
+
+ );
+ default:
+ return
;
+ }
+ };
+
+ return (
+
+
+ {getTypeIcon(diff.type)}
+
+
+
+ {diff.path}
+
+ {isExpanded ? 'Hide' : 'Show'} details
+
+
+
+ {diff.context && (
+
{diff.context}
+ )}
+
+ {isExpanded && (
+
+ {diff.oldValue && (
+
+
- Removed
+
{diff.oldValue}
+
+ )}
+
+ {diff.newValue && (
+
+
+ Added
+
{diff.newValue}
+
+ )}
+
+ )}
+
+
+
+ );
+}
+
+export default function DiffViewer({ patches, title = "Changes", className = "" }: DiffViewerProps) {
+ const [expandedItems, setExpandedItems] = useState
>(new Set());
+
+ const toggleExpanded = (index: number) => {
+ const newExpanded = new Set(expandedItems);
+ if (newExpanded.has(index)) {
+ newExpanded.delete(index);
+ } else {
+ newExpanded.add(index);
+ }
+ setExpandedItems(newExpanded);
+ };
+
+ if (patches.length === 0) {
+ return (
+
+
+
+
+
+
No changes
+
This version is identical to its parent
+
+
+ );
+ }
+
+ const changeCount = patches.length;
+ const addedCount = patches.filter(p => p.type === 'added').length;
+ const removedCount = patches.filter(p => p.type === 'removed').length;
+ const changedCount = patches.filter(p => p.type === 'changed').length;
+
+ return (
+
+
+
+
{title}
+
+ {addedCount > 0 && (
+
+
+ {addedCount} added
+
+ )}
+ {changedCount > 0 && (
+
+
+ {changedCount} changed
+
+ )}
+ {removedCount > 0 && (
+
+
+ {removedCount} removed
+
+ )}
+
+
+
+
+ {changeCount} {changeCount === 1 ? 'change' : 'changes'} detected
+
+
+
+
+ {patches.map((patch, index) => (
+ toggleExpanded(index)}
+ />
+ ))}
+
+
+ );
+}
\ No newline at end of file
diff --git a/apps/webapp/src/types/cv.ts b/apps/webapp/src/types/cv.ts
new file mode 100644
index 0000000..0a9282e
--- /dev/null
+++ b/apps/webapp/src/types/cv.ts
@@ -0,0 +1,133 @@
+// Core data types for Resume Branches system
+
+export interface CVDocument {
+ id: string;
+ ownerId: string;
+ title: string;
+ rootVersionId: string;
+ createdAt: string;
+ updatedAt: string;
+}
+
+export interface CVVersion {
+ id: string;
+ documentId: string;
+ parentVersionId?: string;
+ branchName?: string;
+ versionLabel: string;
+ artifactDocxKey: string;
+ previewHtmlKey?: string;
+ createdBy: string;
+ createdAt: string;
+ isPublic: boolean;
+}
+
+export interface CVPatch {
+ id: string;
+ versionId: string;
+ patchType: 'text_replace' | 'reorder' | 'add' | 'remove';
+ targetPath: string; // e.g., "experience[1].bullets[2]" or "summary.paragraph_1"
+ operation: string;
+ oldValue?: string;
+ newValue?: string;
+ metadata?: Record;
+}
+
+export interface Specialization {
+ id: string;
+ documentId: string;
+ name: string;
+ basedOnVersionId: string;
+ description?: string;
+ color?: string; // For UI visualization
+}
+
+export interface Submission {
+ id: string;
+ versionId: string;
+ companyName: string;
+ roleTitle: string;
+ jobUrl?: string;
+ jobDescription?: string;
+ status: 'draft' | 'submitted' | 'rejected' | 'interviewing' | 'offer' | 'closed';
+ publicAssetId?: string;
+ createdAt: string;
+ updatedAt: string;
+}
+
+export interface PublicAsset {
+ id: string;
+ submissionId?: string;
+ versionId?: string;
+ slug: string;
+ artifactKey: string;
+ isPublic: boolean;
+ expiresAt?: string;
+ viewCount: number;
+}
+
+export interface AISuggestion {
+ id: string;
+ submissionId: string;
+ targetPath: string;
+ suggestionType: 'strengthen_verb' | 'add_keyword' | 'reorder' | 'shorten' | 'expand';
+ proposedText: string;
+ rationale: string;
+ accepted?: boolean;
+ appliedAt?: string;
+}
+
+// UI-specific types
+export interface CVTreeNode {
+ id: string;
+ label: string;
+ type: 'root' | 'branch' | 'submission';
+ versionId: string;
+ parentId?: string;
+ children: CVTreeNode[];
+ metadata?: {
+ companyName?: string;
+ roleTitle?: string;
+ status?: string;
+ isPublic?: boolean;
+ lastModified?: string;
+ };
+}
+
+export interface PatchDiff {
+ path: string;
+ type: 'added' | 'removed' | 'changed';
+ oldValue?: string;
+ newValue?: string;
+ context?: string;
+}
+
+export interface CVStructure {
+ header: {
+ name: string;
+ title: string;
+ contact: string[];
+ };
+ summary?: {
+ paragraphs: string[];
+ };
+ experience: Array<{
+ company: string;
+ title: string;
+ duration: string;
+ bullets: string[];
+ }>;
+ education: Array<{
+ institution: string;
+ degree: string;
+ duration: string;
+ details?: string[];
+ }>;
+ skills: {
+ [category: string]: string[];
+ };
+ sections?: Array<{
+ title: string;
+ content: string[] | Record;
+ }>;
+}
\ No newline at end of file
diff --git a/apps/webapp/tailwind.config.ts b/apps/webapp/tailwind.config.ts
new file mode 100644
index 0000000..b2339ae
--- /dev/null
+++ b/apps/webapp/tailwind.config.ts
@@ -0,0 +1,107 @@
+import type { Config } from "tailwindcss";
+
+const config: Config = {
+ content: [
+ "./src/pages/**/*.{js,ts,jsx,tsx,mdx}",
+ "./src/components/**/*.{js,ts,jsx,tsx,mdx}",
+ "./src/app/**/*.{js,ts,jsx,tsx,mdx}",
+ ],
+ theme: {
+ extend: {
+ colors: {
+ background: "var(--background)",
+ foreground: "var(--foreground)",
+ primary: {
+ 50: "#f0f9ff",
+ 100: "#e0f2fe",
+ 200: "#bae6fd",
+ 300: "#7dd3fc",
+ 400: "#38bdf8",
+ 500: "#0ea5e9",
+ 600: "#0284c7",
+ 700: "#0369a1",
+ 800: "#075985",
+ 900: "#0c4a6e",
+ },
+ gray: {
+ 50: "#f9fafb",
+ 100: "#f3f4f6",
+ 200: "#e5e7eb",
+ 300: "#d1d5db",
+ 400: "#9ca3af",
+ 500: "#6b7280",
+ 600: "#4b5563",
+ 700: "#374151",
+ 800: "#1f2937",
+ 900: "#111827",
+ },
+ success: {
+ 50: "#ecfdf5",
+ 100: "#d1fae5",
+ 200: "#a7f3d0",
+ 300: "#6ee7b7",
+ 400: "#34d399",
+ 500: "#10b981",
+ 600: "#059669",
+ 700: "#047857",
+ 800: "#065f46",
+ 900: "#064e3b",
+ },
+ warning: {
+ 50: "#fffbeb",
+ 100: "#fef3c7",
+ 200: "#fde68a",
+ 300: "#fcd34d",
+ 400: "#fbbf24",
+ 500: "#f59e0b",
+ 600: "#d97706",
+ 700: "#b45309",
+ 800: "#92400e",
+ 900: "#78350f",
+ },
+ error: {
+ 50: "#fef2f2",
+ 100: "#fee2e2",
+ 200: "#fecaca",
+ 300: "#fca5a5",
+ 400: "#f87171",
+ 500: "#ef4444",
+ 600: "#dc2626",
+ 700: "#b91c1c",
+ 800: "#991b1b",
+ 900: "#7f1d1d",
+ }
+ },
+ fontFamily: {
+ sans: ["var(--font-sans)", "Inter", "system-ui", "sans-serif"],
+ mono: ["var(--font-mono)", "Consolas", "Monaco", "monospace"],
+ },
+ spacing: {
+ '18': '4.5rem',
+ '88': '22rem',
+ },
+ animation: {
+ 'fade-in': 'fadeIn 0.5s ease-in-out',
+ 'slide-up': 'slideUp 0.3s ease-out',
+ 'slide-down': 'slideDown 0.3s ease-out',
+ },
+ keyframes: {
+ fadeIn: {
+ '0%': { opacity: '0' },
+ '100%': { opacity: '1' },
+ },
+ slideUp: {
+ '0%': { transform: 'translateY(10px)', opacity: '0' },
+ '100%': { transform: 'translateY(0)', opacity: '1' },
+ },
+ slideDown: {
+ '0%': { transform: 'translateY(-10px)', opacity: '0' },
+ '100%': { transform: 'translateY(0)', opacity: '1' },
+ },
+ },
+ },
+ },
+ plugins: [],
+};
+
+export default config;
\ No newline at end of file