chore init

This commit is contained in:
devcodetools committed 2026-09-16 21:34:09 +02:00
1 parent 1b7406b1d3
commit a0d312a884
60 files changed
+5046 -125

No files matched your search

+18
View File
@@ -0,0 +1,18 @@
import type { Experience } from "@/lib/types/cv";
export interface CvExperienceRendered {
experience: Experience;
html: string;
}
export function CvExperiencePreview({ experience, html }: CvExperienceRendered) {
return (
<article className="mb-4">
<h4 className="text-[0.9rem] font-bold text-ink">{experience.titre}</h4>
<div
className="cv-md text-[0.8rem] leading-snug text-ink"
dangerouslySetInnerHTML={{ __html: html }}
/>
</article>
);
}
+14
View File
@@ -0,0 +1,14 @@
interface CvHeaderProps {
html: string;
}
export function CvHeader({ html }: CvHeaderProps) {
return (
<header className="mb-4 border-b border-border pb-3">
<div
className="cv-md text-ink"
dangerouslySetInnerHTML={{ __html: html }}
/>
</header>
);
}
+68
View File
@@ -0,0 +1,68 @@
"use client";
import { useState } from "react";
import { CvTabs } from "@/components/cv/CvTabs";
import { MarkdownTextarea } from "@/components/cv/MarkdownTextarea";
import { ExperienceManager } from "@/components/cv/ExperienceManager";
import { CV_TABS, type CVTabId, type CVCareerData } from "@/lib/types/cv";
const PLACEHOLDERS: Record<CVTabId, string> = {
accroche: "**Jean Dupont**\nDéveloppeur Full-Stack passionné par le web…",
etatCivil:
"**Jean Dupont**\n- jean.dupont@email.fr\n- 06 12 34 56 78\n- [Site web](https://mon-site.fr)\n- [GitHub](https://github.com/jean-dupont)",
formations:
"### Master Informatique\nUniversité — 2020\n\n### Licence Informatique\nUniversité — 2018",
softSkills:
"- Esprit d'équipe\n- Autonomie\n- Curiosité technique",
centresInteret:
"- Randonnée\n- Lecture\n- Musique",
};
interface CvInputPanelProps {
data: CVCareerData;
onSetText: (tab: CVTabId, value: string) => void;
onAddExperience: (titre: string, contenu: string) => void;
onUpdateExperience: (id: string, titre: string, contenu: string) => void;
onRemoveExperience: (id: string) => void;
}
export function CvInputPanel({
data,
onSetText,
onAddExperience,
onUpdateExperience,
onRemoveExperience,
}: CvInputPanelProps) {
const [activeTab, setActiveTab] = useState<CVTabId>("accroche");
const meta = CV_TABS[activeTab];
const textareaId = `textarea-${activeTab}`;
return (
<div className="print-hidden flex flex-col gap-4 p-6">
<header>
<h1 className="text-lg font-bold text-ink">Contenu du CV</h1>
<p className="text-sm text-muted">
Saisissez votre CV en Markdown. Le rendu s&apos;affiche en temps réel à droite.
</p>
</header>
<CvTabs activeTab={activeTab} onTabChange={setActiveTab} />
<MarkdownTextarea
id={textareaId}
label={`Zone ${meta.numero} — ${meta.label}`}
value={data[activeTab]}
onChange={(text) => onSetText(activeTab, text)}
placeholder={PLACEHOLDERS[activeTab]}
minHeightClass={activeTab === "accroche" ? "min-h-[120px]" : "min-h-[280px]"}
/>
<ExperienceManager
experiences={data.experiences}
onAdd={onAddExperience}
onUpdate={onUpdateExperience}
onRemove={onRemoveExperience}
/>
</div>
);
}
+24
View File
@@ -0,0 +1,24 @@
import { CvSection } from "@/components/cv/CvSection";
interface CvLeftColumnProps {
etatCivilHtml: string;
formationsHtml: string;
softSkillsHtml: string;
centresInteretHtml: string;
}
export function CvLeftColumn({
etatCivilHtml,
formationsHtml,
softSkillsHtml,
centresInteretHtml,
}: CvLeftColumnProps) {
return (
<aside>
<CvSection title="État civil" html={etatCivilHtml} />
<CvSection title="Formations" html={formationsHtml} />
<CvSection title="Soft skills" html={softSkillsHtml} />
<CvSection title="Centres d'intérêt" html={centresInteretHtml} />
</aside>
);
}
+45
View File
@@ -0,0 +1,45 @@
"use client";
import { useMemo } from "react";
import { markdownToHtml } from "@/lib/markdown";
import type { CVCareerData } from "@/lib/types/cv";
import { CvHeader } from "@/components/cv/CvHeader";
import { CvLeftColumn } from "@/components/cv/CvLeftColumn";
import { CvRightColumn } from "@/components/cv/CvRightColumn";
import type { CvExperienceRendered } from "@/components/cv/CvExperiencePreview";
export function CvPreview({ data }: { data: CVCareerData }) {
const accrocheHtml = useMemo(() => markdownToHtml(data.accroche), [data.accroche]);
const etatCivilHtml = useMemo(() => markdownToHtml(data.etatCivil), [data.etatCivil]);
const formationsHtml = useMemo(() => markdownToHtml(data.formations), [data.formations]);
const softSkillsHtml = useMemo(() => markdownToHtml(data.softSkills), [data.softSkills]);
const centresInteretHtml = useMemo(
() => markdownToHtml(data.centresInteret),
[data.centresInteret],
);
const experiences = useMemo<CvExperienceRendered[]>(
() =>
data.experiences.map((experience) => ({
experience,
html: markdownToHtml(experience.contenu),
})),
[data.experiences],
);
return (
<div className="flex justify-center overflow-x-auto p-6 print:block print:p-0 print:overflow-visible">
<div className="cv-sheet">
<CvHeader html={accrocheHtml} />
<div className="cv-sheet-body">
<CvLeftColumn
etatCivilHtml={etatCivilHtml}
formationsHtml={formationsHtml}
softSkillsHtml={softSkillsHtml}
centresInteretHtml={centresInteretHtml}
/>
<CvRightColumn experiences={experiences} />
</div>
</div>
</div>
);
}
+31
View File
@@ -0,0 +1,31 @@
import {
CvExperiencePreview,
type CvExperienceRendered,
} from "@/components/cv/CvExperiencePreview";
interface CvRightColumnProps {
experiences: CvExperienceRendered[];
}
export function CvRightColumn({ experiences }: CvRightColumnProps) {
return (
<main>
<h2 className="mb-3 text-[0.75rem] font-bold uppercase tracking-wide text-muted">
Expériences professionnelles
</h2>
{experiences.length === 0 ? (
<p className="print-hidden text-sm text-muted">
Ajoutez une expérience dans le panneau gauche
</p>
) : (
experiences.map((item) => (
<CvExperiencePreview
key={item.experience.id}
experience={item.experience}
html={item.html}
/>
))
)}
</main>
);
}
+19
View File
@@ -0,0 +1,19 @@
interface CvSectionProps {
title: string;
html: string;
}
export function CvSection({ title, html }: CvSectionProps) {
if (!html.trim()) return null;
return (
<section className="mb-4">
<h3 className="mb-1 text-[0.75rem] font-bold uppercase tracking-wide text-muted">
{title}
</h3>
<div
className="cv-md text-[0.8rem] leading-snug text-ink"
dangerouslySetInnerHTML={{ __html: html }}
/>
</section>
);
}
+18
View File
@@ -0,0 +1,18 @@
"use client";
import { Tabs } from "@/components/ui/Tabs";
import { CV_TABS, CV_TAB_ORDER, type CVTabId } from "@/lib/types/cv";
interface CvTabsProps {
activeTab: CVTabId;
onTabChange: (tab: CVTabId) => void;
}
export function CvTabs({ activeTab, onTabChange }: CvTabsProps) {
const items = CV_TAB_ORDER.map((id) => ({
id,
label: `${CV_TABS[id].numero} · ${CV_TABS[id].label}`,
}));
return <Tabs items={items} activeId={activeTab} onChange={onTabChange} />;
}
+119
View File
@@ -0,0 +1,119 @@
"use client";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { useRef, type ChangeEvent } from "react";
import { Download, FileText, Printer, RotateCcw, Upload } from "lucide-react";
import { Button } from "@/components/ui/Button";
import { normalizeCVCareerData } from "@/lib/storage";
import type { CVCareerData } from "@/lib/types/cv";
interface CvToolbarProps {
onReset: () => void;
data: CVCareerData;
onImport: (value: CVCareerData) => void;
}
export function CvToolbar({ onReset, data, onImport }: CvToolbarProps) {
const pathname = usePathname();
const fileInputRef = useRef<HTMLInputElement>(null);
function handleExport() {
const blob = new Blob([JSON.stringify(data, null, 2)], { type: "application/json" });
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = "cv-maker.json";
link.click();
URL.revokeObjectURL(url);
}
function handleImportFile(event: ChangeEvent<HTMLInputElement>) {
const file = event.target.files?.[0];
event.target.value = "";
if (!file) return;
const reader = new FileReader();
reader.onload = () => {
try {
const parsed: unknown = JSON.parse(String(reader.result));
const value = normalizeCVCareerData(parsed);
if (value === null) {
window.alert("Fichier invalide : ce n'est pas un CV au format attendu.");
return;
}
if (window.confirm("Importer ce CV ? Le contenu actuel sera remplacé.")) {
onImport(value);
}
} catch {
window.alert("Fichier illisible : le JSON est invalide.");
}
};
reader.readAsText(file);
}
return (
<header className="print-hidden sticky top-0 z-10 flex flex-wrap items-center justify-between gap-3 border-b border-border bg-white px-6 py-3">
<div className="flex items-center gap-2">
<span className="flex h-8 w-8 items-center justify-center rounded-md bg-accent text-white">
<FileText size={18} aria-hidden />
</span>
<span className="text-base font-bold text-ink">CV-Maker</span>
</div>
<nav className="flex flex-wrap items-center gap-2">
<Button variant="ghost" size="sm" onClick={() => window.print()} title="Imprimer ou exporter en PDF">
<Printer size={14} aria-hidden />
Imprimer / PDF
</Button>
<input
ref={fileInputRef}
type="file"
accept="application/json,.json"
className="hidden"
onChange={handleImportFile}
/>
<Button
variant="ghost"
size="sm"
onClick={() => fileInputRef.current?.click()}
title="Importer un CV depuis un fichier JSON"
>
<Upload size={14} aria-hidden />
Importer JSON
</Button>
<Button variant="ghost" size="sm" onClick={handleExport} title="Télécharger le CV au format JSON">
<Download size={14} aria-hidden />
Exporter JSON
</Button>
<Button
variant="ghost"
size="sm"
title="Réinitialiser le CV"
onClick={() => {
if (window.confirm("Réinitialiser tout le contenu du CV ?")) onReset();
}}
>
<RotateCcw size={14} aria-hidden />
Réinitialiser
</Button>
{[
{ href: "/documentation", label: "Documentation" },
].map(({ href, label: linkLabel }) => {
const active = pathname === href;
return (
<Link
key={href}
href={href}
aria-current={active ? "page" : undefined}
className={`rounded-md px-3 py-1.5 text-sm font-medium transition-colors focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent ${
active ? "bg-accent text-white" : "text-muted hover:text-ink"
}`}
>
{linkLabel}
</Link>
);
})}
</nav>
</header>
);
}
+34
View File
@@ -0,0 +1,34 @@
"use client";
import { useCVData } from "@/lib/hooks/useCVData";
import { CvToolbar } from "@/components/cv/CvToolbar";
import { CvInputPanel } from "@/components/cv/CvInputPanel";
import { CvPreview } from "@/components/cv/CvPreview";
export function CvWorkspace() {
const {
data,
setText,
addExperience,
updateExperience,
removeExperience,
resetCV,
importCV,
} = useCVData();
return (
<div className="min-h-screen bg-paper text-ink print:min-h-0">
<CvToolbar onReset={resetCV} data={data} onImport={importCV} />
<div className="grid grid-cols-1 lg:grid-cols-[1fr_2fr] print:block">
<CvInputPanel
data={data}
onSetText={setText}
onAddExperience={addExperience}
onUpdateExperience={updateExperience}
onRemoveExperience={removeExperience}
/>
<CvPreview data={data} />
</div>
</div>
);
}
+67
View File
@@ -0,0 +1,67 @@
"use client";
import { useState } from "react";
import { Pencil, Trash2 } from "lucide-react";
import { Button } from "@/components/ui/Button";
import { IconButton } from "@/components/ui/IconButton";
import type { Experience } from "@/lib/types/cv";
interface ExperienceItemProps {
index: number;
experience: Experience;
onEdit: () => void;
onRemove: () => void;
}
export function ExperienceItem({
index,
experience,
onEdit,
onRemove,
}: ExperienceItemProps) {
const [confirming, setConfirming] = useState(false);
if (confirming) {
return (
<li className="flex items-center justify-between gap-2 rounded-md border border-danger/40 bg-red-50 px-3 py-2">
<span className="text-sm text-ink">Supprimer cette expérience ?</span>
<div className="flex shrink-0 gap-1.5">
<Button
variant="danger"
size="sm"
onClick={() => {
setConfirming(false);
onRemove();
}}
>
Oui
</Button>
<Button variant="ghost" size="sm" onClick={() => setConfirming(false)}>
Non
</Button>
</div>
</li>
);
}
return (
<li className="flex items-center justify-between gap-2 rounded-md border border-border bg-white px-3 py-2">
<span className="truncate text-sm text-ink">
<span className="mr-1.5 text-muted">{index + 1}.</span>
{experience.titre}
</span>
<div className="flex shrink-0 gap-0.5">
<IconButton aria-label="Modifier l'expérience" onClick={onEdit}>
<Pencil size={14} />
</IconButton>
<IconButton
danger
aria-label="Supprimer l'expérience"
onClick={() => setConfirming(true)}
>
<Trash2 size={14} />
</IconButton>
</div>
</li>
);
}
+141
View File
@@ -0,0 +1,141 @@
"use client";
import { useState, type FormEvent } from "react";
import { Plus, Save, X } from "lucide-react";
import { Button } from "@/components/ui/Button";
import { Textarea } from "@/components/ui/Textarea";
import { ExperienceItem } from "@/components/cv/ExperienceItem";
import type { Experience } from "@/lib/types/cv";
type ExperienceEditor =
| { mode: "closed" }
| { mode: "new" }
| { mode: "edit"; id: Experience["id"] };
interface ExperienceManagerProps {
experiences: Experience[];
onAdd: (titre: string, contenu: string) => void;
onUpdate: (id: string, titre: string, contenu: string) => void;
onRemove: (id: string) => void;
}
export function ExperienceManager({
experiences,
onAdd,
onUpdate,
onRemove,
}: ExperienceManagerProps) {
const [editor, setEditor] = useState<ExperienceEditor>({ mode: "closed" });
const [titre, setTitre] = useState("");
const [contenu, setContenu] = useState("");
const isEditing = editor.mode !== "closed";
const editingId = editor.mode === "edit" ? editor.id : null;
function startNew() {
setEditor({ mode: "new" });
setTitre("");
setContenu("");
}
function startEdit(id: string) {
const experience = experiences.find((exp) => exp.id === id);
if (!experience) return;
setEditor({ mode: "edit", id });
setTitre(experience.titre);
setContenu(experience.contenu);
}
function close() {
setEditor({ mode: "closed" });
setTitre("");
setContenu("");
}
function handleSubmit(event: FormEvent) {
event.preventDefault();
const trimmedTitre = titre.trim();
const title = trimmedTitre || "Expérience";
if (editor.mode === "new") {
onAdd(title, contenu);
} else if (editor.mode === "edit") {
onUpdate(editor.id, title, contenu);
}
close();
}
return (
<section aria-labelledby="experiences-title" className="mt-6 border-t border-border pt-4">
<div className="flex items-center justify-between gap-2">
<h2 id="experiences-title" className="text-sm font-semibold text-ink">
Expériences professionnelles
</h2>
<Button size="sm" onClick={startNew}>
<Plus size={14} aria-hidden />
Ajouter
</Button>
</div>
{isEditing && (
<form
onSubmit={handleSubmit}
className="mt-3 flex flex-col gap-3 rounded-md border border-accent bg-white p-3"
>
<div className="flex flex-col gap-1">
<label htmlFor="experience-titre" className="text-xs font-medium text-ink">
Titre
</label>
<input
id="experience-titre"
type="text"
value={titre}
onChange={(event) => setTitre(event.target.value)}
placeholder="Développeur Full-Stack"
autoFocus
className="rounded-md border border-border px-3 py-2 text-sm text-ink placeholder:text-muted focus:outline-2 focus:outline-offset-2 focus:outline-accent"
/>
</div>
<div className="flex flex-col gap-1">
<label htmlFor="experience-contenu" className="text-xs font-medium text-ink">
Contenu (Markdown)
</label>
<Textarea
id="experience-contenu"
value={contenu}
onChange={(event) => setContenu(event.target.value)}
placeholder="- Stack : React, Next.js&#10;- Missions : …"
className="min-h-[120px]"
/>
</div>
<div className="flex gap-2">
<Button type="submit" size="sm">
<Save size={14} aria-hidden />
{editingId ? "Enregistrer" : "Ajouter"}
</Button>
<Button variant="ghost" size="sm" type="button" onClick={close}>
<X size={14} aria-hidden />
Annuler
</Button>
</div>
</form>
)}
<ol className="mt-3 flex flex-col gap-1.5">
{experiences.map((experience, index) => (
<ExperienceItem
key={experience.id}
index={index}
experience={experience}
onEdit={() => startEdit(experience.id)}
onRemove={() => onRemove(experience.id)}
/>
))}
{experiences.length === 0 && (
<p className="text-sm text-muted">
Aucune expérience. Utilisez le bouton « Ajouter » pour commencer.
</p>
)}
</ol>
</section>
);
}
+47
View File
@@ -0,0 +1,47 @@
"use client";
import Link from "next/link";
import { Textarea } from "@/components/ui/Textarea";
interface MarkdownTextareaProps {
id: string;
label: string;
value: string;
onChange: (text: string) => void;
placeholder?: string;
minHeightClass?: string;
}
export function MarkdownTextarea({
id,
label,
value,
onChange,
placeholder,
minHeightClass,
}: MarkdownTextareaProps) {
return (
<div className="flex flex-col gap-1.5">
<div className="flex items-baseline justify-between gap-2">
<label htmlFor={id} className="text-sm font-semibold text-ink">
{label}
</label>
<Link
href="/documentation"
className="text-xs text-muted underline-offset-2 hover:text-accent hover:underline"
>
Syntaxe Markdown
</Link>
</div>
<Textarea
id={id}
role="tabpanel"
aria-labelledby={`tab-${id.replace("textarea-", "")}`}
value={value}
onChange={(event) => onChange(event.target.value)}
placeholder={placeholder}
className={minHeightClass}
/>
</div>
);
}
+36
View File
@@ -0,0 +1,36 @@
import type { ButtonHTMLAttributes } from "react";
type ButtonVariant = "primary" | "ghost" | "danger";
type ButtonSize = "sm" | "md";
interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
variant?: ButtonVariant;
size?: ButtonSize;
}
const variantClasses: Record<ButtonVariant, string> = {
primary: "bg-accent text-white hover:bg-accent/90",
ghost: "border border-border bg-white text-ink hover:bg-paper",
danger: "bg-danger text-white hover:bg-danger/90",
};
const sizeClasses: Record<ButtonSize, string> = {
sm: "rounded-sm px-2 py-1 text-xs",
md: "rounded-md px-3 py-2 text-sm",
};
export function Button({
variant = "primary",
size = "md",
className = "",
type = "button",
...props
}: ButtonProps) {
return (
<button
type={type}
className={`inline-flex items-center justify-center gap-1.5 font-medium transition-colors focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent disabled:cursor-not-allowed disabled:opacity-50 ${variantClasses[variant]} ${sizeClasses[size]} ${className}`}
{...props}
/>
);
}
+20
View File
@@ -0,0 +1,20 @@
import Link from "next/link";
export function Footer() {
const year = new Date().getFullYear();
return (
<footer className="print-hidden border-t border-border bg-white px-6 py-4 text-center text-sm text-muted">
<span>&copy; {year} CV-Maker</span>
<span aria-hidden className="mx-2 text-border">
•
</span>
<Link
href="/mentions-legales"
className="font-medium underline-offset-2 hover:text-accent hover:underline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent"
>
Mentions légales
</Link>
</footer>
);
}
+25
View File
@@ -0,0 +1,25 @@
import type { ButtonHTMLAttributes } from "react";
interface IconButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
"aria-label": string;
danger?: boolean;
}
export function IconButton({
"aria-label": ariaLabel,
danger = false,
type = "button",
className = "",
...props
}: IconButtonProps) {
return (
<button
type={type}
aria-label={ariaLabel}
className={`inline-flex h-8 w-8 items-center justify-center rounded-md transition-colors focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent disabled:opacity-50 ${
danger ? "text-danger hover:bg-danger/10" : "text-muted hover:bg-paper hover:text-ink"
} ${className}`}
{...props}
/>
);
}
+56
View File
@@ -0,0 +1,56 @@
"use client";
import { type KeyboardEvent } from "react";
export interface TabItem<T extends string> {
id: T;
label: string;
}
interface TabsProps<T extends string> {
items: TabItem<T>[];
activeId: T;
onChange: (id: T) => void;
}
export function Tabs<T extends string>({ items, activeId, onChange }: TabsProps<T>) {
function handleKeyDown(event: KeyboardEvent<HTMLButtonElement>) {
const currentIndex = items.findIndex((item) => item.id === activeId);
if (event.key === "ArrowRight") {
event.preventDefault();
const next = items.at((currentIndex + 1) % items.length);
if (next) onChange(next.id);
} else if (event.key === "ArrowLeft") {
event.preventDefault();
const prev = items.at((currentIndex - 1 + items.length) % items.length);
if (prev) onChange(prev.id);
}
}
return (
<div role="tablist" aria-label="Sections du CV" className="flex flex-wrap gap-1">
{items.map((item) => {
const selected = item.id === activeId;
return (
<button
key={item.id}
role="tab"
id={`tab-${item.id}`}
aria-selected={selected}
aria-controls={`panel-${item.id}`}
tabIndex={selected ? 0 : -1}
onClick={() => onChange(item.id)}
onKeyDown={handleKeyDown}
className={`rounded-md px-3 py-1.5 text-sm font-medium transition-colors focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent ${
selected
? "bg-accent text-white"
: "border border-border bg-white text-muted hover:text-ink"
}`}
>
{item.label}
</button>
);
})}
</div>
);
}
+11
View File
@@ -0,0 +1,11 @@
import type { TextareaHTMLAttributes } from "react";
export function Textarea({ className = "", ...props }: TextareaHTMLAttributes<HTMLTextAreaElement>) {
return (
<textarea
suppressHydrationWarning
className={`w-full resize-y rounded-md border border-border bg-white p-3 font-mono text-sm text-ink placeholder:text-muted focus:outline-2 focus:outline-offset-2 focus:outline-accent ${className}`}
{...props}
/>
);
}