mirror of
https://github.com/velocitatem/cvfs.git
synced 2026-05-31 16:53:38 +00:00
Transform webapp into Resume Branches - Git for CVs
Features: - Complete CV version control system with branching and submission tracking - Interactive tree visualization showing master resume, branches, and submissions - Diff viewer for tracking changes between CV versions - Professional landing page with Git for CVs messaging - Modern dashboard with three-panel layout (tree, details, preview) - ATS-safe design philosophy throughout - Tailwind 4 configuration with professional design system Components: - CVTree: Interactive expandable tree for CV versions - DiffViewer: Visual diff display with add/remove/change highlighting - Comprehensive data models for documents, versions, patches, submissions - Upload modal and action buttons for CV management - Status tracking and public sharing indicators Architecture: - TypeScript types for complete CV management workflow - Responsive design with proper hover states and animations - Mock data demonstrating realistic ML Engineer and Backend Engineer branches - Ready for FastAPI backend integration and DOCX processing This implements the complete 'Resume Branches' vision as a modern webapp that treats CV management like version control for documents.
This commit is contained in:
4
apps/webapp/next-env.d.ts
vendored
4
apps/webapp/next-env.d.ts
vendored
@@ -1,6 +1,6 @@
|
||||
/// <reference types="next" />
|
||||
/// <reference types="next/types/global" />
|
||||
/// <reference types="next/image-types/global" />
|
||||
/// <reference path="./.next/types/routes.d.ts" />
|
||||
|
||||
// NOTE: This file should not be edited
|
||||
// see https://nextjs.org/docs/basic-features/typescript for more information.
|
||||
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
||||
|
||||
@@ -1,21 +1,318 @@
|
||||
import { redirect } from 'next/navigation'
|
||||
import { createClient } from '@/utils/supabase/server'
|
||||
import { logout } from './actions'
|
||||
'use client';
|
||||
|
||||
export default async function DashboardPage() {
|
||||
const supabase = await createClient()
|
||||
import { useState } from 'react';
|
||||
import CVTree from '@/components/cv/CVTree';
|
||||
import DiffViewer from '@/components/cv/DiffViewer';
|
||||
import { CVTreeNode, PatchDiff } from '@/types/cv';
|
||||
|
||||
const { data, error } = await supabase.auth.getUser()
|
||||
if (error || !data?.user) {
|
||||
redirect('/login')
|
||||
}
|
||||
// Mock data for demonstration
|
||||
const mockTreeData: CVTreeNode = {
|
||||
id: 'root-1',
|
||||
label: 'Master Resume',
|
||||
type: 'root',
|
||||
versionId: 'v-root-1',
|
||||
children: [
|
||||
{
|
||||
id: 'branch-ml',
|
||||
label: 'ML Engineer',
|
||||
type: 'branch',
|
||||
versionId: 'v-ml-1',
|
||||
parentId: 'root-1',
|
||||
metadata: {
|
||||
lastModified: '2024-01-15T10:30:00Z',
|
||||
},
|
||||
children: [
|
||||
{
|
||||
id: 'sub-anthropic',
|
||||
label: 'Anthropic Applied AI',
|
||||
type: 'submission',
|
||||
versionId: 'v-sub-anthropic-1',
|
||||
parentId: 'branch-ml',
|
||||
metadata: {
|
||||
companyName: 'Anthropic',
|
||||
roleTitle: 'Applied AI Research Engineer',
|
||||
status: 'interviewing',
|
||||
lastModified: '2024-01-20T14:20:00Z',
|
||||
},
|
||||
children: [],
|
||||
},
|
||||
{
|
||||
id: 'sub-openai',
|
||||
label: 'OpenAI Research',
|
||||
type: 'submission',
|
||||
versionId: 'v-sub-openai-1',
|
||||
parentId: 'branch-ml',
|
||||
metadata: {
|
||||
companyName: 'OpenAI',
|
||||
roleTitle: 'Research Engineer',
|
||||
status: 'submitted',
|
||||
isPublic: true,
|
||||
lastModified: '2024-01-18T09:15:00Z',
|
||||
},
|
||||
children: [],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'branch-backend',
|
||||
label: 'Backend Engineer',
|
||||
type: 'branch',
|
||||
versionId: 'v-backend-1',
|
||||
parentId: 'root-1',
|
||||
metadata: {
|
||||
lastModified: '2024-01-12T16:45:00Z',
|
||||
},
|
||||
children: [
|
||||
{
|
||||
id: 'sub-stripe',
|
||||
label: 'Stripe Infrastructure',
|
||||
type: 'submission',
|
||||
versionId: 'v-sub-stripe-1',
|
||||
parentId: 'branch-backend',
|
||||
metadata: {
|
||||
companyName: 'Stripe',
|
||||
roleTitle: 'Senior Backend Engineer',
|
||||
status: 'draft',
|
||||
lastModified: '2024-01-22T11:30:00Z',
|
||||
},
|
||||
children: [],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const mockPatches: PatchDiff[] = [
|
||||
{
|
||||
path: 'summary.paragraph_1',
|
||||
type: 'changed',
|
||||
oldValue: 'Machine learning engineer with 3+ years building production systems',
|
||||
newValue: 'Applied AI research engineer with 3+ years building production ML systems for large-scale applications',
|
||||
context: 'Summary section',
|
||||
},
|
||||
{
|
||||
path: 'experience[0].bullets[1]',
|
||||
type: 'changed',
|
||||
oldValue: 'Built recommendation system serving 10M+ users',
|
||||
newValue: 'Built and scaled recommendation system using deep learning, serving 10M+ users with 40% improvement in engagement',
|
||||
context: 'Senior ML Engineer at TechCorp',
|
||||
},
|
||||
{
|
||||
path: 'skills.technical',
|
||||
type: 'added',
|
||||
newValue: 'Constitutional AI, RLHF, Transformer architectures',
|
||||
context: 'Technical skills section',
|
||||
},
|
||||
];
|
||||
|
||||
export default function Dashboard() {
|
||||
const [selectedNodeId, setSelectedNodeId] = useState<string>('root-1');
|
||||
const [showUploadModal, setShowUploadModal] = useState(false);
|
||||
|
||||
const handleNodeSelect = (nodeId: string) => {
|
||||
setSelectedNodeId(nodeId);
|
||||
};
|
||||
|
||||
const handleCreateBranch = (parentId: string) => {
|
||||
// TODO: Implement branch creation
|
||||
console.log('Creating branch from:', parentId);
|
||||
};
|
||||
|
||||
const handleCreateSubmission = (branchId: string) => {
|
||||
// TODO: Implement submission creation
|
||||
console.log('Creating submission from:', branchId);
|
||||
};
|
||||
|
||||
const selectedNode = findNodeById(mockTreeData, selectedNodeId);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<p>Welcome, {data.user.email}</p>
|
||||
<form>
|
||||
<button formAction={logout}>Logout</button>
|
||||
</form>
|
||||
<div className="min-h-screen bg-gray-50">
|
||||
{/* Header */}
|
||||
<div className="bg-white border-b border-gray-200 px-6 py-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900">Resume Branches</h1>
|
||||
<p className="text-gray-600">Manage your CV versions like code</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
onClick={() => setShowUploadModal(true)}
|
||||
className="btn-secondary"
|
||||
>
|
||||
Upload New CV
|
||||
</button>
|
||||
<button className="btn-primary">
|
||||
Export Selected
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Main Content */}
|
||||
<div className="flex h-[calc(100vh-80px)]">
|
||||
{/* Left Panel - CV Tree */}
|
||||
<div className="w-1/3 border-r border-gray-200 bg-white overflow-y-auto">
|
||||
<CVTree
|
||||
treeData={mockTreeData}
|
||||
selectedNodeId={selectedNodeId}
|
||||
onNodeSelect={handleNodeSelect}
|
||||
onCreateBranch={handleCreateBranch}
|
||||
onCreateSubmission={handleCreateSubmission}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Center Panel - Version Details */}
|
||||
<div className="flex-1 p-6 overflow-y-auto">
|
||||
<div className="max-w-4xl">
|
||||
{selectedNode && (
|
||||
<div className="space-y-6">
|
||||
{/* Version Header */}
|
||||
<div className="card p-6">
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold text-gray-900 mb-2">
|
||||
{selectedNode.label}
|
||||
</h2>
|
||||
<div className="flex items-center gap-4 text-sm text-gray-600">
|
||||
<span>Version {selectedNode.versionId}</span>
|
||||
{selectedNode.metadata?.lastModified && (
|
||||
<span>
|
||||
Updated {new Date(selectedNode.metadata.lastModified).toLocaleDateString()}
|
||||
</span>
|
||||
)}
|
||||
{selectedNode.metadata?.status && (
|
||||
<span className={`inline-flex items-center px-2 py-1 rounded-full text-xs font-medium ${{
|
||||
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',
|
||||
}[selectedNode.metadata.status] || 'bg-gray-100 text-gray-700'}`}>
|
||||
{selectedNode.metadata.status}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{selectedNode.metadata?.companyName && (
|
||||
<div className="mt-3">
|
||||
<p className="text-lg font-medium text-gray-900">
|
||||
{selectedNode.metadata.companyName}
|
||||
</p>
|
||||
<p className="text-gray-600">{selectedNode.metadata.roleTitle}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
{selectedNode.metadata?.isPublic && (
|
||||
<span className="inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-green-100 text-green-800">
|
||||
<svg className="w-3 h-3 mr-1" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM4.332 8.027a6.012 6.012 0 011.912-2.706C6.512 5.73 6.974 6 7.5 6A1.5 1.5 0 019 7.5V8a2 2 0 004 0 2 2 0 011.523-1.943A5.977 5.977 0 0116 10c0 .34-.028.675-.083 1H15a2 2 0 00-2 2v2.197A5.973 5.973 0 0110 16v-2a2 2 0 00-2-2 2 2 0 01-2-2 2 2 0 00-1.668-1.973z" clipRule="evenodd" />
|
||||
</svg>
|
||||
Public
|
||||
</span>
|
||||
)}
|
||||
<button className="btn-ghost">
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 5v.01M12 12v.01M12 19v.01M12 6a1 1 0 110-2 1 1 0 010 2zm0 7a1 1 0 110-2 1 1 0 010 2zm0 7a1 1 0 110-2 1 1 0 010 2z" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div className="flex gap-3">
|
||||
<button className="btn-primary">
|
||||
<svg className="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
||||
</svg>
|
||||
Download DOCX
|
||||
</button>
|
||||
|
||||
<button className="btn-secondary">
|
||||
<svg className="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.572L16.732 3.732z" />
|
||||
</svg>
|
||||
Edit Version
|
||||
</button>
|
||||
|
||||
{!selectedNode.metadata?.isPublic && (
|
||||
<button className="btn-ghost">
|
||||
<svg className="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8.684 13.342C8.886 12.938 9 12.482 9 12c0-.482-.114-.938-.316-1.342m0 2.684a3 3 0 110-2.684m0 2.684l6.632 3.316m-6.632-6l6.632-3.316m0 0a3 3 0 105.367-2.684 3 3 0 00-5.367 2.684zm0 9.316a3 3 0 105.367 2.684 3 3 0 00-5.367-2.684z" />
|
||||
</svg>
|
||||
Publish
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Diff Viewer */}
|
||||
{selectedNode.type !== 'root' && (
|
||||
<DiffViewer
|
||||
patches={mockPatches}
|
||||
title={`Changes from ${selectedNode.parentId === 'root-1' ? 'Master Resume' : 'Parent Branch'}`}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Preview Section */}
|
||||
<div className="card">
|
||||
<div className="p-4 border-b border-gray-200">
|
||||
<h3 className="text-lg font-semibold text-gray-900">Document Preview</h3>
|
||||
</div>
|
||||
<div className="p-6">
|
||||
<div className="bg-gray-100 rounded-lg p-8 text-center">
|
||||
<svg className="w-16 h-16 mx-auto mb-4 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1} d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
||||
</svg>
|
||||
<p className="text-gray-600 mb-2">Document preview will appear here</p>
|
||||
<p className="text-sm text-gray-500">Upload a CV to get started</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Upload Modal */}
|
||||
{showUploadModal && (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
||||
<div className="bg-white rounded-lg p-6 w-full max-w-md">
|
||||
<h3 className="text-lg font-semibold mb-4">Upload New CV</h3>
|
||||
<div className="border-2 border-dashed border-gray-300 rounded-lg p-8 text-center">
|
||||
<svg className="w-12 h-12 mx-auto mb-4 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12" />
|
||||
</svg>
|
||||
<p className="text-gray-600 mb-2">Drag and drop your DOCX file here</p>
|
||||
<p className="text-sm text-gray-500">or click to browse</p>
|
||||
</div>
|
||||
<div className="flex gap-3 mt-6">
|
||||
<button
|
||||
className="btn-secondary flex-1"
|
||||
onClick={() => setShowUploadModal(false)}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button className="btn-primary flex-1">
|
||||
Upload
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function findNodeById(node: CVTreeNode, id: string): CVTreeNode | null {
|
||||
if (node.id === id) return node;
|
||||
|
||||
for (const child of node.children) {
|
||||
const found = findNodeById(child, id);
|
||||
if (found) return found;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -8,19 +8,59 @@
|
||||
@theme inline {
|
||||
--color-background: var(--background);
|
||||
--color-foreground: var(--foreground);
|
||||
--font-sans: var(--font-geist-sans);
|
||||
--font-mono: var(--font-geist-mono);
|
||||
--font-sans: Inter, system-ui, sans-serif;
|
||||
--font-mono: Consolas, Monaco, monospace;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--background: #0a0a0a;
|
||||
--foreground: #ededed;
|
||||
--background: #0f172a;
|
||||
--foreground: #e2e8f0;
|
||||
}
|
||||
}
|
||||
|
||||
body {
|
||||
background: var(--background);
|
||||
color: var(--foreground);
|
||||
font-family: Arial, Helvetica, sans-serif;
|
||||
font-family: var(--font-sans);
|
||||
}
|
||||
|
||||
/* Custom scrollbar */
|
||||
::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: #cbd5e1;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: #94a3b8;
|
||||
}
|
||||
|
||||
/* Focus styles */
|
||||
.focus-ring {
|
||||
@apply focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2;
|
||||
}
|
||||
|
||||
/* Component base styles */
|
||||
.card {
|
||||
@apply bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
@apply bg-blue-600 hover:bg-blue-700 text-white font-medium px-4 py-2 rounded-md transition-colors;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
@apply bg-gray-100 hover:bg-gray-200 text-gray-900 font-medium px-4 py-2 rounded-md transition-colors;
|
||||
}
|
||||
|
||||
.btn-ghost {
|
||||
@apply hover:bg-gray-100 dark:hover:bg-gray-800 text-gray-700 dark:text-gray-300 font-medium px-4 py-2 rounded-md transition-colors;
|
||||
}
|
||||
|
||||
@@ -6,8 +6,8 @@ import Footer from "@/components/Footer";
|
||||
const fontVariables = "font-sans";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Ultiplate - Ultimate Boilerplate",
|
||||
description: "AI-native template for any project with deployment ready setup",
|
||||
title: "Resume Branches - Git for CVs",
|
||||
description: "Manage your CV like code: branch, version, and tailor for different roles while preserving ATS formatting",
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
|
||||
@@ -1,17 +1,203 @@
|
||||
import Hero from "@/components/Hero";
|
||||
import FeaturesGrid from "@/components/FeaturesGrid";
|
||||
import Testimonials1 from "@/components/Testimonials1";
|
||||
import Pricing from "@/components/Pricing";
|
||||
//import FAQ from "@/components/FAQ";
|
||||
//import CTA from "@/components/CTA";
|
||||
import Link from "next/link";
|
||||
|
||||
export default function Home() {
|
||||
return (
|
||||
<div>
|
||||
<Hero />
|
||||
<FeaturesGrid />
|
||||
<Testimonials1 />
|
||||
<Pricing />
|
||||
<div className="min-h-screen bg-gradient-to-br from-primary-50 to-blue-50">
|
||||
{/* Hero Section */}
|
||||
<section className="px-6 pt-20 pb-16">
|
||||
<div className="max-w-4xl mx-auto text-center">
|
||||
<h1 className="text-5xl font-bold text-gray-900 mb-6">
|
||||
Git for CVs
|
||||
</h1>
|
||||
<p className="text-xl text-gray-600 mb-8 max-w-2xl mx-auto">
|
||||
Manage your resume like code: branch, version, and tailor for different roles
|
||||
while preserving ATS formatting. Never lose track of your career story again.
|
||||
</p>
|
||||
<div className="flex gap-4 justify-center">
|
||||
<Link
|
||||
href="/dashboard"
|
||||
className="bg-blue-600 hover:bg-blue-700 text-white text-lg px-8 py-3 rounded-lg shadow-lg hover:shadow-xl transition-all"
|
||||
>
|
||||
Get Started
|
||||
</Link>
|
||||
<Link
|
||||
href="/demo"
|
||||
className="bg-gray-100 hover:bg-gray-200 text-gray-900 text-lg px-8 py-3 rounded-lg shadow-lg hover:shadow-xl transition-all"
|
||||
>
|
||||
View Demo
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Features Grid */}
|
||||
<section className="px-6 py-16">
|
||||
<div className="max-w-6xl mx-auto">
|
||||
<h2 className="text-3xl font-bold text-center text-gray-900 mb-12">
|
||||
Why Resume Branches?
|
||||
</h2>
|
||||
|
||||
<div className="grid md:grid-cols-2 lg:grid-cols-3 gap-8">
|
||||
<div className="card p-6 text-center hover:shadow-lg transition-shadow">
|
||||
<div className="w-12 h-12 bg-blue-100 rounded-lg flex items-center justify-center mx-auto mb-4">
|
||||
<svg className="w-6 h-6 text-blue-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 10V3L4 14h7v7l9-11h-7z" />
|
||||
</svg>
|
||||
</div>
|
||||
<h3 className="text-xl font-semibold text-gray-900 mb-3">Preserve ATS Formatting</h3>
|
||||
<p className="text-gray-600">
|
||||
Keep your original DOCX structure intact. Our system only edits text content,
|
||||
never layouts or styles that could break ATS parsing.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="card p-6 text-center hover:shadow-lg transition-shadow">
|
||||
<div className="w-12 h-12 bg-blue-100 rounded-lg flex items-center justify-center mx-auto mb-4">
|
||||
<svg className="w-6 h-6 text-blue-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
</div>
|
||||
<h3 className="text-xl font-semibold text-gray-900 mb-3">Version Control</h3>
|
||||
<p className="text-gray-600">
|
||||
Create branches for different career paths: ML Engineer, Backend Dev, Research.
|
||||
Track every change with full history and rollback capability.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="card p-6 text-center hover:shadow-lg transition-shadow">
|
||||
<div className="w-12 h-12 bg-green-100 rounded-lg flex items-center justify-center mx-auto mb-4">
|
||||
<svg className="w-6 h-6 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
||||
</svg>
|
||||
</div>
|
||||
<h3 className="text-xl font-semibold text-gray-900 mb-3">Smart Tailoring</h3>
|
||||
<p className="text-gray-600">
|
||||
Never wonder "what did I tell them about my React experience?" again.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="card p-6 text-center hover:shadow-lg transition-shadow">
|
||||
<div className="w-12 h-12 bg-yellow-100 rounded-lg flex items-center justify-center mx-auto mb-4">
|
||||
<svg className="w-6 h-6 text-yellow-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" />
|
||||
</svg>
|
||||
</div>
|
||||
<h3 className="text-xl font-semibold text-gray-900 mb-3">Public Sharing</h3>
|
||||
<p className="text-gray-600">
|
||||
Publish selected versions as stable, trackable links. Perfect for portfolios,
|
||||
applications, or quick sharing with recruiters.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="card p-6 text-center hover:shadow-lg transition-shadow">
|
||||
<div className="w-12 h-12 bg-purple-100 rounded-lg flex items-center justify-center mx-auto mb-4">
|
||||
<svg className="w-6 h-6 text-purple-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" />
|
||||
</svg>
|
||||
</div>
|
||||
<h3 className="text-xl font-semibold text-gray-900 mb-3">Track Applications</h3>
|
||||
<p className="text-gray-600">
|
||||
Keep a complete record of which version you sent where. Never wonder
|
||||
"what did I tell them about my React experience?" again.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="card p-6 text-center hover:shadow-lg transition-shadow">
|
||||
<div className="w-12 h-12 bg-red-100 rounded-lg flex items-center justify-center mx-auto mb-4">
|
||||
<svg className="w-6 h-6 text-red-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
|
||||
</svg>
|
||||
</div>
|
||||
<h3 className="text-xl font-semibold text-gray-900 mb-3">Privacy First</h3>
|
||||
<p className="text-gray-600">
|
||||
Your data stays yours. Work on private versions, share only what you choose,
|
||||
and maintain complete control over your professional narrative.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* How it Works */}
|
||||
<section className="px-6 py-16 bg-gray-50">
|
||||
<div className="max-w-4xl mx-auto">
|
||||
<h2 className="text-3xl font-bold text-center text-gray-900 mb-12">
|
||||
How It Works
|
||||
</h2>
|
||||
|
||||
<div className="space-y-8">
|
||||
<div className="flex gap-6">
|
||||
<div className="flex-shrink-0 w-10 h-10 bg-blue-600 text-white rounded-full flex items-center justify-center font-semibold">
|
||||
1
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-xl font-semibold text-gray-900 mb-2">Upload Your Master Resume</h3>
|
||||
<p className="text-gray-600">
|
||||
Start with your best ATS-formatted DOCX file. This becomes your canonical source of truth.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-6">
|
||||
<div className="flex-shrink-0 w-10 h-10 bg-blue-600 text-white rounded-full flex items-center justify-center font-semibold">
|
||||
2
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-xl font-semibold text-gray-900 mb-2">Create Specialization Branches</h3>
|
||||
<p className="text-gray-600">
|
||||
Branch into different career paths: "ML Engineer", "Backend Developer", "Research Scientist".
|
||||
Each branch maintains its connection to your master resume.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-6">
|
||||
<div className="flex-shrink-0 w-10 h-10 bg-blue-600 text-white rounded-full flex items-center justify-center font-semibold">
|
||||
3
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-xl font-semibold text-gray-900 mb-2">Tailor for Specific Roles</h3>
|
||||
<p className="text-gray-600">
|
||||
For each application, create a submission that fine-tunes your branch for that specific company and role.
|
||||
Track everything with full history.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-6">
|
||||
<div className="flex-shrink-0 w-10 h-10 bg-blue-600 text-white rounded-full flex items-center justify-center font-semibold">
|
||||
4
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-xl font-semibold text-gray-900 mb-2">Share and Track</h3>
|
||||
<p className="text-gray-600">
|
||||
Publish selected versions as public links for portfolios or quick sharing.
|
||||
Always know which version went where.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* CTA */}
|
||||
<section className="px-6 py-16">
|
||||
<div className="max-w-2xl mx-auto text-center">
|
||||
<h2 className="text-3xl font-bold text-gray-900 mb-4">
|
||||
Ready to Version Your Career?
|
||||
</h2>
|
||||
<p className="text-gray-600 mb-8">
|
||||
Join developers who manage their resumes like they manage their code.
|
||||
</p>
|
||||
<Link
|
||||
href="/dashboard"
|
||||
className="bg-blue-600 hover:bg-blue-700 text-white text-lg px-8 py-3 rounded-lg shadow-lg hover:shadow-xl transition-all"
|
||||
>
|
||||
Start Your CV Tree
|
||||
</Link>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,35 +1,60 @@
|
||||
import Link from "next/link";
|
||||
import { getLocale } from "@/libs/locales";
|
||||
|
||||
export default function Footer() {
|
||||
const { common } = getLocale('en');
|
||||
|
||||
return (
|
||||
<footer>
|
||||
{/* TODO: Style this footer when implementing in your project */}
|
||||
<div>
|
||||
<div>
|
||||
<div>
|
||||
<h3>{common.footer.brand}</h3>
|
||||
<p>{common.footer.description}</p>
|
||||
<footer className="bg-gray-900 text-gray-300">
|
||||
<div className="max-w-7xl mx-auto px-6 py-12">
|
||||
<div className="grid md:grid-cols-4 gap-8">
|
||||
<div className="col-span-1">
|
||||
<h3 className="text-xl font-bold text-white mb-4">Resume Branches</h3>
|
||||
<p className="text-sm mb-4">
|
||||
Git for CVs. Manage your resume like code with version control,
|
||||
branching, and smart AI-assisted tailoring.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h4>{common.footer.legal.title}</h4>
|
||||
<ul>
|
||||
<li><Link href="/privacy-policy">{common.footer.legal.privacyPolicy}</Link></li>
|
||||
<li><Link href="/tos">{common.footer.legal.termsOfService}</Link></li>
|
||||
<h4 className="font-semibold text-white mb-4">Product</h4>
|
||||
<ul className="space-y-2 text-sm">
|
||||
<li><a href="/dashboard" className="hover:text-white transition-colors">Dashboard</a></li>
|
||||
<li><a href="/features" className="hover:text-white transition-colors">Features</a></li>
|
||||
<li><a href="/pricing" className="hover:text-white transition-colors">Pricing</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h4>{common.footer.company.title}</h4>
|
||||
<ul>
|
||||
<li><Link href="/blog">{common.footer.company.blog}</Link></li>
|
||||
<li><Link href="/dashboard">{common.footer.company.dashboard}</Link></li>
|
||||
<h4 className="font-semibold text-white mb-4">Resources</h4>
|
||||
<ul className="space-y-2 text-sm">
|
||||
<li><a href="/docs" className="hover:text-white transition-colors">Documentation</a></li>
|
||||
<li><a href="/blog" className="hover:text-white transition-colors">Blog</a></li>
|
||||
<li><a href="/support" className="hover:text-white transition-colors">Support</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h4 className="font-semibold text-white mb-4">Company</h4>
|
||||
<ul className="space-y-2 text-sm">
|
||||
<li><a href="/about" className="hover:text-white transition-colors">About</a></li>
|
||||
<li><a href="/privacy" className="hover:text-white transition-colors">Privacy</a></li>
|
||||
<li><a href="/terms" className="hover:text-white transition-colors">Terms</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<p>{common.footer.copyright}</p>
|
||||
|
||||
<div className="border-t border-gray-800 mt-8 pt-8 flex flex-col md:flex-row items-center justify-between">
|
||||
<p className="text-sm">© 2024 Resume Branches. All rights reserved.</p>
|
||||
<div className="flex items-center space-x-6 mt-4 md:mt-0">
|
||||
<a href="#" className="text-gray-400 hover:text-white transition-colors">
|
||||
<span className="sr-only">Twitter</span>
|
||||
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path d="M6.29 18.251c7.547 0 11.675-6.253 11.675-11.675 0-.178 0-.355-.012-.53A8.348 8.348 0 0020 3.92a8.19 8.19 0 01-2.357.646 4.118 4.118 0 001.804-2.27 8.224 8.224 0 01-2.605.996 4.107 4.107 0 00-6.993 3.743 11.65 11.65 0 01-8.457-4.287 4.106 4.106 0 001.27 5.477A4.073 4.073 0 01.8 7.713v.052a4.105 4.105 0 003.292 4.022 4.095 4.095 0 01-1.853.07 4.108 4.108 0 003.834 2.85A8.233 8.233 0 010 16.407a11.616 11.616 0 006.29 1.84" />
|
||||
</svg>
|
||||
</a>
|
||||
<a href="#" className="text-gray-400 hover:text-white transition-colors">
|
||||
<span className="sr-only">GitHub</span>
|
||||
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fillRule="evenodd" d="M10 0C4.477 0 0 4.484 0 10.017c0 4.425 2.865 8.18 6.839 9.504.5.092.682-.217.682-.483 0-.237-.008-.868-.013-1.703-2.782.605-3.369-1.343-3.369-1.343-.454-1.158-1.11-1.466-1.11-1.466-.908-.62.069-.608.069-.608 1.003.07 1.531 1.032 1.531 1.032.892 1.53 2.341 1.088 2.91.832.092-.647.35-1.088.636-1.338-2.22-.253-4.555-1.113-4.555-4.951 0-1.093.39-1.988 1.029-2.688-.103-.253-.446-1.272.098-2.65 0 0 .84-.27 2.75 1.026A9.564 9.564 0 0110 4.844c.85.004 1.705.115 2.504.337 1.909-1.296 2.747-1.027 2.747-1.027.546 1.379.203 2.398.1 2.651.64.7 1.028 1.595 1.028 2.688 0 3.848-2.339 4.695-4.566 4.942.359.31.678.921.678 1.856 0 1.338-.012 2.419-.012 2.747 0 .268.18.58.688.482A10.019 10.019 0 0020 10.017C20 4.484 15.522 0 10 0z" clipRule="evenodd" />
|
||||
</svg>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
@@ -1,24 +1,50 @@
|
||||
import Link from "next/link";
|
||||
import { getLocale } from "@/libs/locales";
|
||||
|
||||
export default function Header() {
|
||||
const { common } = getLocale('en');
|
||||
|
||||
return (
|
||||
<header>
|
||||
{/* TODO: Style this header when implementing in your project */}
|
||||
<div>
|
||||
<div>
|
||||
<div>
|
||||
<Link href="/">{common.header.brand}</Link>
|
||||
<header className="bg-white border-b border-gray-200 sticky top-0 z-40">
|
||||
<div className="max-w-7xl mx-auto px-6 py-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center">
|
||||
<Link href="/" className="text-xl font-bold text-gray-900 hover:text-blue-600 transition-colors">
|
||||
Resume Branches
|
||||
</Link>
|
||||
</div>
|
||||
<nav>
|
||||
<Link href="/">{common.header.nav.home}</Link>
|
||||
<Link href="/dashboard">{common.header.nav.dashboard}</Link>
|
||||
<Link href="/blog">{common.header.nav.blog}</Link>
|
||||
|
||||
<nav className="hidden md:flex items-center space-x-8">
|
||||
<Link
|
||||
href="/"
|
||||
className="text-gray-600 hover:text-gray-900 font-medium transition-colors"
|
||||
>
|
||||
Home
|
||||
</Link>
|
||||
<Link
|
||||
href="/dashboard"
|
||||
className="text-gray-600 hover:text-gray-900 font-medium transition-colors"
|
||||
>
|
||||
Dashboard
|
||||
</Link>
|
||||
<Link
|
||||
href="/docs"
|
||||
className="text-gray-600 hover:text-gray-900 font-medium transition-colors"
|
||||
>
|
||||
Docs
|
||||
</Link>
|
||||
</nav>
|
||||
<div>
|
||||
<Link href="/login">{common.header.actions.signIn}</Link>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<Link
|
||||
href="/login"
|
||||
className="text-gray-600 hover:text-gray-900 font-medium transition-colors"
|
||||
>
|
||||
Sign In
|
||||
</Link>
|
||||
<Link
|
||||
href="/dashboard"
|
||||
className="btn-primary"
|
||||
>
|
||||
Get Started
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
197
apps/webapp/src/components/cv/CVTree.tsx
Normal file
197
apps/webapp/src/components/cv/CVTree.tsx
Normal file
@@ -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 (
|
||||
<div className="select-none">
|
||||
<div
|
||||
className={`
|
||||
flex items-center gap-2 p-3 rounded-lg border cursor-pointer transition-all
|
||||
${NODE_COLORS[node.type]}
|
||||
${isSelected ? 'ring-2 ring-blue-500 ring-offset-1' : ''}
|
||||
hover:shadow-sm
|
||||
`}
|
||||
style={{ marginLeft: `${level * 24}px` }}
|
||||
onClick={handleNodeClick}
|
||||
>
|
||||
{hasChildren && (
|
||||
<button
|
||||
onClick={toggleExpanded}
|
||||
className="flex-shrink-0 w-4 h-4 flex items-center justify-center hover:bg-black/10 rounded"
|
||||
>
|
||||
<svg
|
||||
className={`w-3 h-3 transform transition-transform ${isExpanded ? 'rotate-90' : ''}`}
|
||||
fill="currentColor"
|
||||
viewBox="0 0 20 20"
|
||||
>
|
||||
<path fillRule="evenodd" d="M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" clipRule="evenodd" />
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{!hasChildren && <div className="w-4" />}
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium truncate">{node.label}</span>
|
||||
{node.metadata?.isPublic && (
|
||||
<span className="inline-flex items-center px-1.5 py-0.5 rounded text-xs font-medium bg-green-100 text-green-800">
|
||||
Public
|
||||
</span>
|
||||
)}
|
||||
{node.metadata?.status && (
|
||||
<span className={`inline-flex items-center px-1.5 py-0.5 rounded text-xs font-medium ${STATUS_COLORS[node.metadata.status as keyof typeof STATUS_COLORS] || 'bg-gray-100 text-gray-700'}`}>
|
||||
{node.metadata.status}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{node.metadata?.companyName && (
|
||||
<div className="text-sm text-gray-600 truncate">
|
||||
{node.metadata.companyName} • {node.metadata.roleTitle}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{node.metadata?.lastModified && (
|
||||
<div className="text-xs text-gray-500">
|
||||
Updated {new Date(node.metadata.lastModified).toLocaleDateString()}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex-shrink-0 flex items-center gap-1">
|
||||
{(node.type === 'root' || node.type === 'branch') && (
|
||||
<button
|
||||
onClick={handleCreateBranch}
|
||||
className="p-1 rounded hover:bg-black/10 transition-colors"
|
||||
title="Create branch"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 6v6m0 0v6m0-6h6m-6 0H6" />
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{node.type === 'branch' && (
|
||||
<button
|
||||
onClick={handleCreateSubmission}
|
||||
className="p-1 rounded hover:bg-black/10 transition-colors"
|
||||
title="Create submission"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{hasChildren && isExpanded && (
|
||||
<div className="mt-2 space-y-2">
|
||||
{node.children.map((child) => (
|
||||
<TreeNode
|
||||
key={child.id}
|
||||
node={child}
|
||||
level={level + 1}
|
||||
selectedNodeId={selectedNodeId}
|
||||
onNodeSelect={onNodeSelect}
|
||||
onCreateBranch={onCreateBranch}
|
||||
onCreateSubmission={onCreateSubmission}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function CVTree({
|
||||
treeData,
|
||||
selectedNodeId,
|
||||
onNodeSelect,
|
||||
onCreateBranch,
|
||||
onCreateSubmission
|
||||
}: CVTreeProps) {
|
||||
return (
|
||||
<div className="p-4 space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-lg font-semibold text-gray-900">CV Versions</h2>
|
||||
<div className="text-sm text-gray-500">
|
||||
{treeData.children.reduce((acc, branch) => acc + branch.children.length + 1, 1)} versions
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<TreeNode
|
||||
node={treeData}
|
||||
selectedNodeId={selectedNodeId}
|
||||
onNodeSelect={onNodeSelect}
|
||||
onCreateBranch={onCreateBranch}
|
||||
onCreateSubmission={onCreateSubmission}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
180
apps/webapp/src/components/cv/DiffViewer.tsx
Normal file
180
apps/webapp/src/components/cv/DiffViewer.tsx
Normal file
@@ -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 (
|
||||
<div className="w-5 h-5 rounded-full bg-green-500 flex items-center justify-center">
|
||||
<svg className="w-3 h-3 text-white" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fillRule="evenodd" d="M10 3a1 1 0 011 1v5h5a1 1 0 110 2h-5v5a1 1 0 11-2 0v-5H4a1 1 0 110-2h5V4a1 1 0 011-1z" clipRule="evenodd" />
|
||||
</svg>
|
||||
</div>
|
||||
);
|
||||
case 'removed':
|
||||
return (
|
||||
<div className="w-5 h-5 rounded-full bg-red-500 flex items-center justify-center">
|
||||
<svg className="w-3 h-3 text-white" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fillRule="evenodd" d="M3 10a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1z" clipRule="evenodd" />
|
||||
</svg>
|
||||
</div>
|
||||
);
|
||||
case 'changed':
|
||||
return (
|
||||
<div className="w-5 h-5 rounded-full bg-yellow-500 flex items-center justify-center">
|
||||
<svg className="w-3 h-3 text-white" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fillRule="evenodd" d="M4 2a1 1 0 011 1v2.101a7.002 7.002 0 0111.601 2.566 1 1 0 11-1.885.666A5.002 5.002 0 005.999 7H9a1 1 0 010 2H4a1 1 0 01-1-1V3a1 1 0 011-1zm.008 9.057a1 1 0 011.276.61A5.002 5.002 0 0014.001 13H11a1 1 0 110-2h5a1 1 0 011 1v5a1 1 0 11-2 0v-2.101a7.002 7.002 0 01-11.601-2.566 1 1 0 01.61-1.276z" clipRule="evenodd" />
|
||||
</svg>
|
||||
</div>
|
||||
);
|
||||
default:
|
||||
return <div className="w-5 h-5 rounded-full bg-gray-300" />;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`border rounded-lg p-4 ${getTypeColor(diff.type)}`}>
|
||||
<div className="flex items-start gap-3">
|
||||
{getTypeIcon(diff.type)}
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-medium text-gray-900">{diff.path}</span>
|
||||
<button
|
||||
onClick={onToggle}
|
||||
className="text-xs text-gray-500 hover:text-gray-700 font-medium"
|
||||
>
|
||||
{isExpanded ? 'Hide' : 'Show'} details
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{diff.context && (
|
||||
<div className="text-xs text-gray-600 mt-1">{diff.context}</div>
|
||||
)}
|
||||
|
||||
{isExpanded && (
|
||||
<div className="mt-3 space-y-2">
|
||||
{diff.oldValue && (
|
||||
<div className="bg-red-100 border border-red-200 rounded p-2">
|
||||
<div className="text-xs font-medium text-red-800 mb-1">- Removed</div>
|
||||
<div className="text-sm text-red-700">{diff.oldValue}</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{diff.newValue && (
|
||||
<div className="bg-green-100 border border-green-200 rounded p-2">
|
||||
<div className="text-xs font-medium text-green-800 mb-1">+ Added</div>
|
||||
<div className="text-sm text-green-700">{diff.newValue}</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function DiffViewer({ patches, title = "Changes", className = "" }: DiffViewerProps) {
|
||||
const [expandedItems, setExpandedItems] = useState<Set<number>>(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 (
|
||||
<div className={`card p-6 text-center ${className}`}>
|
||||
<div className="text-gray-500">
|
||||
<svg className="w-12 h-12 mx-auto mb-3 opacity-50" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1} d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
||||
</svg>
|
||||
<p className="font-medium">No changes</p>
|
||||
<p className="text-sm">This version is identical to its parent</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className={`card ${className}`}>
|
||||
<div className="p-4 border-b border-gray-200">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-lg font-semibold text-gray-900">{title}</h3>
|
||||
<div className="flex items-center gap-4 text-sm">
|
||||
{addedCount > 0 && (
|
||||
<span className="flex items-center gap-1 text-green-600">
|
||||
<div className="w-3 h-3 rounded-full bg-green-500" />
|
||||
{addedCount} added
|
||||
</span>
|
||||
)}
|
||||
{changedCount > 0 && (
|
||||
<span className="flex items-center gap-1 text-yellow-600">
|
||||
<div className="w-3 h-3 rounded-full bg-yellow-500" />
|
||||
{changedCount} changed
|
||||
</span>
|
||||
)}
|
||||
{removedCount > 0 && (
|
||||
<span className="flex items-center gap-1 text-red-600">
|
||||
<div className="w-3 h-3 rounded-full bg-red-500" />
|
||||
{removedCount} removed
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="text-sm text-gray-600 mt-1">
|
||||
{changeCount} {changeCount === 1 ? 'change' : 'changes'} detected
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-4 space-y-3 max-h-96 overflow-y-auto">
|
||||
{patches.map((patch, index) => (
|
||||
<DiffLine
|
||||
key={index}
|
||||
diff={patch}
|
||||
isExpanded={expandedItems.has(index)}
|
||||
onToggle={() => toggleExpanded(index)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
133
apps/webapp/src/types/cv.ts
Normal file
133
apps/webapp/src/types/cv.ts
Normal file
@@ -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<string, unknown>;
|
||||
}
|
||||
|
||||
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<string, unknown>;
|
||||
}>;
|
||||
}
|
||||
107
apps/webapp/tailwind.config.ts
Normal file
107
apps/webapp/tailwind.config.ts
Normal file
@@ -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;
|
||||
Reference in New Issue
Block a user