282 lines
10 KiB
TypeScript
282 lines
10 KiB
TypeScript
'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>
|
|
);
|
|
};
|