Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -123,3 +123,5 @@ next-env.d.ts
# Config file with API keys - NEVER COMMIT
config.json
**/config.json

*.bat
40 changes: 40 additions & 0 deletions apps/backend/app/schemas/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,7 @@ class SectionType(str, Enum):
TEXT = "text" # Single text block (like summary)
ITEM_LIST = "itemList" # Array of items with fields (like experience)
STRING_LIST = "stringList" # Array of strings (like skills)
LABELED_LISTS = "labeledLists" # Multiple titled lists (like Skills & Languages)


# Resume Data Models (matching frontend types in resume-component.tsx)
Expand Down Expand Up @@ -228,13 +229,34 @@ def _normalize_description(cls, value: Any) -> list[str]:
return _coerce_string_list(value)


class LabeledListItem(BaseModel):
"""A labeled list for custom labeled-list sections (e.g., 'Technical Skills: Python, React')."""

id: int = 0
label: str = "" # Section label (e.g., "Technical Skills", "Languages")
items: list[str] = Field(default_factory=list) # Array of items for this label

@field_validator("label", mode="before")
@classmethod
def _normalize_label(cls, value: Any) -> str:
if value is None:
return ""
return _coerce_text(value)

@field_validator("items", mode="before")
@classmethod
def _normalize_items(cls, value: Any) -> list[str]:
return _coerce_string_list(value)


class CustomSection(BaseModel):
"""Custom section data container."""

sectionType: SectionType
items: list[CustomSectionItem] | None = None # For ITEM_LIST
strings: list[str] | None = None # For STRING_LIST
text: str | None = None # For TEXT
namedLists: list[LabeledListItem] | None = None # For LABELED_LISTS

@field_validator("items", mode="before")
@classmethod
Expand Down Expand Up @@ -263,6 +285,24 @@ def _normalize_strings(cls, value: Any) -> list[str] | None:
def _normalize_text(cls, value: Any) -> str | None:
return _coerce_optional_text(value)

@field_validator("namedLists", mode="before")
@classmethod
def _normalize_named_lists(cls, value: Any) -> list[LabeledListItem] | None:
if value is None:
return None
if not isinstance(value, list):
return value
result = []
for i, item in enumerate(value):
if isinstance(item, dict):
# Ensure id is set
if "id" not in item or item["id"] is None:
item["id"] = i + 1
result.append(item)
else:
result.append({"id": i + 1, "label": str(item), "items": []})
return result


# Default section metadata for backward compatibility
DEFAULT_SECTION_META: list[dict[str, Any]] = [
Expand Down
60 changes: 32 additions & 28 deletions apps/frontend/components/builder/add-section-dialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ import {
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Plus, FileText, List, ListOrdered } from 'lucide-react';
import { Plus, FileText, List, ListOrdered, Layers } from 'lucide-react';
import type { SectionType } from '@/components/dashboard/resume-component';
import { useTranslations } from '@/lib/i18n';

Expand Down Expand Up @@ -61,25 +61,31 @@ export const AddSectionDialog: React.FC<AddSectionDialogProps> = ({
icon: React.ReactNode;
description: string;
}[] = [
{
type: 'text',
label: t('builder.customSections.sectionTypes.textBlockLabel'),
icon: <FileText className="w-5 h-5" />,
description: t('builder.customSections.sectionTypes.textBlockDescription'),
},
{
type: 'itemList',
label: t('builder.customSections.sectionTypes.itemListLabel'),
icon: <ListOrdered className="w-5 h-5" />,
description: t('builder.customSections.sectionTypes.itemListDescription'),
},
{
type: 'stringList',
label: t('builder.customSections.sectionTypes.stringListLabel'),
icon: <List className="w-5 h-5" />,
description: t('builder.customSections.sectionTypes.stringListDescription'),
},
];
{
type: 'text',
label: t('builder.customSections.sectionTypes.textBlockLabel'),
icon: <FileText className="w-5 h-5" />,
description: t('builder.customSections.sectionTypes.textBlockDescription'),
},
{
type: 'itemList',
label: t('builder.customSections.sectionTypes.itemListLabel'),
icon: <ListOrdered className="w-5 h-5" />,
description: t('builder.customSections.sectionTypes.itemListDescription'),
},
{
type: 'stringList',
label: t('builder.customSections.sectionTypes.stringListLabel'),
icon: <List className="w-5 h-5" />,
description: t('builder.customSections.sectionTypes.stringListDescription'),
},
{
type: 'labeledLists',
label: t('builder.customSections.sectionTypes.labeledListsLabel'),
icon: <Layers className="w-5 h-5" />,
description: t('builder.customSections.sectionTypes.labeledListsDescription'),
},
];

return (
<Dialog open={open} onOpenChange={onOpenChange}>
Expand Down Expand Up @@ -120,19 +126,17 @@ export const AddSectionDialog: React.FC<AddSectionDialogProps> = ({
key={item.type}
type="button"
onClick={() => setSectionType(item.type)}
className={`w-full p-4 border text-left transition-colors ${
sectionType === item.type
className={`w-full p-4 border text-left transition-colors ${sectionType === item.type
? 'border-black bg-paper-tint shadow-sw-sm'
: 'border-steel-grey hover:border-steel-grey'
}`}
}`}
>
<div className="flex items-start gap-3">
<div
className={`p-2 border ${
sectionType === item.type
? 'border-black bg-white'
: 'border-steel-grey bg-paper-tint'
}`}
className={`p-2 border ${sectionType === item.type
? 'border-black bg-white'
: 'border-steel-grey bg-paper-tint'
}`}
>
{item.icon}
</div>
Expand Down
210 changes: 210 additions & 0 deletions apps/frontend/components/builder/forms/generic-labeled-list-form.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,210 @@
'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 { Plus, Trash2, ArrowUp, ArrowDown } from 'lucide-react';
import type { LabeledListItem } from '@/components/dashboard/resume-component';
import { useTranslations } from '@/lib/i18n';

interface GenericLabeledListFormProps {
items: LabeledListItem[];
onChange: (items: LabeledListItem[]) => void;
addLabel?: string;
labelPlaceholder?: string;
itemsPlaceholder?: string;
}

/**
* Generic Labeled List Form Component
*
* Used for LABELED_LISTS type sections.
* Renders a list of labeled subsections, each containing newline-separated items.
* Example: "Technical Skills: Python, React" + "Languages: English, Spanish"
*/
export const GenericLabeledListForm: React.FC<GenericLabeledListFormProps> = ({
items,
onChange,
addLabel,
labelPlaceholder,
itemsPlaceholder,
}) => {
const { t } = useTranslations();

const finalAddLabel = addLabel ?? t('builder.genericLabeledListForm.addSubsectionLabel');
const finalLabelPlaceholder =
labelPlaceholder ?? t('builder.genericLabeledListForm.labelPlaceholder');
const finalItemsPlaceholder =
itemsPlaceholder ?? t('builder.genericLabeledListForm.itemsPlaceholder');

const handleAdd = () => {
const newId = Math.max(...items.map((d) => d.id), 0) + 1;
onChange([
...items,
{
id: newId,
label: '',
items: [],
},
]);
};

const handleRemove = (id: number) => {
onChange(items.filter((item) => item.id !== id));
};

const handleLabelChange = (id: number, newLabel: string) => {
onChange(
items.map((item) => {
if (item.id === id) {
return { ...item, label: newLabel };
}
return item;
})
);
};

const handleItemsChange = (id: number, value: string) => {
// Split by newlines, filter empty lines
const newItems = value.split('\n').filter((item) => item.trim() !== '');
onChange(
items.map((item) => {
if (item.id === id) {
return { ...item, items: newItems };
}
return item;
})
);
};

const handleMoveUp = (id: number) => {
const index = items.findIndex((item) => item.id === id);
if (index <= 0) return;

const newItems = [...items];
[newItems[index - 1], newItems[index]] = [newItems[index], newItems[index - 1]];
onChange(newItems);
};

const handleMoveDown = (id: number) => {
const index = items.findIndex((item) => item.id === id);
if (index >= items.length - 1) return;

const newItems = [...items];
[newItems[index], newItems[index + 1]] = [newItems[index + 1], newItems[index]];
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-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-6">
{items.map((item, index) => (
<div key={item.id} className="p-6 border border-black bg-gray-50 relative group">
{/* Delete Button */}
<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)}
>
<Trash2 className="w-4 h-4" />
</Button>

{/* Reorder Buttons */}
<div className="absolute top-2 right-14 opacity-0 group-hover:opacity-100 transition-opacity flex gap-1">
<Button
variant="ghost"
size="icon"
disabled={index === 0}
className="h-8 w-8"
onClick={() => handleMoveUp(item.id)}
>
<ArrowUp className="w-3 h-3" />
</Button>
<Button
variant="ghost"
size="icon"
disabled={index === items.length - 1}
className="h-8 w-8"
onClick={() => handleMoveDown(item.id)}
>
<ArrowDown className="w-3 h-3" />
</Button>
</div>

<div className="space-y-4 pr-20">
{/* Label Input */}
<div className="space-y-2">
<Label className="font-mono text-xs uppercase tracking-wider text-gray-500">
{t('builder.genericLabeledListForm.fields.label')}
</Label>
<Input
value={item.label}
onChange={(e) => handleLabelChange(item.id, e.target.value)}
placeholder={finalLabelPlaceholder}
className="rounded-none border-black bg-white"
/>
</div>

{/* Items Textarea */}
<div className="space-y-2">
<Label className="font-mono text-xs uppercase tracking-wider text-gray-500">
{t('builder.genericLabeledListForm.fields.items')}
</Label>
<p className="font-mono text-xs text-blue-700 border-l-2 border-blue-700 pl-3 mb-2">
{t('builder.additionalForm.instructions')}
</p>
<Textarea
value={formatItems(item.items)}
onChange={(e) => handleItemsChange(item.id, e.target.value)}
onKeyDown={handleKeyDown}
placeholder={finalItemsPlaceholder}
className="min-h-[100px] 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>
))}

{items.length === 0 && (
<div className="text-center py-12 bg-gray-50 border border-dashed border-black">
<p className="font-mono text-sm text-gray-500 mb-4">
{t('builder.genericLabeledListForm.noSubsections')}
</p>
<Button
variant="outline"
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>
</div>
);
};
Loading