nxtgauge-frontend-solid/src/components/dashboard/PortfolioPage.tsx

687 lines
30 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* PortfolioPage — real My Portfolio CRUD, wired to backend.
* Uses existing /api/:rolePrefix/portfolio/me endpoints for professionals.
* Job seekers get a dedicated portfolio editor (education/work experience/skills).
*/
import { For, Show, createSignal, onMount } from 'solid-js';
import { CARD, BTN_ORANGE, BTN_GHOST, BTN_PRIMARY, INPUT, LABEL } from '~/components/DashboardShell';
import { readJobSeekerProfile, updateJobSeekerCustomData } from '~/lib/job-seeker-custom-data';
const API = '/api/gateway';
// ── Role prefix map ───────────────────────────────────────────────────────────
const ROLE_PREFIX: Record<string, string> = {
PHOTOGRAPHER: 'photographers',
MAKEUP_ARTIST: 'makeup-artists',
TUTOR: 'tutors',
DEVELOPER: 'developers',
VIDEO_EDITOR: 'video-editors',
GRAPHIC_DESIGNER: 'graphic-designers',
SOCIAL_MEDIA_MANAGER:'social-media-managers',
FITNESS_TRAINER: 'fitness-trainers',
CATERING_SERVICES: 'catering-services',
UGC_CONTENT_CREATOR: 'ugc-content-creators',
};
// ── Types ─────────────────────────────────────────────────────────────────────
interface PortfolioItem {
id: string;
title: string;
description?: string;
tags?: string[];
created_at?: string;
}
interface FormState {
title: string;
description: string;
tags: string;
}
interface JobSeekerPortfolioState {
headline: string;
summary: string;
education: string;
workExperience: string;
skills: string;
}
const EMPTY_FORM: FormState = { title: '', description: '', tags: '' };
const EMPTY_JOB_SEEKER_FORM: JobSeekerPortfolioState = {
headline: '',
summary: '',
education: '',
workExperience: '',
skills: '',
};
const JOB_SEEKER_FALLBACK_TABS = ['About', 'Education', 'Work Experience', 'Skills'];
const PROFESSIONAL_PORTFOLIO_TABS: Record<string, string[]> = {
DEVELOPER: ['About', 'Services & Pricing', 'Projects', 'Tech Stack & Experience', 'Testimonials', 'FAQs'],
default: ['About', 'Services & Pricing', 'Projects', 'Experience', 'Testimonials', 'FAQs'],
};
type ProfessionalPortfolioState = {
about: string;
services: string;
experience: string;
testimonials: string;
faqs: string;
};
const EMPTY_PROFESSIONAL_FORM: ProfessionalPortfolioState = {
about: '',
services: '',
experience: '',
testimonials: '',
faqs: '',
};
// ── Helpers ───────────────────────────────────────────────────────────────────
async function apiFetch(path: string, opts?: RequestInit) {
const token =
typeof window !== "undefined"
? window.sessionStorage.getItem("nxtgauge_access_token") || ""
: "";
return fetch(`${API}${path}`, {
...opts,
credentials: "include",
headers: {
"Content-Type": "application/json",
...(token ? { Authorization: `Bearer ${token}` } : {}),
...(opts?.headers ?? {}),
},
});
}
// ── Component ─────────────────────────────────────────────────────────────────
interface Props {
roleKey: string;
runtimeTabs?: string[];
runtimeFields?: string[];
}
export default function PortfolioPage(props: Props) {
const prefix = () => ROLE_PREFIX[props.roleKey] ?? '';
const isProfessional = () => Boolean(prefix());
const isJobSeeker = () => String(props.roleKey || '').toUpperCase() === 'JOB_SEEKER';
const [items, setItems] = createSignal<PortfolioItem[]>([]);
const [loading, setLoading] = createSignal(true);
const [showForm, setShowForm] = createSignal(false);
const [editId, setEditId] = createSignal<string | null>(null);
const [form, setForm] = createSignal<FormState>({ ...EMPTY_FORM });
const [saving, setSaving] = createSignal(false);
const [deleting, setDeleting] = createSignal<string | null>(null);
const [error, setError] = createSignal('');
const [jobSeekerForm, setJobSeekerForm] = createSignal<JobSeekerPortfolioState>({ ...EMPTY_JOB_SEEKER_FORM });
const [jobSeekerSavedAt, setJobSeekerSavedAt] = createSignal('');
const [jobSeekerTab, setJobSeekerTab] = createSignal('About');
const [jobSeekerSaving, setJobSeekerSaving] = createSignal(false);
const [jobSeekerMsg, setJobSeekerMsg] = createSignal('');
const [jobSeekerErr, setJobSeekerErr] = createSignal('');
const [professionalTab, setProfessionalTab] = createSignal('About');
const [professionalForm, setProfessionalForm] = createSignal<ProfessionalPortfolioState>({ ...EMPTY_PROFESSIONAL_FORM });
const [professionalMsg, setProfessionalMsg] = createSignal('');
const loadItems = async () => {
if (!isProfessional()) { setLoading(false); return; }
setLoading(true);
try {
const res = await apiFetch(`/api/${prefix()}/portfolio/me`);
if (res.ok) {
const data = await res.json();
setItems(Array.isArray(data) ? data : (data.items ?? []));
}
} finally {
setLoading(false);
}
};
const loadJobSeekerPortfolio = () => {
readJobSeekerProfile()
.then((profile) => {
const parsed = profile?.custom_data?.job_seeker_portfolio as Record<string, unknown> | undefined;
if (!parsed || typeof parsed !== 'object') return;
setJobSeekerForm({
headline: String(parsed?.headline || ''),
summary: String(parsed?.summary || ''),
education: String(parsed?.education || ''),
workExperience: String(parsed?.workExperience || ''),
skills: String(parsed?.skills || ''),
});
setJobSeekerSavedAt(String(parsed?.savedAt || ''));
})
.catch(() => {
// ignore non-blocking load errors
});
};
const saveJobSeekerPortfolio = () => {
setJobSeekerSaving(true);
setJobSeekerMsg('');
setJobSeekerErr('');
const savedAt = new Date().toISOString();
const payload = { ...jobSeekerForm(), savedAt };
updateJobSeekerCustomData((current) => ({ ...current, job_seeker_portfolio: payload }))
.then(() => {
setJobSeekerSavedAt(savedAt);
setJobSeekerMsg('Portfolio saved successfully.');
})
.catch((e: any) => {
setJobSeekerErr(e?.message || 'Failed to save portfolio.');
})
.finally(() => {
setJobSeekerSaving(false);
});
};
const normalizeToken = (value: string) => String(value || '').trim().toLowerCase().replace(/[_-]+/g, ' ');
const toLabel = (value: string) =>
String(value || '')
.trim()
.replace(/[_-]+/g, ' ')
.replace(/\b\w/g, (c) => c.toUpperCase());
const runtimePortfolioTabs = () => {
const raw = Array.isArray(props.runtimeTabs) ? props.runtimeTabs : [];
const allowed = new Set(['about', 'education', 'work experience', 'skills']);
const mapped = raw
.map((tab) => toLabel(tab))
.filter(Boolean)
.map((tab) => {
const t = normalizeToken(tab);
if (t.includes('education')) return 'Education';
if (t.includes('work') || t.includes('experience')) return 'Work Experience';
if (t.includes('skill')) return 'Skills';
if (t.includes('about') || t.includes('overview') || t.includes('profile')) return 'About';
return '';
})
.filter((tab) => Boolean(tab) && allowed.has(normalizeToken(tab)));
const unique = Array.from(new Set(mapped));
return unique.length ? unique : JOB_SEEKER_FALLBACK_TABS;
};
const runtimeFieldsByTab = () => {
const fromRuntime = Array.isArray(props.runtimeFields) ? props.runtimeFields.map((f) => toLabel(String(f || ''))).filter(Boolean) : [];
const grouped: Record<string, string[]> = {
About: [],
Education: [],
'Work Experience': [],
Skills: [],
};
for (const field of fromRuntime) {
const key = normalizeToken(field);
if (key.includes('education') || key.includes('college') || key.includes('degree')) grouped.Education.push(field);
else if (key.includes('work') || key.includes('experience') || key.includes('employment')) grouped['Work Experience'].push(field);
else if (key.includes('skill') || key.includes('tool') || key.includes('technology')) grouped.Skills.push(field);
else grouped.About.push(field);
}
if (!grouped.About.length) grouped.About = ['Professional Headline', 'Career Summary'];
if (!grouped.Education.length) grouped.Education = ['Education'];
if (!grouped['Work Experience'].length) grouped['Work Experience'] = ['Work Experience'];
if (!grouped.Skills.length) grouped.Skills = ['Skills'];
return grouped;
};
const professionalTabs = () => {
const runtimeRaw = Array.isArray(props.runtimeTabs) ? props.runtimeTabs : [];
const fromRuntime = runtimeRaw
.map((tab) => toLabel(tab))
.filter(Boolean)
.map((tab) => {
const t = normalizeToken(tab);
if (t.includes('about') || t.includes('overview') || t.includes('profile')) return 'About';
if (t.includes('service') || t.includes('pricing') || t.includes('package')) return 'Services & Pricing';
if (t.includes('project') || t.includes('portfolio') || t.includes('gallery') || t.includes('showreel')) return 'Projects';
if (t.includes('stack') || t.includes('experience') || t.includes('qualification') || t.includes('tool')) return props.roleKey === 'DEVELOPER' ? 'Tech Stack & Experience' : 'Experience';
if (t.includes('testimonial') || t.includes('review')) return 'Testimonials';
if (t.includes('faq') || t.includes('question')) return 'FAQs';
return '';
})
.filter(Boolean);
const uniqueRuntime = Array.from(new Set(fromRuntime));
if (uniqueRuntime.length >= 3) return uniqueRuntime;
return PROFESSIONAL_PORTFOLIO_TABS[props.roleKey] || PROFESSIONAL_PORTFOLIO_TABS.default;
};
const professionalFormStorageKey = () => `nxtgauge_portfolio_meta_${String(props.roleKey || 'professional').toLowerCase()}`;
const loadProfessionalForm = () => {
if (typeof window === 'undefined') return;
try {
const raw = window.localStorage.getItem(professionalFormStorageKey());
if (!raw) return;
const parsed = JSON.parse(raw) as Partial<ProfessionalPortfolioState>;
setProfessionalForm({
about: String(parsed?.about || ''),
services: String(parsed?.services || ''),
experience: String(parsed?.experience || ''),
testimonials: String(parsed?.testimonials || ''),
faqs: String(parsed?.faqs || ''),
});
} catch {
// Ignore malformed local storage payloads.
}
};
const saveProfessionalForm = () => {
if (typeof window !== 'undefined') {
window.localStorage.setItem(professionalFormStorageKey(), JSON.stringify(professionalForm()));
}
setProfessionalMsg('Portfolio section saved.');
window.setTimeout(() => setProfessionalMsg(''), 1800);
};
onMount(() => {
if (isJobSeeker()) {
loadJobSeekerPortfolio();
const tabs = runtimePortfolioTabs();
setJobSeekerTab(tabs[0] || 'About');
setLoading(false);
return;
}
if (isProfessional()) {
const tabs = professionalTabs();
setProfessionalTab(tabs[0] || 'About');
loadProfessionalForm();
}
void loadItems();
});
const openCreate = () => {
setEditId(null);
setForm({ ...EMPTY_FORM });
setError('');
setShowForm(true);
};
const openEdit = (item: PortfolioItem) => {
setEditId(item.id);
setForm({
title: item.title,
description: item.description ?? '',
tags: (item.tags ?? []).join(', '),
});
setError('');
setShowForm(true);
};
const cancelForm = () => {
setShowForm(false);
setEditId(null);
setForm({ ...EMPTY_FORM });
setError('');
};
const setField = (key: keyof FormState, val: string) =>
setForm((prev) => ({ ...prev, [key]: val }));
const handleSave = async () => {
if (!form().title.trim()) { setError('Title is required.'); return; }
setSaving(true);
setError('');
const payload = {
title: form().title.trim(),
description: form().description.trim() || undefined,
tags: form().tags
.split(',')
.map((t) => t.trim())
.filter(Boolean),
};
try {
const id = editId();
const res = id
? await apiFetch(`/api/${prefix()}/portfolio/me/${id}`, { method: 'PATCH', body: JSON.stringify(payload) })
: await apiFetch(`/api/${prefix()}/portfolio/me`, { method: 'POST', body: JSON.stringify(payload) });
if (res.ok) {
await loadItems();
cancelForm();
} else {
const d = await res.json().catch(() => ({}));
setError(d.error ?? d.message ?? 'Failed to save. Please try again.');
}
} catch {
setError('Network error. Please try again.');
} finally {
setSaving(false);
}
};
const handleDelete = async (id: string) => {
if (!confirm('Delete this portfolio item?')) return;
setDeleting(id);
try {
await apiFetch(`/api/${prefix()}/portfolio/me/${id}`, { method: 'DELETE' });
await loadItems();
} finally {
setDeleting(null);
}
};
// ── Job seeker portfolio editor ─────────────────────────────────────────
if (isJobSeeker()) {
const setField = (fieldKey: string, value: string) => {
const key = normalizeToken(fieldKey);
setJobSeekerForm((prev) => {
if (key.includes('headline')) return { ...prev, headline: value };
if (key.includes('summary') || key.includes('about')) return { ...prev, summary: value };
if (key.includes('education') || key.includes('degree') || key.includes('college')) return { ...prev, education: value };
if (key.includes('work') || key.includes('experience') || key.includes('employment')) return { ...prev, workExperience: value };
if (key.includes('skill') || key.includes('tool') || key.includes('technology')) return { ...prev, skills: value };
return { ...prev, summary: value };
});
};
const readField = (fieldKey: string) => {
const key = normalizeToken(fieldKey);
if (key.includes('headline')) return jobSeekerForm().headline;
if (key.includes('summary') || key.includes('about')) return jobSeekerForm().summary;
if (key.includes('education') || key.includes('degree') || key.includes('college')) return jobSeekerForm().education;
if (key.includes('work') || key.includes('experience') || key.includes('employment')) return jobSeekerForm().workExperience;
if (key.includes('skill') || key.includes('tool') || key.includes('technology')) return jobSeekerForm().skills;
return '';
};
const tabs = runtimePortfolioTabs();
const fieldsByTab = runtimeFieldsByTab();
const activeTab = () => tabs.includes(jobSeekerTab()) ? jobSeekerTab() : (tabs[0] || 'About');
const activeFields = () => fieldsByTab[activeTab()] || [];
const isLongField = (field: string) => {
const key = normalizeToken(field);
return key.includes('summary')
|| key.includes('about')
|| key.includes('experience')
|| key.includes('education')
|| key.includes('skills');
};
return (
<div style={{ 'max-width': '900px' }}>
<div style={{ ...CARD, 'margin-bottom': '14px', padding: '0 16px' }}>
<div style={{ display: 'flex', gap: '20px', 'border-bottom': '1px solid #E5E7EB', padding: '12px 0 0' }}>
<For each={tabs}>
{(tab) => (
<button
type="button"
onClick={() => setJobSeekerTab(tab)}
style={{
padding: '0 0 10px',
border: 'none',
background: 'none',
cursor: 'pointer',
'font-size': '13px',
'font-weight': jobSeekerTab() === tab ? '700' : '500',
color: jobSeekerTab() === tab ? '#FF5E13' : '#6B7280',
'border-bottom': jobSeekerTab() === tab ? '2px solid #FF5E13' : '2px solid transparent',
'margin-bottom': '-1px',
}}
>
{tab}
</button>
)}
</For>
</div>
<div style={{ padding: '14px 0' }}>
<p style={{ margin: '0', 'font-size': '18px', 'font-weight': '800', color: '#111827' }}>My Portfolio</p>
<p style={{ margin: '6px 0 0', 'font-size': '13px', color: '#6B7280' }}>
Runtime-config driven form using configured tabs and fields.
</p>
<Show when={jobSeekerSavedAt()}>
<p style={{ margin: '8px 0 0', 'font-size': '12px', color: '#059669', 'font-weight': '600' }}>
Saved to profile at {new Date(jobSeekerSavedAt()).toLocaleString()}.
</p>
</Show>
</div>
</div>
<div style={{ ...CARD, display: 'grid', gap: '12px' }}>
<Show when={jobSeekerMsg()}>
<div style={{ border: '1px solid #BBF7D0', background: '#ECFDF5', color: '#065F46', 'border-radius': '10px', padding: '10px 12px', 'font-size': '12px', 'font-weight': '600' }}>
{jobSeekerMsg()}
</div>
</Show>
<Show when={jobSeekerErr()}>
<div style={{ border: '1px solid #FECACA', background: '#FEF2F2', color: '#B91C1C', 'border-radius': '10px', padding: '10px 12px', 'font-size': '12px', 'font-weight': '600' }}>
{jobSeekerErr()}
</div>
</Show>
<p style={{ margin: '0', 'font-size': '14px', 'font-weight': '700', color: '#111827' }}>{activeTab()}</p>
<div style={{ display: 'grid', 'grid-template-columns': '1fr 1fr', gap: '12px' }}>
<For each={activeFields()}>
{(field) => (
<div style={{ 'grid-column': isLongField(field) ? '1 / -1' : 'auto' }}>
<label style={LABEL}>{field}</label>
<Show
when={!isLongField(field)}
fallback={
<textarea
rows={4}
value={readField(field)}
onInput={(e) => setField(field, e.currentTarget.value)}
placeholder={`Enter ${field.toLowerCase()}`}
style={{ ...INPUT, height: 'auto', padding: '10px 12px', resize: 'vertical' }}
/>
}
>
<input
type="text"
value={readField(field)}
onInput={(e) => setField(field, e.currentTarget.value)}
placeholder={`Enter ${field.toLowerCase()}`}
style={INPUT}
/>
</Show>
</div>
)}
</For>
</div>
<div style={{ display: 'flex', gap: '10px', 'justify-content': 'flex-end' }}>
<button type="button" onClick={() => setJobSeekerForm({ ...EMPTY_JOB_SEEKER_FORM })} style={BTN_GHOST}>
Clear
</button>
<button type="button" onClick={saveJobSeekerPortfolio} disabled={jobSeekerSaving()} style={{ ...BTN_ORANGE, opacity: jobSeekerSaving() ? '0.7' : '1' }}>
{jobSeekerSaving() ? 'Saving...' : 'Save Portfolio'}
</button>
</div>
</div>
</div>
);
}
// ── Not a professional role ─────────────────────────────────────────────
if (!isProfessional()) {
return (
<div style={{ ...CARD, 'text-align': 'center', padding: '40px' }}>
<p style={{ margin: '0', 'font-size': '15px', color: '#9CA3AF' }}>
Portfolio is available for professional roles only.
</p>
</div>
);
}
const isProjectsTab = () => {
const key = normalizeToken(professionalTab());
return key.includes('project') || key.includes('portfolio') || key.includes('gallery') || key.includes('showreel');
};
const isServicesTab = () => {
const key = normalizeToken(professionalTab());
return key.includes('service') || key.includes('pricing') || key.includes('package');
};
const isTestimonialsTab = () => normalizeToken(professionalTab()).includes('testimonial');
const isFaqTab = () => normalizeToken(professionalTab()).includes('faq');
const sectionFieldKey = () => {
if (isServicesTab()) return 'services' as const;
if (isTestimonialsTab()) return 'testimonials' as const;
if (isFaqTab()) return 'faqs' as const;
const key = normalizeToken(professionalTab());
if (key.includes('experience') || key.includes('stack') || key.includes('tool') || key.includes('qualification')) return 'experience' as const;
return 'about' as const;
};
const sectionPlaceholder = () => {
if (isServicesTab()) return 'List your plans, pricing slabs, and deliverables...';
if (isTestimonialsTab()) return 'Add client quotes and project outcomes...';
if (isFaqTab()) return 'Add common questions and answers...';
if (sectionFieldKey() === 'experience') return 'Share stack, years of experience, and toolchain...';
return 'Write a short summary about your profile and strengths...';
};
return (
<div style={{ 'max-width': '800px' }}>
<div style={{ ...CARD, 'margin-bottom': '14px', padding: '0 16px' }}>
<div style={{ display: 'flex', gap: '20px', 'border-bottom': '1px solid #E5E7EB', padding: '12px 0 0', 'flex-wrap': 'wrap' }}>
<For each={professionalTabs()}>
{(tab) => (
<button
type="button"
onClick={() => setProfessionalTab(tab)}
style={{
padding: '0 0 10px',
border: 'none',
background: 'none',
cursor: 'pointer',
'font-size': '13px',
'font-weight': professionalTab() === tab ? '700' : '500',
color: professionalTab() === tab ? '#FF5E13' : '#6B7280',
'border-bottom': professionalTab() === tab ? '2px solid #FF5E13' : '2px solid transparent',
'margin-bottom': '-1px',
}}
>
{tab}
</button>
)}
</For>
</div>
<div style={{ display: 'flex', 'align-items': 'center', 'justify-content': 'space-between', 'padding': '14px 0' }}>
<div>
<p style={{ margin: '0', 'font-size': '18px', 'font-weight': '800', color: '#111827' }}>My Portfolio</p>
<p style={{ margin: '6px 0 0', 'font-size': '13px', color: '#6B7280' }}>
Runtime-config driven tab layout aligned with external dashboard preview.
</p>
</div>
<Show when={isProjectsTab()}>
<button type="button" onClick={openCreate} style={BTN_ORANGE}>
+ Add Item
</button>
</Show>
</div>
</div>
<Show
when={isProjectsTab()}
fallback={
<div style={{ ...CARD, display: 'grid', gap: '12px' }}>
<Show when={professionalMsg()}>
<div style={{ border: '1px solid #BBF7D0', background: '#ECFDF5', color: '#065F46', 'border-radius': '10px', padding: '10px 12px', 'font-size': '12px', 'font-weight': '600' }}>
{professionalMsg()}
</div>
</Show>
<p style={{ margin: '0', 'font-size': '14px', 'font-weight': '700', color: '#111827' }}>{professionalTab()}</p>
<div>
<label style={LABEL}>Section Content</label>
<textarea
rows={8}
value={professionalForm()[sectionFieldKey()]}
onInput={(e) => setProfessionalForm((prev) => ({ ...prev, [sectionFieldKey()]: e.currentTarget.value }))}
placeholder={sectionPlaceholder()}
style={{ ...INPUT, height: 'auto', padding: '10px 12px', resize: 'vertical' }}
/>
</div>
<div style={{ display: 'flex', gap: '10px', 'justify-content': 'flex-end' }}>
<button type="button" onClick={() => setProfessionalForm({ ...EMPTY_PROFESSIONAL_FORM })} style={BTN_GHOST}>
Clear
</button>
<button type="button" onClick={saveProfessionalForm} style={BTN_ORANGE}>
Save Section
</button>
</div>
</div>
}
>
<div>
<Show when={showForm()}>
<div style={{ ...CARD, 'margin-bottom': '16px', border: '1px solid #FF5E13' }}>
<p style={{ margin: '0 0 16px', 'font-size': '16px', 'font-weight': '800', color: '#0D0D2A' }}>
{editId() ? 'Edit Portfolio Item' : 'New Portfolio Item'}
</p>
<div style={{ display: 'grid', 'grid-template-columns': '1fr 1fr', gap: '14px' }}>
<div style={{ 'grid-column': 'span 2' }}>
<label style={LABEL}>Title <span style={{ color: '#EF4444' }}>*</span></label>
<input type="text" placeholder="e.g. Developer dashboard rebuild" value={form().title} onInput={(e) => setField('title', e.currentTarget.value)} style={INPUT} />
</div>
<div style={{ 'grid-column': 'span 2' }}>
<label style={LABEL}>Description</label>
<textarea rows={3} placeholder="Brief description of the project..." value={form().description} onInput={(e) => setField('description', e.currentTarget.value)} style={{ ...INPUT, height: 'auto', padding: '10px 12px', resize: 'vertical' }} />
</div>
<div style={{ 'grid-column': 'span 2' }}>
<label style={LABEL}>Tags (comma separated)</label>
<input type="text" placeholder="e.g. solidjs, rust, dashboard" value={form().tags} onInput={(e) => setField('tags', e.currentTarget.value)} style={INPUT} />
</div>
</div>
<Show when={error()}>
<p style={{ margin: '12px 0 0', 'font-size': '13px', color: '#EF4444', 'font-weight': '600' }}>{error()}</p>
</Show>
<div style={{ display: 'flex', gap: '10px', 'margin-top': '16px' }}>
<button type="button" onClick={handleSave} disabled={saving()} style={{ ...BTN_PRIMARY, opacity: saving() ? '0.6' : '1' }}>
{saving() ? 'Saving…' : editId() ? 'Update Item' : 'Add Item'}
</button>
<button type="button" onClick={cancelForm} style={BTN_GHOST}>Cancel</button>
</div>
</div>
</Show>
<Show when={loading()}>
<div style={{ ...CARD, 'text-align': 'center', padding: '32px', color: '#9CA3AF', 'font-size': '14px' }}>
Loading portfolio
</div>
</Show>
<Show when={!loading() && items().length === 0 && !showForm()}>
<div style={{ ...CARD, 'text-align': 'center', padding: '48px 24px' }}>
<p style={{ margin: '0', 'font-size': '40px' }}>🗂</p>
<p style={{ margin: '12px 0 4px', 'font-size': '16px', 'font-weight': '700', color: '#111827' }}>No portfolio items yet</p>
<p style={{ margin: '0 0 16px', 'font-size': '13px', color: '#6B7280' }}>Add your first work sample to attract clients.</p>
<button type="button" onClick={openCreate} style={BTN_ORANGE}>+ Add First Item</button>
</div>
</Show>
<Show when={!loading() && items().length > 0}>
<div style={{ display: 'grid', 'grid-template-columns': '1fr 1fr', gap: '12px' }}>
<For each={items()}>
{(item) => (
<div style={{ ...CARD, padding: '16px', display: 'flex', 'flex-direction': 'column', gap: '8px' }}>
<div style={{ height: '120px', 'border-radius': '8px', background: '#F3F4F6', display: 'flex', 'align-items': 'center', 'justify-content': 'center', color: '#D1D5DB', 'font-size': '28px' }}>🖼</div>
<p style={{ margin: '0', 'font-size': '14px', 'font-weight': '700', color: '#111827' }}>{item.title}</p>
<Show when={item.description}>
<p style={{ margin: '0', 'font-size': '12px', color: '#6B7280', 'line-height': '1.5' }}>{item.description}</p>
</Show>
<Show when={item.tags && item.tags.length > 0}>
<div style={{ display: 'flex', 'flex-wrap': 'wrap', gap: '4px' }}>
<For each={item.tags}>
{(tag) => <span style={{ 'font-size': '10px', 'font-weight': '700', color: '#6B7280', background: '#F3F4F6', border: '1px solid #E5E7EB', 'border-radius': '6px', padding: '2px 8px' }}>{tag}</span>}
</For>
</div>
</Show>
<div style={{ display: 'flex', gap: '8px', 'margin-top': '4px' }}>
<button type="button" onClick={() => openEdit(item)} style={{ ...BTN_GHOST, height: '30px', 'font-size': '11px', padding: '0 12px', flex: '1' }}>Edit</button>
<button
type="button"
onClick={() => handleDelete(item.id)}
disabled={deleting() === item.id}
style={{ height: '30px', 'border-radius': '8px', border: '1px solid #FECACA', background: '#fff', color: '#EF4444', 'font-size': '11px', 'font-weight': '700', padding: '0 12px', cursor: 'pointer', flex: '1', opacity: deleting() === item.id ? '0.6' : '1' }}
>
{deleting() === item.id ? '…' : 'Delete'}
</button>
</div>
</div>
)}
</For>
</div>
</Show>
</div>
</Show>
</div>
);
}