chore init
This commit is contained in:
1 parent
1b7406b1d3
commit
a0d312a884
60 files changed
+5046
-125
No files matched your search
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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'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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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} />;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 - 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user