chore: import upstream snapshot with attribution
Publish Docker Image / publish (push) Has been cancelled
Publish Docker Image / publish (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,115 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { AdditionalInfo } from '@/components/dashboard/resume-component';
|
||||
import { useTranslations } from '@/lib/i18n';
|
||||
|
||||
interface AdditionalFormProps {
|
||||
data: AdditionalInfo;
|
||||
onChange: (data: AdditionalInfo) => void;
|
||||
}
|
||||
|
||||
export const AdditionalForm: React.FC<AdditionalFormProps> = ({ data, onChange }) => {
|
||||
const { t } = useTranslations();
|
||||
|
||||
// Helper to handle array conversions (text -> string[])
|
||||
const handleArrayChange = (field: keyof AdditionalInfo, value: string) => {
|
||||
// Split by newlines only. Blank/whitespace lines are preserved while editing
|
||||
// so pressing Enter creates a new line (issue #763); consumers filter empty
|
||||
// entries at render time, and the backend drops them on save.
|
||||
const items = value.split('\n');
|
||||
onChange({
|
||||
...data,
|
||||
[field]: items,
|
||||
});
|
||||
};
|
||||
|
||||
const formatArray = (arr?: string[]) => {
|
||||
return arr?.join('\n') || '';
|
||||
};
|
||||
|
||||
// Explicitly allow Enter key to create newlines (prevent form submission interference)
|
||||
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
if (e.key === 'Enter') {
|
||||
// Allow default behavior (newline insertion)
|
||||
e.stopPropagation();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<p className="font-mono text-xs uppercase tracking-wider text-blue-700">
|
||||
{t('builder.additionalForm.instructions')}
|
||||
</p>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div className="space-y-2">
|
||||
<Label
|
||||
htmlFor="technicalSkills"
|
||||
className="font-mono text-xs uppercase tracking-wider text-steel-grey"
|
||||
>
|
||||
{t('resume.additional.technicalSkills')}
|
||||
</Label>
|
||||
<Textarea
|
||||
id="technicalSkills"
|
||||
value={formatArray(data.technicalSkills)}
|
||||
onChange={(e) => handleArrayChange('technicalSkills', e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder={t('builder.additionalForm.placeholders.technicalSkills')}
|
||||
className="min-h-[120px] text-black rounded-none border-black bg-white focus-visible:ring-0 focus-visible:ring-offset-0 focus-visible:border-blue-700"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label
|
||||
htmlFor="languages"
|
||||
className="font-mono text-xs uppercase tracking-wider text-steel-grey"
|
||||
>
|
||||
{t('resume.sections.languages')}
|
||||
</Label>
|
||||
<Textarea
|
||||
id="languages"
|
||||
value={formatArray(data.languages)}
|
||||
onChange={(e) => handleArrayChange('languages', e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder={t('builder.additionalForm.placeholders.languages')}
|
||||
className="min-h-[120px] text-black rounded-none border-black bg-white focus-visible:ring-0 focus-visible:ring-offset-0 focus-visible:border-blue-700"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label
|
||||
htmlFor="certifications"
|
||||
className="font-mono text-xs uppercase tracking-wider text-steel-grey"
|
||||
>
|
||||
{t('resume.sections.certifications')}
|
||||
</Label>
|
||||
<Textarea
|
||||
id="certifications"
|
||||
value={formatArray(data.certificationsTraining)}
|
||||
onChange={(e) => handleArrayChange('certificationsTraining', e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder={t('builder.additionalForm.placeholders.certifications')}
|
||||
className="min-h-[120px] text-black rounded-none border-black bg-white focus-visible:ring-0 focus-visible:ring-offset-0 focus-visible:border-blue-700"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label
|
||||
htmlFor="awards"
|
||||
className="font-mono text-xs uppercase tracking-wider text-steel-grey"
|
||||
>
|
||||
{t('resume.sections.awards')}
|
||||
</Label>
|
||||
<Textarea
|
||||
id="awards"
|
||||
value={formatArray(data.awards)}
|
||||
onChange={(e) => handleArrayChange('awards', e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder={t('builder.additionalForm.placeholders.awards')}
|
||||
className="min-h-[120px] text-black rounded-none border-black bg-white focus-visible:ring-0 focus-visible:ring-offset-0 focus-visible:border-blue-700"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,193 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { Education } from '@/components/dashboard/resume-component';
|
||||
import { Plus, Trash2 } from 'lucide-react';
|
||||
import { useTranslations } from '@/lib/i18n';
|
||||
import {
|
||||
DndContext,
|
||||
closestCenter,
|
||||
PointerSensor,
|
||||
KeyboardSensor,
|
||||
useSensor,
|
||||
useSensors,
|
||||
DragEndEvent,
|
||||
} from '@dnd-kit/core';
|
||||
import {
|
||||
arrayMove,
|
||||
SortableContext,
|
||||
sortableKeyboardCoordinates,
|
||||
verticalListSortingStrategy,
|
||||
} from '@dnd-kit/sortable';
|
||||
import { DraggableListItem } from '../draggable-list-item';
|
||||
|
||||
interface EducationFormProps {
|
||||
data: Education[];
|
||||
onChange: (data: Education[]) => void;
|
||||
}
|
||||
|
||||
export const EducationForm: React.FC<EducationFormProps> = ({ data, onChange }) => {
|
||||
const { t } = useTranslations();
|
||||
|
||||
// Configure drag-and-drop sensors
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor),
|
||||
useSensor(KeyboardSensor, {
|
||||
coordinateGetter: sortableKeyboardCoordinates,
|
||||
})
|
||||
);
|
||||
|
||||
// Handler for drag end event
|
||||
const handleDragEnd = (event: DragEndEvent) => {
|
||||
const { active, over } = event;
|
||||
|
||||
if (!over || active.id === over.id) return;
|
||||
|
||||
const oldIndex = data.findIndex((item) => item.id === active.id);
|
||||
const newIndex = data.findIndex((item) => item.id === over.id);
|
||||
|
||||
if (oldIndex === -1 || newIndex === -1) return;
|
||||
|
||||
// Reorder the array using arrayMove from @dnd-kit
|
||||
const reordered = arrayMove(data, oldIndex, newIndex);
|
||||
onChange(reordered);
|
||||
};
|
||||
|
||||
const handleAdd = () => {
|
||||
const newId = Math.max(...data.map((d) => d.id), 0) + 1;
|
||||
onChange([
|
||||
...data,
|
||||
{
|
||||
id: newId,
|
||||
institution: '',
|
||||
degree: '',
|
||||
years: '',
|
||||
description: '',
|
||||
},
|
||||
]);
|
||||
};
|
||||
|
||||
const handleRemove = (id: number) => {
|
||||
onChange(data.filter((item) => item.id !== id));
|
||||
};
|
||||
|
||||
const handleChange = (id: number, field: keyof Education, value: string) => {
|
||||
onChange(
|
||||
data.map((item) => {
|
||||
if (item.id === id) {
|
||||
return { ...item, [field]: value };
|
||||
}
|
||||
return item;
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleAdd}
|
||||
className="rounded-none border-black hover:bg-black hover:text-white transition-colors"
|
||||
>
|
||||
<Plus className="w-4 h-4 mr-2" /> {t('builder.forms.education.addSchool')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{data.length === 0 ? (
|
||||
<div className="text-center py-12 bg-paper-tint border border-dashed border-black">
|
||||
<p className="font-mono text-sm text-steel-grey mb-4">
|
||||
{t('builder.genericItemForm.noEntries', { label: t('resume.sections.education') })}
|
||||
</p>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleAdd}
|
||||
className="rounded-none border-black"
|
||||
>
|
||||
<Plus className="w-4 h-4 mr-2" /> {t('builder.forms.education.addFirstSchool')}
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
|
||||
<SortableContext
|
||||
items={data.map((item) => item.id)}
|
||||
strategy={verticalListSortingStrategy}
|
||||
>
|
||||
<div className="space-y-8">
|
||||
{data.map((item) => (
|
||||
<DraggableListItem key={item.id} id={item.id}>
|
||||
<div className="p-6 border border-black bg-paper-tint relative group">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="absolute top-2 right-2 opacity-0 group-hover:opacity-100 transition-opacity text-destructive hover:text-destructive hover:bg-destructive/10"
|
||||
onClick={() => handleRemove(item.id)}
|
||||
aria-label={t('a11y.removeItem')}
|
||||
title={t('a11y.removeItem')}
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</Button>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 mb-4 pr-8">
|
||||
<div className="space-y-2">
|
||||
<Label className="font-mono text-xs uppercase tracking-wider text-steel-grey">
|
||||
{t('builder.forms.education.fields.institution')}
|
||||
</Label>
|
||||
<Input
|
||||
value={item.institution || ''}
|
||||
onChange={(e) => handleChange(item.id, 'institution', e.target.value)}
|
||||
placeholder={t('builder.forms.education.placeholders.institution')}
|
||||
className="rounded-none border-black bg-white"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label className="font-mono text-xs uppercase tracking-wider text-steel-grey">
|
||||
{t('builder.forms.education.fields.degree')}
|
||||
</Label>
|
||||
<Input
|
||||
value={item.degree || ''}
|
||||
onChange={(e) => handleChange(item.id, 'degree', e.target.value)}
|
||||
placeholder={t('builder.forms.education.placeholders.degree')}
|
||||
className="rounded-none border-black bg-white"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label className="font-mono text-xs uppercase tracking-wider text-steel-grey">
|
||||
{t('builder.genericItemForm.fields.years')}
|
||||
</Label>
|
||||
<Input
|
||||
value={item.years || ''}
|
||||
onChange={(e) => handleChange(item.id, 'years', e.target.value)}
|
||||
placeholder={t('builder.forms.education.placeholders.years')}
|
||||
className="rounded-none border-black bg-white"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label className="font-mono text-xs uppercase tracking-wider text-steel-grey">
|
||||
{t('builder.forms.education.fields.descriptionOptional')}
|
||||
</Label>
|
||||
<Textarea
|
||||
value={item.description || ''}
|
||||
onChange={(e) => handleChange(item.id, 'description', e.target.value)}
|
||||
className="min-h-[60px] text-black text-sm rounded-none border-black bg-white"
|
||||
placeholder={t('builder.forms.education.placeholders.description')}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</DraggableListItem>
|
||||
))}
|
||||
</div>
|
||||
</SortableContext>
|
||||
</DndContext>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,281 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import dynamic from 'next/dynamic';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Button } from '@/components/ui/button';
|
||||
|
||||
// Lazy-load TipTap-based editor — keeps it out of the initial bundle.
|
||||
// Loads only when an experience entry is actually being edited.
|
||||
const RichTextEditor = dynamic(
|
||||
() => import('@/components/ui/rich-text-editor').then((m) => m.RichTextEditor),
|
||||
{
|
||||
ssr: false,
|
||||
loading: () => (
|
||||
<div className="min-h-[100px] border border-black bg-transparent" aria-busy="true" />
|
||||
),
|
||||
}
|
||||
);
|
||||
import { Experience } from '@/components/dashboard/resume-component';
|
||||
import { Plus, Trash2 } from 'lucide-react';
|
||||
import { useTranslations } from '@/lib/i18n';
|
||||
import {
|
||||
DndContext,
|
||||
closestCenter,
|
||||
PointerSensor,
|
||||
KeyboardSensor,
|
||||
useSensor,
|
||||
useSensors,
|
||||
DragEndEvent,
|
||||
} from '@dnd-kit/core';
|
||||
import {
|
||||
arrayMove,
|
||||
SortableContext,
|
||||
sortableKeyboardCoordinates,
|
||||
verticalListSortingStrategy,
|
||||
} from '@dnd-kit/sortable';
|
||||
import { DraggableListItem } from '../draggable-list-item';
|
||||
|
||||
interface ExperienceFormProps {
|
||||
data: Experience[];
|
||||
onChange: (data: Experience[]) => void;
|
||||
}
|
||||
|
||||
export const ExperienceForm: React.FC<ExperienceFormProps> = ({ data, onChange }) => {
|
||||
const { t } = useTranslations();
|
||||
|
||||
// Configure drag-and-drop sensors
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor),
|
||||
useSensor(KeyboardSensor, {
|
||||
coordinateGetter: sortableKeyboardCoordinates,
|
||||
})
|
||||
);
|
||||
|
||||
// Handler for drag end event
|
||||
const handleDragEnd = (event: DragEndEvent) => {
|
||||
const { active, over } = event;
|
||||
|
||||
if (!over || active.id === over.id) return;
|
||||
|
||||
const oldIndex = data.findIndex((item) => item.id === active.id);
|
||||
const newIndex = data.findIndex((item) => item.id === over.id);
|
||||
|
||||
if (oldIndex === -1 || newIndex === -1) return;
|
||||
|
||||
// Reorder the array using arrayMove from @dnd-kit
|
||||
const reordered = arrayMove(data, oldIndex, newIndex);
|
||||
onChange(reordered);
|
||||
};
|
||||
|
||||
const handleAdd = () => {
|
||||
const newId = Math.max(...data.map((d) => d.id), 0) + 1;
|
||||
onChange([
|
||||
...data,
|
||||
{
|
||||
id: newId,
|
||||
title: '',
|
||||
company: '',
|
||||
location: '',
|
||||
years: '',
|
||||
description: [''],
|
||||
},
|
||||
]);
|
||||
};
|
||||
|
||||
const handleRemove = (id: number) => {
|
||||
onChange(data.filter((item) => item.id !== id));
|
||||
};
|
||||
|
||||
const handleChange = (id: number, field: keyof Experience, value: string | string[]) => {
|
||||
onChange(
|
||||
data.map((item) => {
|
||||
if (item.id === id) {
|
||||
return { ...item, [field]: value };
|
||||
}
|
||||
return item;
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
const handleDescriptionChange = (id: number, index: number, value: string) => {
|
||||
onChange(
|
||||
data.map((item) => {
|
||||
if (item.id === id) {
|
||||
const newDesc = [...(item.description || [])];
|
||||
newDesc[index] = value;
|
||||
return { ...item, description: newDesc };
|
||||
}
|
||||
return item;
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
const handleAddDescription = (id: number) => {
|
||||
onChange(
|
||||
data.map((item) => {
|
||||
if (item.id === id) {
|
||||
return { ...item, description: [...(item.description || []), ''] };
|
||||
}
|
||||
return item;
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
const handleRemoveDescription = (id: number, index: number) => {
|
||||
onChange(
|
||||
data.map((item) => {
|
||||
if (item.id === id) {
|
||||
const newDesc = [...(item.description || [])];
|
||||
newDesc.splice(index, 1);
|
||||
return { ...item, description: newDesc };
|
||||
}
|
||||
return item;
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleAdd}
|
||||
className="rounded-none border-black hover:bg-black hover:text-white transition-colors"
|
||||
>
|
||||
<Plus className="w-4 h-4 mr-2" /> {t('builder.forms.experience.addJob')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{data.length === 0 ? (
|
||||
<div className="text-center py-12 bg-paper-tint border border-dashed border-black">
|
||||
<p className="font-mono text-sm text-steel-grey mb-4">
|
||||
{t('builder.genericItemForm.noEntries', { label: t('resume.sections.experience') })}
|
||||
</p>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleAdd}
|
||||
className="rounded-none border-black"
|
||||
>
|
||||
<Plus className="w-4 h-4 mr-2" /> {t('builder.forms.experience.addFirstJob')}
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
|
||||
<SortableContext
|
||||
items={data.map((item) => item.id)}
|
||||
strategy={verticalListSortingStrategy}
|
||||
>
|
||||
<div className="space-y-8">
|
||||
{data.map((item) => (
|
||||
<DraggableListItem key={item.id} id={item.id}>
|
||||
<div className="p-6 border border-black bg-paper-tint relative group">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="absolute top-2 right-2 opacity-0 group-hover:opacity-100 transition-opacity text-destructive hover:text-destructive hover:bg-destructive/10"
|
||||
onClick={() => handleRemove(item.id)}
|
||||
aria-label={t('a11y.removeItem')}
|
||||
title={t('a11y.removeItem')}
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</Button>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 mb-4 pr-8">
|
||||
<div className="space-y-2">
|
||||
<Label className="font-mono text-xs uppercase tracking-wider text-steel-grey">
|
||||
{t('builder.forms.experience.fields.jobTitle')}
|
||||
</Label>
|
||||
<Input
|
||||
value={item.title || ''}
|
||||
onChange={(e) => handleChange(item.id, 'title', e.target.value)}
|
||||
placeholder={t('builder.forms.experience.placeholders.jobTitle')}
|
||||
className="rounded-none border-black bg-white"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label className="font-mono text-xs uppercase tracking-wider text-steel-grey">
|
||||
{t('builder.forms.experience.fields.company')}
|
||||
</Label>
|
||||
<Input
|
||||
value={item.company || ''}
|
||||
onChange={(e) => handleChange(item.id, 'company', e.target.value)}
|
||||
placeholder={t('builder.forms.experience.placeholders.company')}
|
||||
className="rounded-none border-black bg-white"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label className="font-mono text-xs uppercase tracking-wider text-steel-grey">
|
||||
{t('builder.genericItemForm.fields.location')}
|
||||
</Label>
|
||||
<Input
|
||||
value={item.location || ''}
|
||||
onChange={(e) => handleChange(item.id, 'location', e.target.value)}
|
||||
placeholder={t('builder.forms.experience.placeholders.location')}
|
||||
className="rounded-none border-black bg-white"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label className="font-mono text-xs uppercase tracking-wider text-steel-grey">
|
||||
{t('builder.genericItemForm.fields.years')}
|
||||
</Label>
|
||||
<Input
|
||||
value={item.years || ''}
|
||||
onChange={(e) => handleChange(item.id, 'years', e.target.value)}
|
||||
placeholder={t('builder.forms.experience.placeholders.years')}
|
||||
className="rounded-none border-black bg-white"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div className="flex justify-between items-center">
|
||||
<Label className="font-mono text-xs uppercase tracking-wider text-steel-grey">
|
||||
{t('builder.genericItemForm.fields.descriptionPoints')}
|
||||
</Label>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleAddDescription(item.id)}
|
||||
className="h-6 text-xs text-blue-700 hover:text-blue-800 hover:bg-blue-50"
|
||||
>
|
||||
<Plus className="w-3 h-3 mr-1" />{' '}
|
||||
{t('builder.genericItemForm.actions.addPoint')}
|
||||
</Button>
|
||||
</div>
|
||||
{item.description?.map((desc, idx) => (
|
||||
<div key={idx} className="flex gap-2">
|
||||
<div className="flex-1">
|
||||
<RichTextEditor
|
||||
value={desc}
|
||||
onChange={(html) => handleDescriptionChange(item.id, idx, html)}
|
||||
placeholder={t('builder.forms.experience.placeholders.description')}
|
||||
minHeight="60px"
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => handleRemoveDescription(item.id, idx)}
|
||||
className="h-[60px] w-8 text-muted-foreground hover:text-destructive self-end"
|
||||
aria-label={t('a11y.removeDescription')}
|
||||
title={t('a11y.removeDescription')}
|
||||
>
|
||||
<Trash2 className="w-3 h-3" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</DraggableListItem>
|
||||
))}
|
||||
</div>
|
||||
</SortableContext>
|
||||
</DndContext>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,279 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import dynamic from 'next/dynamic';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Button } from '@/components/ui/button';
|
||||
|
||||
// Lazy-load TipTap-based editor — keeps it out of the initial bundle.
|
||||
const RichTextEditor = dynamic(
|
||||
() => import('@/components/ui/rich-text-editor').then((m) => m.RichTextEditor),
|
||||
{
|
||||
ssr: false,
|
||||
loading: () => (
|
||||
<div className="min-h-[100px] border border-black bg-transparent" aria-busy="true" />
|
||||
),
|
||||
}
|
||||
);
|
||||
import { Plus, Trash2 } from 'lucide-react';
|
||||
import type { CustomSectionItem } from '@/components/dashboard/resume-component';
|
||||
import { useTranslations } from '@/lib/i18n';
|
||||
|
||||
interface GenericItemFormProps {
|
||||
items: CustomSectionItem[];
|
||||
onChange: (items: CustomSectionItem[]) => void;
|
||||
itemLabel?: string;
|
||||
addLabel?: string;
|
||||
showSubtitle?: boolean;
|
||||
showLocation?: boolean;
|
||||
showYears?: boolean;
|
||||
titlePlaceholder?: string;
|
||||
subtitlePlaceholder?: string;
|
||||
locationPlaceholder?: string;
|
||||
yearsPlaceholder?: string;
|
||||
descriptionPlaceholder?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generic Item Form Component
|
||||
*
|
||||
* Used for ITEM_LIST type sections (like Experience, Education, Projects).
|
||||
* Renders a list of items with configurable fields.
|
||||
*/
|
||||
export const GenericItemForm: React.FC<GenericItemFormProps> = ({
|
||||
items,
|
||||
onChange,
|
||||
itemLabel,
|
||||
addLabel,
|
||||
showSubtitle = true,
|
||||
showLocation = true,
|
||||
showYears = true,
|
||||
titlePlaceholder,
|
||||
subtitlePlaceholder,
|
||||
locationPlaceholder,
|
||||
yearsPlaceholder,
|
||||
descriptionPlaceholder,
|
||||
}) => {
|
||||
const { t } = useTranslations();
|
||||
|
||||
const finalItemLabel = itemLabel ?? t('builder.genericItemForm.itemLabel');
|
||||
const finalAddLabel =
|
||||
addLabel ?? t('builder.genericItemForm.addItemLabel', { label: finalItemLabel });
|
||||
|
||||
const finalTitlePlaceholder = titlePlaceholder ?? t('builder.genericItemForm.placeholders.title');
|
||||
const finalSubtitlePlaceholder =
|
||||
subtitlePlaceholder ?? t('builder.genericItemForm.placeholders.organization');
|
||||
const finalLocationPlaceholder =
|
||||
locationPlaceholder ?? t('builder.genericItemForm.placeholders.location');
|
||||
const finalYearsPlaceholder = yearsPlaceholder ?? t('builder.genericItemForm.placeholders.years');
|
||||
const finalDescriptionPlaceholder =
|
||||
descriptionPlaceholder ?? t('builder.genericItemForm.placeholders.description');
|
||||
|
||||
const handleAdd = () => {
|
||||
const newId = Math.max(...items.map((d) => d.id), 0) + 1;
|
||||
onChange([
|
||||
...items,
|
||||
{
|
||||
id: newId,
|
||||
title: '',
|
||||
subtitle: '',
|
||||
location: '',
|
||||
years: '',
|
||||
description: [''],
|
||||
},
|
||||
]);
|
||||
};
|
||||
|
||||
const handleRemove = (id: number) => {
|
||||
onChange(items.filter((item) => item.id !== id));
|
||||
};
|
||||
|
||||
const handleChange = (id: number, field: keyof CustomSectionItem, value: string | string[]) => {
|
||||
onChange(
|
||||
items.map((item) => {
|
||||
if (item.id === id) {
|
||||
return { ...item, [field]: value };
|
||||
}
|
||||
return item;
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
const handleDescriptionChange = (id: number, index: number, value: string) => {
|
||||
onChange(
|
||||
items.map((item) => {
|
||||
if (item.id === id) {
|
||||
const newDesc = [...(item.description || [])];
|
||||
newDesc[index] = value;
|
||||
return { ...item, description: newDesc };
|
||||
}
|
||||
return item;
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
const handleAddDescription = (id: number) => {
|
||||
onChange(
|
||||
items.map((item) => {
|
||||
if (item.id === id) {
|
||||
return { ...item, description: [...(item.description || []), ''] };
|
||||
}
|
||||
return item;
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
const handleRemoveDescription = (id: number, index: number) => {
|
||||
onChange(
|
||||
items.map((item) => {
|
||||
if (item.id === id) {
|
||||
const newDesc = [...(item.description || [])];
|
||||
newDesc.splice(index, 1);
|
||||
return { ...item, description: newDesc };
|
||||
}
|
||||
return item;
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleAdd}
|
||||
className="rounded-none border-black hover:bg-black hover:text-white transition-colors"
|
||||
>
|
||||
<Plus className="w-4 h-4 mr-2" /> {finalAddLabel}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-8">
|
||||
{items.map((item) => (
|
||||
<div key={item.id} className="p-6 border border-black bg-paper-tint relative group">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="absolute top-2 right-2 opacity-0 group-hover:opacity-100 transition-opacity text-destructive hover:text-destructive hover:bg-destructive/10"
|
||||
onClick={() => handleRemove(item.id)}
|
||||
aria-label={t('a11y.removeItem')}
|
||||
title={t('a11y.removeItem')}
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</Button>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 mb-4 pr-8">
|
||||
<div className="space-y-2">
|
||||
<Label className="font-mono text-xs uppercase tracking-wider text-steel-grey">
|
||||
{t('builder.genericItemForm.fields.title')}
|
||||
</Label>
|
||||
<Input
|
||||
value={item.title || ''}
|
||||
onChange={(e) => handleChange(item.id, 'title', e.target.value)}
|
||||
placeholder={finalTitlePlaceholder}
|
||||
className="rounded-none border-black bg-white"
|
||||
/>
|
||||
</div>
|
||||
{showSubtitle && (
|
||||
<div className="space-y-2">
|
||||
<Label className="font-mono text-xs uppercase tracking-wider text-steel-grey">
|
||||
{t('builder.genericItemForm.fields.organization')}
|
||||
</Label>
|
||||
<Input
|
||||
value={item.subtitle || ''}
|
||||
onChange={(e) => handleChange(item.id, 'subtitle', e.target.value)}
|
||||
placeholder={finalSubtitlePlaceholder}
|
||||
className="rounded-none border-black bg-white"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{showLocation && (
|
||||
<div className="space-y-2">
|
||||
<Label className="font-mono text-xs uppercase tracking-wider text-steel-grey">
|
||||
{t('builder.genericItemForm.fields.location')}
|
||||
</Label>
|
||||
<Input
|
||||
value={item.location || ''}
|
||||
onChange={(e) => handleChange(item.id, 'location', e.target.value)}
|
||||
placeholder={finalLocationPlaceholder}
|
||||
className="rounded-none border-black bg-white"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{showYears && (
|
||||
<div className="space-y-2">
|
||||
<Label className="font-mono text-xs uppercase tracking-wider text-steel-grey">
|
||||
{t('builder.genericItemForm.fields.years')}
|
||||
</Label>
|
||||
<Input
|
||||
value={item.years || ''}
|
||||
onChange={(e) => handleChange(item.id, 'years', e.target.value)}
|
||||
placeholder={finalYearsPlaceholder}
|
||||
className="rounded-none border-black bg-white"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div className="flex justify-between items-center">
|
||||
<Label className="font-mono text-xs uppercase tracking-wider text-steel-grey">
|
||||
{t('builder.genericItemForm.fields.descriptionPoints')}
|
||||
</Label>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleAddDescription(item.id)}
|
||||
className="h-6 text-xs text-blue-700 hover:text-blue-800 hover:bg-blue-50"
|
||||
>
|
||||
<Plus className="w-3 h-3 mr-1" /> {t('builder.genericItemForm.actions.addPoint')}
|
||||
</Button>
|
||||
</div>
|
||||
{item.description?.map((desc, idx) => (
|
||||
<div key={idx} className="flex gap-2">
|
||||
<div className="flex-1">
|
||||
<RichTextEditor
|
||||
value={desc}
|
||||
onChange={(html) => handleDescriptionChange(item.id, idx, html)}
|
||||
placeholder={finalDescriptionPlaceholder}
|
||||
minHeight="60px"
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => handleRemoveDescription(item.id, idx)}
|
||||
className="h-[60px] w-8 text-muted-foreground hover:text-destructive self-end"
|
||||
aria-label={t('a11y.removeDescription')}
|
||||
title={t('a11y.removeDescription')}
|
||||
>
|
||||
<Trash2 className="w-3 h-3" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{items.length === 0 && (
|
||||
<div className="text-center py-12 bg-paper-tint border border-dashed border-black">
|
||||
<p className="font-mono text-sm text-steel-grey mb-4">
|
||||
{t('builder.genericItemForm.noEntries', { label: finalItemLabel })}
|
||||
</p>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleAdd}
|
||||
className="rounded-none border-black"
|
||||
>
|
||||
<Plus className="w-4 h-4 mr-2" />{' '}
|
||||
{t('builder.genericItemForm.addFirstItem', { label: finalItemLabel })}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,65 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { useTranslations } from '@/lib/i18n';
|
||||
|
||||
interface GenericListFormProps {
|
||||
items: string[];
|
||||
onChange: (items: string[]) => void;
|
||||
label?: string;
|
||||
placeholder?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generic List Form Component
|
||||
*
|
||||
* Used for STRING_LIST type sections (like Skills).
|
||||
* Renders a textarea where items are separated by newlines.
|
||||
*/
|
||||
export const GenericListForm: React.FC<GenericListFormProps> = ({
|
||||
items,
|
||||
onChange,
|
||||
label,
|
||||
placeholder,
|
||||
}) => {
|
||||
const { t } = useTranslations();
|
||||
const finalLabel = label ?? t('builder.customSections.itemsLabel');
|
||||
const finalPlaceholder = placeholder ?? t('builder.customSections.itemsPlaceholder');
|
||||
|
||||
const handleChange = (value: string) => {
|
||||
// Split by newlines, filter empty lines
|
||||
const newItems = value.split('\n').filter((item) => item.trim() !== '');
|
||||
onChange(newItems);
|
||||
};
|
||||
|
||||
const formatItems = (arr?: string[]) => {
|
||||
return arr?.join('\n') || '';
|
||||
};
|
||||
|
||||
// Explicitly allow Enter key to create newlines
|
||||
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.stopPropagation();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<Label className="font-mono text-xs uppercase tracking-wider text-steel-grey">
|
||||
{finalLabel}
|
||||
</Label>
|
||||
<p className="font-mono text-xs uppercase tracking-wider text-blue-700 mb-2">
|
||||
{t('builder.additionalForm.instructions')}
|
||||
</p>
|
||||
<Textarea
|
||||
value={formatItems(items)}
|
||||
onChange={(e) => handleChange(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder={finalPlaceholder}
|
||||
className="min-h-[150px] text-black rounded-none border-black bg-white focus-visible:ring-0 focus-visible:ring-offset-0 focus-visible:border-blue-700"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,52 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { useTranslations } from '@/lib/i18n';
|
||||
|
||||
interface GenericTextFormProps {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
label?: string;
|
||||
placeholder?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generic Text Form Component
|
||||
*
|
||||
* Used for TEXT type sections (like Summary).
|
||||
* Renders a single textarea for text content.
|
||||
*/
|
||||
export const GenericTextForm: React.FC<GenericTextFormProps> = ({
|
||||
value,
|
||||
onChange,
|
||||
label,
|
||||
placeholder,
|
||||
}) => {
|
||||
const { t } = useTranslations();
|
||||
const finalLabel = label ?? t('builder.customSections.contentLabel');
|
||||
const finalPlaceholder = placeholder ?? t('builder.customSections.defaultTextPlaceholder');
|
||||
|
||||
// Explicitly allow Enter key to create newlines
|
||||
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.stopPropagation();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<Label className="font-mono text-xs uppercase tracking-wider text-steel-grey">
|
||||
{finalLabel}
|
||||
</Label>
|
||||
<Textarea
|
||||
value={value || ''}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder={finalPlaceholder}
|
||||
className="min-h-[150px] text-black rounded-none border-black focus-visible:ring-0 focus-visible:ring-offset-0 focus-visible:border-blue-700 bg-white"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,155 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { PersonalInfo } from '@/components/dashboard/resume-component';
|
||||
import { useTranslations } from '@/lib/i18n';
|
||||
|
||||
interface PersonalInfoFormProps {
|
||||
data: PersonalInfo;
|
||||
onChange: (data: PersonalInfo) => void;
|
||||
}
|
||||
|
||||
export const PersonalInfoForm: React.FC<PersonalInfoFormProps> = ({ data, onChange }) => {
|
||||
const { t } = useTranslations();
|
||||
|
||||
const handleChange = (field: keyof PersonalInfo, value: string) => {
|
||||
onChange({
|
||||
...data,
|
||||
[field]: value,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4 border border-black p-6 bg-white shadow-sw-default">
|
||||
<h3 className="font-serif text-xl font-bold border-b border-black pb-2 mb-4">
|
||||
{t('builder.personalInfo')}
|
||||
</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label
|
||||
htmlFor="name"
|
||||
className="font-mono text-xs uppercase tracking-wider text-steel-grey"
|
||||
>
|
||||
{t('resume.personalInfo.name')}
|
||||
</Label>
|
||||
<Input
|
||||
id="name"
|
||||
value={data.name || ''}
|
||||
onChange={(e) => handleChange('name', e.target.value)}
|
||||
placeholder={t('builder.personalInfoForm.placeholders.name')}
|
||||
className="rounded-none border-black focus-visible:ring-0 focus-visible:ring-offset-0 focus-visible:border-blue-700 bg-transparent"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label
|
||||
htmlFor="title"
|
||||
className="font-mono text-xs uppercase tracking-wider text-steel-grey"
|
||||
>
|
||||
{t('resume.personalInfo.title')}
|
||||
</Label>
|
||||
<Input
|
||||
id="title"
|
||||
value={data.title || ''}
|
||||
onChange={(e) => handleChange('title', e.target.value)}
|
||||
placeholder={t('builder.personalInfoForm.placeholders.title')}
|
||||
className="rounded-none border-black focus-visible:ring-0 focus-visible:ring-offset-0 focus-visible:border-blue-700 bg-transparent"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label
|
||||
htmlFor="email"
|
||||
className="font-mono text-xs uppercase tracking-wider text-steel-grey"
|
||||
>
|
||||
{t('resume.personalInfo.email')}
|
||||
</Label>
|
||||
<Input
|
||||
id="email"
|
||||
type="email"
|
||||
value={data.email || ''}
|
||||
onChange={(e) => handleChange('email', e.target.value)}
|
||||
placeholder={t('builder.personalInfoForm.placeholders.email')}
|
||||
className="rounded-none border-black focus-visible:ring-0 focus-visible:ring-offset-0 focus-visible:border-blue-700 bg-transparent"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label
|
||||
htmlFor="phone"
|
||||
className="font-mono text-xs uppercase tracking-wider text-steel-grey"
|
||||
>
|
||||
{t('resume.personalInfo.phone')}
|
||||
</Label>
|
||||
<Input
|
||||
id="phone"
|
||||
type="tel"
|
||||
value={data.phone || ''}
|
||||
onChange={(e) => handleChange('phone', e.target.value)}
|
||||
placeholder={t('builder.personalInfoForm.placeholders.phone')}
|
||||
className="rounded-none border-black focus-visible:ring-0 focus-visible:ring-offset-0 focus-visible:border-blue-700 bg-transparent"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label
|
||||
htmlFor="location"
|
||||
className="font-mono text-xs uppercase tracking-wider text-steel-grey"
|
||||
>
|
||||
{t('resume.personalInfo.location')}
|
||||
</Label>
|
||||
<Input
|
||||
id="location"
|
||||
value={data.location || ''}
|
||||
onChange={(e) => handleChange('location', e.target.value)}
|
||||
placeholder={t('builder.personalInfoForm.placeholders.location')}
|
||||
className="rounded-none border-black focus-visible:ring-0 focus-visible:ring-offset-0 focus-visible:border-blue-700 bg-transparent"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label
|
||||
htmlFor="website"
|
||||
className="font-mono text-xs uppercase tracking-wider text-steel-grey"
|
||||
>
|
||||
{t('resume.personalInfo.website')}
|
||||
</Label>
|
||||
<Input
|
||||
id="website"
|
||||
value={data.website || ''}
|
||||
onChange={(e) => handleChange('website', e.target.value)}
|
||||
placeholder={t('builder.personalInfoForm.placeholders.website')}
|
||||
className="rounded-none border-black focus-visible:ring-0 focus-visible:ring-offset-0 focus-visible:border-blue-700 bg-transparent"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label
|
||||
htmlFor="linkedin"
|
||||
className="font-mono text-xs uppercase tracking-wider text-steel-grey"
|
||||
>
|
||||
{t('resume.personalInfo.linkedin')}
|
||||
</Label>
|
||||
<Input
|
||||
id="linkedin"
|
||||
value={data.linkedin || ''}
|
||||
onChange={(e) => handleChange('linkedin', e.target.value)}
|
||||
placeholder={t('builder.personalInfoForm.placeholders.linkedin')}
|
||||
className="rounded-none border-black focus-visible:ring-0 focus-visible:ring-offset-0 focus-visible:border-blue-700 bg-transparent"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label
|
||||
htmlFor="github"
|
||||
className="font-mono text-xs uppercase tracking-wider text-steel-grey"
|
||||
>
|
||||
{t('resume.personalInfo.github')}
|
||||
</Label>
|
||||
<Input
|
||||
id="github"
|
||||
value={data.github || ''}
|
||||
onChange={(e) => handleChange('github', e.target.value)}
|
||||
placeholder={t('builder.personalInfoForm.placeholders.github')}
|
||||
className="rounded-none border-black focus-visible:ring-0 focus-visible:ring-offset-0 focus-visible:border-blue-700 bg-transparent"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,246 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import dynamic from 'next/dynamic';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Button } from '@/components/ui/button';
|
||||
|
||||
// Lazy-load TipTap-based editor — keeps it out of the initial bundle.
|
||||
const RichTextEditor = dynamic(
|
||||
() => import('@/components/ui/rich-text-editor').then((m) => m.RichTextEditor),
|
||||
{
|
||||
ssr: false,
|
||||
loading: () => (
|
||||
<div className="min-h-[100px] border border-black bg-transparent" aria-busy="true" />
|
||||
),
|
||||
}
|
||||
);
|
||||
import { Project } from '@/components/dashboard/resume-component';
|
||||
import { Plus, Trash2, Github, Globe } from 'lucide-react';
|
||||
import { useTranslations } from '@/lib/i18n';
|
||||
|
||||
interface ProjectsFormProps {
|
||||
data: Project[];
|
||||
onChange: (data: Project[]) => void;
|
||||
}
|
||||
|
||||
export const ProjectsForm: React.FC<ProjectsFormProps> = ({ data, onChange }) => {
|
||||
const { t } = useTranslations();
|
||||
|
||||
const handleAdd = () => {
|
||||
const newId = Math.max(...data.map((d) => d.id), 0) + 1;
|
||||
onChange([
|
||||
...data,
|
||||
{
|
||||
id: newId,
|
||||
name: '',
|
||||
role: '',
|
||||
years: '',
|
||||
github: '',
|
||||
website: '',
|
||||
description: [''],
|
||||
},
|
||||
]);
|
||||
};
|
||||
|
||||
const handleRemove = (id: number) => {
|
||||
onChange(data.filter((item) => item.id !== id));
|
||||
};
|
||||
|
||||
const handleChange = (id: number, field: keyof Project, value: string | string[]) => {
|
||||
onChange(
|
||||
data.map((item) => {
|
||||
if (item.id === id) {
|
||||
return { ...item, [field]: value };
|
||||
}
|
||||
return item;
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
const handleDescriptionChange = (id: number, index: number, value: string) => {
|
||||
onChange(
|
||||
data.map((item) => {
|
||||
if (item.id === id) {
|
||||
const newDesc = [...(item.description || [])];
|
||||
newDesc[index] = value;
|
||||
return { ...item, description: newDesc };
|
||||
}
|
||||
return item;
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
const handleAddDescription = (id: number) => {
|
||||
onChange(
|
||||
data.map((item) => {
|
||||
if (item.id === id) {
|
||||
return { ...item, description: [...(item.description || []), ''] };
|
||||
}
|
||||
return item;
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
const handleRemoveDescription = (id: number, index: number) => {
|
||||
onChange(
|
||||
data.map((item) => {
|
||||
if (item.id === id) {
|
||||
const newDesc = [...(item.description || [])];
|
||||
newDesc.splice(index, 1);
|
||||
return { ...item, description: newDesc };
|
||||
}
|
||||
return item;
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleAdd}
|
||||
className="rounded-none border-black hover:bg-black hover:text-white transition-colors"
|
||||
>
|
||||
<Plus className="w-4 h-4 mr-2" /> {t('builder.forms.projects.addProject')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-8">
|
||||
{data.map((item) => (
|
||||
<div key={item.id} className="p-6 border border-black bg-paper-tint relative group">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="absolute top-2 right-2 opacity-0 group-hover:opacity-100 transition-opacity text-destructive hover:text-destructive hover:bg-destructive/10"
|
||||
onClick={() => handleRemove(item.id)}
|
||||
aria-label={t('a11y.removeItem')}
|
||||
title={t('a11y.removeItem')}
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</Button>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 mb-4 pr-8">
|
||||
<div className="space-y-2">
|
||||
<Label className="font-mono text-xs uppercase tracking-wider text-steel-grey">
|
||||
{t('builder.forms.projects.fields.projectName')}
|
||||
</Label>
|
||||
<Input
|
||||
value={item.name || ''}
|
||||
onChange={(e) => handleChange(item.id, 'name', e.target.value)}
|
||||
placeholder={t('builder.forms.projects.placeholders.projectName')}
|
||||
className="rounded-none border-black bg-white"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label className="font-mono text-xs uppercase tracking-wider text-steel-grey">
|
||||
{t('builder.forms.projects.fields.role')}
|
||||
</Label>
|
||||
<Input
|
||||
value={item.role || ''}
|
||||
onChange={(e) => handleChange(item.id, 'role', e.target.value)}
|
||||
placeholder={t('builder.forms.projects.placeholders.role')}
|
||||
className="rounded-none border-black bg-white"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label className="font-mono text-xs uppercase tracking-wider text-steel-grey">
|
||||
{t('builder.genericItemForm.fields.years')}{' '}
|
||||
<span className="text-steel-grey">({t('common.optional')})</span>
|
||||
</Label>
|
||||
<Input
|
||||
value={item.years || ''}
|
||||
onChange={(e) => handleChange(item.id, 'years', e.target.value)}
|
||||
placeholder={t('builder.forms.projects.placeholders.years')}
|
||||
className="rounded-none border-black bg-white"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label className="font-mono text-xs uppercase tracking-wider text-steel-grey">
|
||||
<Github className="w-3 h-3 inline mr-1" />
|
||||
GitHub <span className="text-steel-grey">({t('common.optional')})</span>
|
||||
</Label>
|
||||
<Input
|
||||
value={item.github || ''}
|
||||
onChange={(e) => handleChange(item.id, 'github', e.target.value)}
|
||||
placeholder={t('builder.forms.projects.placeholders.github')}
|
||||
className="rounded-none border-black bg-white"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2 md:col-span-2">
|
||||
<Label className="font-mono text-xs uppercase tracking-wider text-steel-grey">
|
||||
<Globe className="w-3 h-3 inline mr-1" />
|
||||
{t('builder.forms.projects.fields.website')}{' '}
|
||||
<span className="text-steel-grey">({t('common.optional')})</span>
|
||||
</Label>
|
||||
<Input
|
||||
value={item.website || ''}
|
||||
onChange={(e) => handleChange(item.id, 'website', e.target.value)}
|
||||
placeholder={t('builder.forms.projects.placeholders.website')}
|
||||
className="rounded-none border-black bg-white"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div className="flex justify-between items-center">
|
||||
<Label className="font-mono text-xs uppercase tracking-wider text-steel-grey">
|
||||
{t('builder.genericItemForm.fields.descriptionPoints')}
|
||||
</Label>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleAddDescription(item.id)}
|
||||
className="h-6 text-xs text-blue-700 hover:text-blue-800 hover:bg-blue-50"
|
||||
>
|
||||
<Plus className="w-3 h-3 mr-1" /> {t('builder.genericItemForm.actions.addPoint')}
|
||||
</Button>
|
||||
</div>
|
||||
{item.description?.map((desc, idx) => (
|
||||
<div key={idx} className="flex gap-2">
|
||||
<div className="flex-1">
|
||||
<RichTextEditor
|
||||
value={desc}
|
||||
onChange={(html) => handleDescriptionChange(item.id, idx, html)}
|
||||
placeholder={t('builder.forms.projects.placeholders.description')}
|
||||
minHeight="60px"
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => handleRemoveDescription(item.id, idx)}
|
||||
className="h-[60px] w-8 text-muted-foreground hover:text-destructive self-end"
|
||||
aria-label={t('a11y.removeDescription')}
|
||||
title={t('a11y.removeDescription')}
|
||||
>
|
||||
<Trash2 className="w-3 h-3" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{data.length === 0 && (
|
||||
<div className="text-center py-12 bg-paper-tint border border-dashed border-black">
|
||||
<p className="font-mono text-sm text-steel-grey mb-4">
|
||||
{t('builder.genericItemForm.noEntries', { label: t('resume.sections.projects') })}
|
||||
</p>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleAdd}
|
||||
className="rounded-none border-black"
|
||||
>
|
||||
<Plus className="w-4 h-4 mr-2" /> {t('builder.forms.projects.addFirstProject')}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,43 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { useTranslations } from '@/lib/i18n';
|
||||
|
||||
interface SummaryFormProps {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
}
|
||||
|
||||
export const SummaryForm: React.FC<SummaryFormProps> = ({ value, onChange }) => {
|
||||
const { t } = useTranslations();
|
||||
|
||||
// Explicitly allow Enter key to create newlines
|
||||
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.stopPropagation();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label
|
||||
htmlFor="summary"
|
||||
className="font-mono text-xs uppercase tracking-wider text-steel-grey"
|
||||
>
|
||||
{t('resume.sections.summary')}
|
||||
</Label>
|
||||
<Textarea
|
||||
id="summary"
|
||||
value={value || ''}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder={t('builder.placeholders.summary')}
|
||||
className="min-h-[150px] text-black rounded-none border-black focus-visible:ring-0 focus-visible:ring-offset-0 focus-visible:border-blue-700 bg-white"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user