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

688 lines
30 KiB
TypeScript
Raw Normal View History

/**
* 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>
);
}