594 lines
22 KiB
TypeScript
594 lines
22 KiB
TypeScript
'use client';
|
|
|
|
import React, { useState } from 'react';
|
|
import { Button } from '@/components/ui/button';
|
|
import { ChevronDown, ChevronUp, RotateCcw } from 'lucide-react';
|
|
import {
|
|
type TemplateSettings,
|
|
type TemplateType,
|
|
type PageSize,
|
|
type SpacingLevel,
|
|
type HeaderFontFamily,
|
|
type BodyFontFamily,
|
|
type AccentColor,
|
|
DEFAULT_TEMPLATE_SETTINGS,
|
|
applyTemplatePreset,
|
|
SECTION_SPACING_MAP,
|
|
ITEM_SPACING_MAP,
|
|
LINE_HEIGHT_MAP,
|
|
FONT_SIZE_MAP,
|
|
HEADER_SCALE_MAP,
|
|
COMPACT_MULTIPLIER,
|
|
COMPACT_LINE_HEIGHT_MULTIPLIER,
|
|
TEMPLATE_OPTIONS,
|
|
PAGE_SIZE_INFO,
|
|
ACCENT_COLOR_MAP,
|
|
} from '@/lib/types/template-settings';
|
|
import { TemplateThumbnail } from './template-selector';
|
|
import { useTranslations } from '@/lib/i18n';
|
|
|
|
interface FormattingControlsProps {
|
|
settings: TemplateSettings;
|
|
onChange: (settings: TemplateSettings) => void;
|
|
}
|
|
|
|
/**
|
|
* Formatting Controls Panel
|
|
*
|
|
* Provides user controls for adjusting resume layout:
|
|
* - Template selection with visual thumbnails
|
|
* - Page size (A4 / US Letter)
|
|
* - Margins (top, bottom, left, right)
|
|
* - Section/item spacing
|
|
* - Line height
|
|
* - Font sizes
|
|
*
|
|
* Swiss design: Square buttons, monospace labels, high contrast
|
|
*/
|
|
export const FormattingControls: React.FC<FormattingControlsProps> = ({ settings, onChange }) => {
|
|
const { t } = useTranslations();
|
|
const [isExpanded, setIsExpanded] = useState(true);
|
|
const compactMultiplier = settings.compactMode ? COMPACT_MULTIPLIER : 1;
|
|
const sectionGapRem =
|
|
parseFloat(SECTION_SPACING_MAP[settings.spacing.section]) * compactMultiplier;
|
|
const itemGapRem = parseFloat(ITEM_SPACING_MAP[settings.spacing.item]) * compactMultiplier;
|
|
const lineHeightValue = settings.compactMode
|
|
? LINE_HEIGHT_MAP[settings.spacing.lineHeight] * COMPACT_LINE_HEIGHT_MULTIPLIER
|
|
: LINE_HEIGHT_MAP[settings.spacing.lineHeight];
|
|
|
|
const formatRem = (value: number) =>
|
|
`${value.toFixed(2).replace(/\.00$/, '').replace(/0$/, '')}rem`;
|
|
|
|
const handleTemplateChange = (template: TemplateType) => {
|
|
// Single-typeface templates (latex/clean) seed their signature fonts on selection
|
|
// so they match their reference look by default; both font controls stay live.
|
|
onChange(applyTemplatePreset(settings, template));
|
|
};
|
|
|
|
const handlePageSizeChange = (pageSize: PageSize) => {
|
|
onChange({ ...settings, pageSize });
|
|
};
|
|
|
|
const handleMarginChange = (key: keyof TemplateSettings['margins'], value: number) => {
|
|
onChange({
|
|
...settings,
|
|
margins: { ...settings.margins, [key]: value },
|
|
});
|
|
};
|
|
|
|
const handleSpacingChange = (key: keyof TemplateSettings['spacing'], value: SpacingLevel) => {
|
|
onChange({
|
|
...settings,
|
|
spacing: { ...settings.spacing, [key]: value },
|
|
});
|
|
};
|
|
|
|
const handleFontChange = (key: keyof TemplateSettings['fontSize'], value: SpacingLevel) => {
|
|
onChange({
|
|
...settings,
|
|
fontSize: { ...settings.fontSize, [key]: value },
|
|
});
|
|
};
|
|
|
|
const handleHeaderFontChange = (headerFont: HeaderFontFamily) => {
|
|
onChange({
|
|
...settings,
|
|
fontSize: { ...settings.fontSize, headerFont },
|
|
});
|
|
};
|
|
|
|
const handleBodyFontChange = (bodyFont: BodyFontFamily) => {
|
|
onChange({
|
|
...settings,
|
|
fontSize: { ...settings.fontSize, bodyFont },
|
|
});
|
|
};
|
|
|
|
const handleCompactModeToggle = () => {
|
|
onChange({ ...settings, compactMode: !settings.compactMode });
|
|
};
|
|
|
|
const handleShowContactIconsToggle = () => {
|
|
onChange({ ...settings, showContactIcons: !settings.showContactIcons });
|
|
};
|
|
|
|
const handleAccentColorChange = (accentColor: AccentColor) => {
|
|
onChange({ ...settings, accentColor });
|
|
};
|
|
|
|
const handleReset = () => {
|
|
onChange(DEFAULT_TEMPLATE_SETTINGS);
|
|
};
|
|
|
|
const templateLabels = React.useMemo(
|
|
() => ({
|
|
'swiss-single': {
|
|
name: t('builder.formatting.templates.swissSingle.name'),
|
|
description: t('builder.formatting.templates.swissSingle.description'),
|
|
},
|
|
'swiss-two-column': {
|
|
name: t('builder.formatting.templates.swissTwoColumn.name'),
|
|
description: t('builder.formatting.templates.swissTwoColumn.description'),
|
|
},
|
|
modern: {
|
|
name: t('builder.formatting.templates.modern.name'),
|
|
description: t('builder.formatting.templates.modern.description'),
|
|
},
|
|
'modern-two-column': {
|
|
name: t('builder.formatting.templates.modernTwoColumn.name'),
|
|
description: t('builder.formatting.templates.modernTwoColumn.description'),
|
|
},
|
|
latex: {
|
|
name: t('builder.formatting.templates.latex.name'),
|
|
description: t('builder.formatting.templates.latex.description'),
|
|
},
|
|
clean: {
|
|
name: t('builder.formatting.templates.clean.name'),
|
|
description: t('builder.formatting.templates.clean.description'),
|
|
},
|
|
vivid: {
|
|
name: t('builder.formatting.templates.vivid.name'),
|
|
description: t('builder.formatting.templates.vivid.description'),
|
|
},
|
|
}),
|
|
[t]
|
|
);
|
|
|
|
const getFontLabel = (font: HeaderFontFamily | BodyFontFamily) => {
|
|
if (font === 'sans-serif') return t('builder.formatting.fontNames.sans');
|
|
if (font === 'serif') return t('builder.formatting.fontNames.serif');
|
|
return t('builder.formatting.fontNames.mono');
|
|
};
|
|
|
|
return (
|
|
<div className="border border-black bg-white shadow-sw-default">
|
|
{/* Header - Always Visible */}
|
|
<button
|
|
onClick={() => setIsExpanded(!isExpanded)}
|
|
className="w-full flex items-center justify-between p-3 hover:bg-paper-tint transition-colors"
|
|
>
|
|
<div className="flex items-center gap-2">
|
|
<div className="w-2 h-2 bg-blue-700"></div>
|
|
<span className="font-mono text-xs font-bold uppercase tracking-wider">
|
|
{t('builder.formatting.panelTitle')}
|
|
</span>
|
|
</div>
|
|
{isExpanded ? (
|
|
<ChevronUp className="w-4 h-4 text-steel-grey" />
|
|
) : (
|
|
<ChevronDown className="w-4 h-4 text-steel-grey" />
|
|
)}
|
|
</button>
|
|
|
|
{/* Expandable Content */}
|
|
{isExpanded && (
|
|
<div className="border-t border-black p-4 space-y-6">
|
|
{/* Template Selection */}
|
|
<div>
|
|
<h4 className="font-mono text-xs font-bold uppercase tracking-wider mb-3 text-ink-soft">
|
|
{t('builder.formatting.template')}
|
|
</h4>
|
|
<div className="flex flex-wrap gap-3">
|
|
{TEMPLATE_OPTIONS.map((template) => (
|
|
<button
|
|
key={template.id}
|
|
onClick={() => handleTemplateChange(template.id)}
|
|
className={`group flex flex-col items-center p-2 border transition-all ${
|
|
settings.template === template.id
|
|
? 'border-blue-700 bg-white shadow-[2px_2px_0px_0px_#1D4ED8]'
|
|
: 'border-black bg-white hover:bg-paper-tint hover:shadow-sw-xs'
|
|
}`}
|
|
title={templateLabels[template.id].description}
|
|
>
|
|
<div className="w-12 h-16 mb-1.5 flex items-center justify-center">
|
|
<TemplateThumbnail
|
|
type={template.id}
|
|
isActive={settings.template === template.id}
|
|
/>
|
|
</div>
|
|
<span
|
|
className={`font-mono text-[9px] uppercase tracking-wider font-bold ${
|
|
settings.template === template.id ? 'text-blue-700' : 'text-ink-soft'
|
|
}`}
|
|
>
|
|
{templateLabels[template.id].name}
|
|
</span>
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Accent Color Selection - Visible for Modern templates */}
|
|
{(settings.template === 'modern' ||
|
|
settings.template === 'modern-two-column' ||
|
|
settings.template === 'vivid') && (
|
|
<div>
|
|
<h4 className="font-mono text-xs font-bold uppercase tracking-wider mb-3 text-ink-soft">
|
|
{t('builder.formatting.accentColor')}
|
|
</h4>
|
|
<div className="flex gap-2">
|
|
{(Object.keys(ACCENT_COLOR_MAP) as AccentColor[]).map((color) => (
|
|
<button
|
|
key={color}
|
|
onClick={() => handleAccentColorChange(color)}
|
|
className={`flex items-center gap-2 px-3 py-2 border font-mono text-xs transition-all ${
|
|
settings.accentColor === color
|
|
? 'border-blue-700 bg-white shadow-[2px_2px_0px_0px_#1D4ED8]'
|
|
: 'border-black bg-white hover:bg-paper-tint'
|
|
}`}
|
|
title={t(`builder.formatting.accentColors.${color}`)}
|
|
>
|
|
<span
|
|
className="w-4 h-4 border border-steel-grey"
|
|
style={{ backgroundColor: ACCENT_COLOR_MAP[color].primary }}
|
|
/>
|
|
<span>{t(`builder.formatting.accentColors.${color}`)}</span>
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Page Size Selection */}
|
|
<div>
|
|
<h4 className="font-mono text-xs font-bold uppercase tracking-wider mb-3 text-ink-soft">
|
|
{t('builder.formatting.pageSize')}
|
|
</h4>
|
|
<div className="flex gap-2">
|
|
{(Object.keys(PAGE_SIZE_INFO) as PageSize[]).map((size) => (
|
|
<button
|
|
key={size}
|
|
onClick={() => handlePageSizeChange(size)}
|
|
className={`flex-1 px-3 py-2 border font-mono text-xs transition-all ${
|
|
settings.pageSize === size
|
|
? 'border-blue-700 bg-white text-blue-700 shadow-[2px_2px_0px_0px_#1D4ED8]'
|
|
: 'border-black bg-white text-ink-soft hover:bg-paper-tint'
|
|
}`}
|
|
title={PAGE_SIZE_INFO[size].dimensions}
|
|
>
|
|
<div className="font-bold">
|
|
{size === 'A4' ? 'A4' : t('builder.pageSize.usLetter')}
|
|
</div>
|
|
<div className="text-[9px] opacity-70">{PAGE_SIZE_INFO[size].dimensions}</div>
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Margins Section */}
|
|
<div>
|
|
<h4 className="font-mono text-xs font-bold uppercase tracking-wider mb-3 text-ink-soft">
|
|
{t('builder.formatting.margins')}
|
|
</h4>
|
|
<div className="grid grid-cols-2 gap-4">
|
|
<MarginSlider
|
|
label={t('builder.formatting.margin.top')}
|
|
value={settings.margins.top}
|
|
onChange={(v) => handleMarginChange('top', v)}
|
|
/>
|
|
<MarginSlider
|
|
label={t('builder.formatting.margin.bottom')}
|
|
value={settings.margins.bottom}
|
|
onChange={(v) => handleMarginChange('bottom', v)}
|
|
/>
|
|
<MarginSlider
|
|
label={t('builder.formatting.margin.left')}
|
|
value={settings.margins.left}
|
|
onChange={(v) => handleMarginChange('left', v)}
|
|
/>
|
|
<MarginSlider
|
|
label={t('builder.formatting.margin.right')}
|
|
value={settings.margins.right}
|
|
onChange={(v) => handleMarginChange('right', v)}
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Spacing Section */}
|
|
<div>
|
|
<h4 className="font-mono text-xs font-bold uppercase tracking-wider mb-3 text-ink-soft">
|
|
{t('builder.formatting.spacing')}
|
|
</h4>
|
|
<div className="space-y-3">
|
|
<SpacingSelector
|
|
label={t('builder.formatting.spacingSection')}
|
|
value={settings.spacing.section}
|
|
onChange={(v) => handleSpacingChange('section', v)}
|
|
/>
|
|
<SpacingSelector
|
|
label={t('builder.formatting.spacingItems')}
|
|
value={settings.spacing.item}
|
|
onChange={(v) => handleSpacingChange('item', v)}
|
|
/>
|
|
<SpacingSelector
|
|
label={t('builder.formatting.spacingLines')}
|
|
value={settings.spacing.lineHeight}
|
|
onChange={(v) => handleSpacingChange('lineHeight', v)}
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Font Size Section */}
|
|
<div>
|
|
<h4 className="font-mono text-xs font-bold uppercase tracking-wider mb-3 text-ink-soft">
|
|
{t('builder.formatting.fontSize')}
|
|
</h4>
|
|
<div className="space-y-3">
|
|
<SpacingSelector
|
|
label={t('builder.formatting.baseFontSize')}
|
|
value={settings.fontSize.base}
|
|
onChange={(v) => handleFontChange('base', v)}
|
|
/>
|
|
<SpacingSelector
|
|
label={t('builder.formatting.headerScale')}
|
|
value={settings.fontSize.headerScale}
|
|
onChange={(v) => handleFontChange('headerScale', v)}
|
|
/>
|
|
{/* Header Font Family */}
|
|
<div className="flex items-center gap-2">
|
|
<span className="font-mono text-xs w-16 text-ink-soft">
|
|
{t('builder.formatting.headerFontFamily')}:
|
|
</span>
|
|
<div className="flex gap-1">
|
|
{(['serif', 'sans-serif', 'mono'] as HeaderFontFamily[]).map((font) => (
|
|
<button
|
|
key={font}
|
|
onClick={() => handleHeaderFontChange(font)}
|
|
className={`px-2 py-1 font-mono text-xs border transition-all ${
|
|
settings.fontSize.headerFont === font
|
|
? 'bg-blue-700 text-white border-blue-700 shadow-sw-xs'
|
|
: 'bg-white text-ink-soft border-steel-grey hover:border-black'
|
|
}`}
|
|
style={{
|
|
fontFamily:
|
|
font === 'serif'
|
|
? 'Georgia, serif'
|
|
: font === 'mono'
|
|
? 'monospace'
|
|
: 'system-ui, sans-serif',
|
|
}}
|
|
>
|
|
{getFontLabel(font)}
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
{/* Body Font Family */}
|
|
<div className="flex items-center gap-2">
|
|
<span className="font-mono text-xs w-16 text-ink-soft">
|
|
{t('builder.formatting.bodyFontFamily')}:
|
|
</span>
|
|
<div className="flex gap-1">
|
|
{(['serif', 'sans-serif', 'mono'] as BodyFontFamily[]).map((font) => (
|
|
<button
|
|
key={font}
|
|
onClick={() => handleBodyFontChange(font)}
|
|
className={`px-2 py-1 font-mono text-xs border transition-all ${
|
|
settings.fontSize.bodyFont === font
|
|
? 'bg-blue-700 text-white border-blue-700 shadow-sw-xs'
|
|
: 'bg-white text-ink-soft border-steel-grey hover:border-black'
|
|
}`}
|
|
style={{
|
|
fontFamily:
|
|
font === 'serif'
|
|
? 'Georgia, serif'
|
|
: font === 'mono'
|
|
? 'monospace'
|
|
: 'system-ui, sans-serif',
|
|
}}
|
|
>
|
|
{getFontLabel(font)}
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Options Section */}
|
|
<div>
|
|
<h4 className="font-mono text-xs font-bold uppercase tracking-wider mb-3 text-ink-soft">
|
|
{t('builder.formatting.options')}
|
|
</h4>
|
|
<div className="space-y-3">
|
|
{/* Compact Mode Toggle */}
|
|
<label className="flex items-center gap-3 cursor-pointer">
|
|
<button
|
|
onClick={handleCompactModeToggle}
|
|
className={`relative w-10 h-5 border-2 transition-all ${
|
|
settings.compactMode
|
|
? 'bg-blue-700 border-blue-700'
|
|
: 'bg-white border-steel-grey'
|
|
}`}
|
|
>
|
|
<span
|
|
className={`absolute top-0.5 w-3.5 h-3.5 bg-white border transition-all ${
|
|
settings.compactMode ? 'left-5 border-blue-700' : 'left-0.5 border-steel-grey'
|
|
}`}
|
|
/>
|
|
</button>
|
|
<span className="font-mono text-xs text-ink-soft">
|
|
{t('builder.formatting.compactMode')}
|
|
</span>
|
|
</label>
|
|
|
|
{/* Show Contact Icons Toggle */}
|
|
<label className="flex items-center gap-3 cursor-pointer">
|
|
<button
|
|
onClick={handleShowContactIconsToggle}
|
|
className={`relative w-10 h-5 border-2 transition-all ${
|
|
settings.showContactIcons
|
|
? 'bg-blue-700 border-blue-700'
|
|
: 'bg-white border-steel-grey'
|
|
}`}
|
|
>
|
|
<span
|
|
className={`absolute top-0.5 w-3.5 h-3.5 bg-white border transition-all ${
|
|
settings.showContactIcons
|
|
? 'left-5 border-blue-700'
|
|
: 'left-0.5 border-steel-grey'
|
|
}`}
|
|
/>
|
|
</button>
|
|
<span className="font-mono text-xs text-ink-soft">
|
|
{t('builder.formatting.contactIcons')}
|
|
</span>
|
|
</label>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Reset Button */}
|
|
<div className="pt-2 border-t border-paper-tint space-y-3">
|
|
<div>
|
|
<h4 className="font-mono text-[10px] font-bold uppercase tracking-wider text-ink-soft mb-2">
|
|
{t('builder.formatting.effectiveOutput')}
|
|
</h4>
|
|
<div className="font-mono text-[10px] text-ink-soft space-y-1">
|
|
<div title={t('builder.formatting.margins')}>
|
|
{t('builder.formatting.effectiveMargins', {
|
|
top: settings.margins.top,
|
|
bottom: settings.margins.bottom,
|
|
left: settings.margins.left,
|
|
right: settings.margins.right,
|
|
})}
|
|
</div>
|
|
<div>
|
|
{t('builder.formatting.effectiveSectionGap')}: {formatRem(sectionGapRem)}
|
|
</div>
|
|
<div>
|
|
{t('builder.formatting.effectiveItemGap')}: {formatRem(itemGapRem)}
|
|
</div>
|
|
<div>
|
|
{t('builder.formatting.effectiveLineHeight')}: {lineHeightValue.toFixed(2)}
|
|
</div>
|
|
<div>
|
|
{t('builder.formatting.effectiveBaseFont')}:{' '}
|
|
{FONT_SIZE_MAP[settings.fontSize.base]}
|
|
</div>
|
|
<div>
|
|
{t('builder.formatting.effectiveHeaderScale')}:{' '}
|
|
{HEADER_SCALE_MAP[settings.fontSize.headerScale]}x
|
|
</div>
|
|
<div>
|
|
{t('builder.formatting.effectiveHeaderFont')}:{' '}
|
|
{getFontLabel(settings.fontSize.headerFont)}
|
|
</div>
|
|
<div>
|
|
{t('builder.formatting.effectiveBodyFont')}:{' '}
|
|
{getFontLabel(settings.fontSize.bodyFont)}
|
|
</div>
|
|
</div>
|
|
{settings.compactMode && (
|
|
<div className="font-mono text-[10px] text-steel-grey mt-2">
|
|
{t('builder.formatting.compactHint')}
|
|
</div>
|
|
)}
|
|
</div>
|
|
<Button variant="outline" size="sm" onClick={handleReset} className="w-full">
|
|
<RotateCcw className="w-3 h-3" />
|
|
{t('builder.formatting.resetDefaults')}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
/**
|
|
* Margin Slider Component
|
|
*
|
|
* Range input for margin values (5-25mm)
|
|
*/
|
|
interface MarginSliderProps {
|
|
label: string;
|
|
value: number;
|
|
onChange: (value: number) => void;
|
|
}
|
|
|
|
const MarginSlider: React.FC<MarginSliderProps> = ({ label, value, onChange }) => {
|
|
return (
|
|
<div className="flex items-center gap-2">
|
|
<span className="font-mono text-xs w-12 text-ink-soft">{label}:</span>
|
|
<input
|
|
type="range"
|
|
min={5}
|
|
max={25}
|
|
value={value}
|
|
onChange={(e) => onChange(parseInt(e.target.value, 10))}
|
|
className="flex-1 h-1 bg-paper-tint rounded-none appearance-none cursor-pointer
|
|
[&::-webkit-slider-thumb]:appearance-none
|
|
[&::-webkit-slider-thumb]:w-3
|
|
[&::-webkit-slider-thumb]:h-3
|
|
[&::-webkit-slider-thumb]:bg-blue-700
|
|
[&::-webkit-slider-thumb]:border-none
|
|
[&::-webkit-slider-thumb]:cursor-pointer
|
|
[&::-moz-range-thumb]:w-3
|
|
[&::-moz-range-thumb]:h-3
|
|
[&::-moz-range-thumb]:bg-blue-700
|
|
[&::-moz-range-thumb]:border-none
|
|
[&::-moz-range-thumb]:cursor-pointer"
|
|
/>
|
|
<span className="font-mono text-xs w-6 text-right text-ink-soft">{value}</span>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
/**
|
|
* Spacing Selector Component
|
|
*
|
|
* Button group for selecting spacing levels (1-5)
|
|
*/
|
|
interface SpacingSelectorProps {
|
|
label: string;
|
|
value: SpacingLevel;
|
|
onChange: (value: SpacingLevel) => void;
|
|
}
|
|
|
|
const SpacingSelector: React.FC<SpacingSelectorProps> = ({ label, value, onChange }) => {
|
|
const levels: SpacingLevel[] = [1, 2, 3, 4, 5];
|
|
|
|
return (
|
|
<div className="flex items-center gap-2">
|
|
<span className="font-mono text-xs w-16 text-ink-soft">{label}:</span>
|
|
<div className="flex gap-1">
|
|
{levels.map((level) => (
|
|
<button
|
|
key={level}
|
|
onClick={() => onChange(level)}
|
|
className={`w-6 h-6 font-mono text-xs border transition-all ${
|
|
value === level
|
|
? 'bg-blue-700 text-white border-blue-700 shadow-sw-xs'
|
|
: 'bg-white text-ink-soft border-steel-grey hover:border-black'
|
|
}`}
|
|
>
|
|
{level}
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default FormattingControls;
|