All job seeker pages are already connected to real APIs:
- Jobs: /api/jobseeker/jobs (real company job postings)
- Applications: /api/jobseeker/applications (my applied jobs)
- Saved Jobs: Custom data storage for bookmarked jobs
- Apply: POST /api/jobseeker/jobs/{id}/apply
Dashboard shows real data from backend, not mock preview.
614 lines
25 KiB
TypeScript
614 lines
25 KiB
TypeScript
/**
|
||
* 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'];
|
||
|
||
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||
|
||
async function apiFetch(path: string, opts?: RequestInit) {
|
||
return fetch(`${API}${path}`, {
|
||
...opts,
|
||
credentials: 'include',
|
||
headers: { 'Content-Type': 'application/json', ...(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 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;
|
||
};
|
||
|
||
onMount(() => {
|
||
if (isJobSeeker()) {
|
||
loadJobSeekerPortfolio();
|
||
const tabs = runtimePortfolioTabs();
|
||
setJobSeekerTab(tabs[0] || 'About');
|
||
setLoading(false);
|
||
return;
|
||
}
|
||
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': '22px', 'font-weight': '800', color: '#0D0D2A' }}>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>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<div style={{ 'max-width': '800px' }}>
|
||
|
||
{/* ── Header ────────────────────────────────────────────────────── */}
|
||
<div style={{ display: 'flex', 'align-items': 'center', 'justify-content': 'space-between', 'margin-bottom': '16px' }}>
|
||
<div>
|
||
<p style={{ margin: '0', 'font-size': '22px', 'font-weight': '800', color: '#0D0D2A' }}>My Portfolio</p>
|
||
<p style={{ margin: '4px 0 0', 'font-size': '13px', color: '#6B7280' }}>
|
||
Showcase your work to attract clients.
|
||
</p>
|
||
</div>
|
||
<button type="button" onClick={openCreate} style={BTN_ORANGE}>
|
||
+ Add Item
|
||
</button>
|
||
</div>
|
||
|
||
{/* ── Create / Edit form ─────────────────────────────────────────── */}
|
||
<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. Wedding shoot at Udaipur"
|
||
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 work…"
|
||
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. wedding, outdoor, portrait"
|
||
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>
|
||
|
||
{/* ── Loading ─────────────────────────────────────────────────────── */}
|
||
<Show when={loading()}>
|
||
<div style={{ ...CARD, 'text-align': 'center', padding: '32px', color: '#9CA3AF', 'font-size': '14px' }}>
|
||
Loading portfolio…
|
||
</div>
|
||
</Show>
|
||
|
||
{/* ── Empty state ─────────────────────────────────────────────────── */}
|
||
<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>
|
||
|
||
{/* ── Portfolio grid ─────────────────────────────────────────────── */}
|
||
<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',
|
||
}}>
|
||
{/* Placeholder image area */}
|
||
<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>
|
||
);
|
||
}
|