nxtgauge-admin-solid/src/routes/admin/approval/[id].tsx

618 lines
30 KiB
TypeScript
Raw Normal View History

import { A, useParams, useSearchParams } from '@solidjs/router';
import { createMemo, createResource, createSignal, For, Show, createEffect } from 'solid-js';
import AdminShell from '~/components/AdminShell';
const API = '/api/gateway';
// ── Types ──────────────────────────────────────────────────────────
type RoleType =
| 'COMPANY' | 'CANDIDATE' | 'CUSTOMER' | 'PHOTOGRAPHER' | 'MAKEUP_ARTIST'
| 'TUTOR' | 'DEVELOPER' | 'VIDEO_EDITOR' | 'GRAPHIC_DESIGNER'
| 'SOCIAL_MEDIA_MANAGER' | 'FITNESS_TRAINER' | 'CATERING_SERVICE'
| 'ADMIN' | 'UNKNOWN';
interface SubmissionData {
user: {
id: string;
name?: string;
email: string;
phone?: string;
status: string;
email_verified: boolean;
created_at: string;
};
role_key?: string;
onboarding?: {
status: string;
progress_json: Record<string, unknown>;
completed_at?: string;
updated_at: string;
} | null;
}
interface AdminRemark {
type: 'INFO' | 'CHANGES_REQUESTED' | 'MORE_DOCUMENTS_REQUESTED' | 'REJECTED';
comment: string;
fields?: string[];
}
// ── Helpers ──────────────────────────────────────────────────────────
function inferRoleType(roleKey?: string, name?: string): RoleType {
const raw = [roleKey, name].filter(Boolean).join(' ').toLowerCase();
if (raw.includes('photographer')) return 'PHOTOGRAPHER';
if (raw.includes('makeup')) return 'MAKEUP_ARTIST';
if (raw.includes('tutor')) return 'TUTOR';
if (raw.includes('developer')) return 'DEVELOPER';
if (raw.includes('video') || raw.includes('video_editor')) return 'VIDEO_EDITOR';
if (raw.includes('graphic') || raw.includes('graphic_designer')) return 'GRAPHIC_DESIGNER';
if (raw.includes('social') || raw.includes('social_media')) return 'SOCIAL_MEDIA_MANAGER';
if (raw.includes('fitness') || raw.includes('fitness_trainer')) return 'FITNESS_TRAINER';
if (raw.includes('catering')) return 'CATERING_SERVICE';
if (raw.includes('customer')) return 'CUSTOMER';
if (raw.includes('job_seeker') || raw.includes('candidate')) return 'CANDIDATE';
if (raw.includes('company')) return 'COMPANY';
if (raw.includes('admin') || raw.includes('employee')) return 'ADMIN';
return 'UNKNOWN';
}
function managementDest(roleType: RoleType): { label: string; href: string } {
const map: Record<RoleType, { label: string; href: string }> = {
COMPANY: { label: 'Company Management', href: '/admin/company' },
CANDIDATE: { label: 'Candidate Management', href: '/admin/candidate' },
CUSTOMER: { label: 'Customer Management', href: '/admin/customer' },
PHOTOGRAPHER: { label: 'Photographer Management', href: '/admin/photographer' },
MAKEUP_ARTIST: { label: 'Makeup Artist Management', href: '/admin/makeup-artist' },
TUTOR: { label: 'Tutors Management', href: '/admin/tutors' },
DEVELOPER: { label: 'Developers Management', href: '/admin/developers' },
VIDEO_EDITOR: { label: 'Video Editor Management', href: '/admin/video-editors' },
GRAPHIC_DESIGNER: { label: 'Graphics Designer Management', href: '/admin/graphic-designers' },
SOCIAL_MEDIA_MANAGER: { label: 'Social Media Manager Management', href: '/admin/social-media-managers' },
FITNESS_TRAINER: { label: 'Fitness Trainer Management', href: '/admin/fitness-trainers' },
CATERING_SERVICE: { label: 'Catering Services Management', href: '/admin/catering-services' },
ADMIN: { label: 'Employee Management', href: '/admin/employees' },
UNKNOWN: { label: 'Users Management', href: '/admin/users' },
};
return map[roleType] ?? map.UNKNOWN;
}
/** Flatten nested JSON into key→value pairs for display */
function flattenFields(obj: Record<string, unknown>, prefix = ''): Array<{ key: string; value: string }> {
const result: Array<{ key: string; value: string }> = [];
for (const [k, v] of Object.entries(obj)) {
const label = prefix ? `${prefix}.${k}` : k;
if (v !== null && typeof v === 'object' && !Array.isArray(v)) {
result.push(...flattenFields(v as Record<string, unknown>, label));
} else if (Array.isArray(v)) {
result.push({ key: label, value: v.join(', ') });
} else {
result.push({ key: label, value: String(v ?? '—') });
}
}
return result;
}
/** Skip internal tracking fields — only show what the user actually submitted */
const SKIP_KEYS = new Set(['step', 'total', 'currentStep', '__version', '__schema']);
function isSubmittedField(key: string): boolean {
return !SKIP_KEYS.has(key) && !key.startsWith('_');
}
const ROLE_COLORS: Record<RoleType, string> = {
COMPANY: 'background:#dbeafe;color:#1d4ed8',
CANDIDATE: 'background:#e0e7ff;color:#4338ca',
CUSTOMER: 'background:#cffafe;color:#0e7490',
PHOTOGRAPHER: 'background:#ede9fe;color:#6d28d9',
MAKEUP_ARTIST: 'background:#fce7f3;color:#9d174d',
TUTOR: 'background:#d1fae5;color:#065f46',
DEVELOPER: 'background:#e0f2fe;color:#0369a1',
VIDEO_EDITOR: 'background:#fef3c7;color:#92400e',
GRAPHIC_DESIGNER: 'background:#f0fdf4;color:#166534',
SOCIAL_MEDIA_MANAGER: 'background:#fdf2f8;color:#86198f',
FITNESS_TRAINER: 'background:#fff7ed;color:#9a3412',
CATERING_SERVICE: 'background:#fefce8;color:#854d0e',
ADMIN: 'background:#f1f5f9;color:#334155',
UNKNOWN: 'background:#f8fafc;color:#64748b',
};
// ── Field type detection ──────────────────────────────────────────────────────────
type FieldKind = 'image' | 'pdf' | 'document' | 'url' | 'text';
function detectKind(key: string, value: string): FieldKind {
const k = key.toLowerCase();
const v = (value || '').toLowerCase();
// Image: key hints OR common image extensions
if (
/photo|image|picture|avatar|selfie|headshot|thumbnail|profile_pic/i.test(k) ||
/\.(jpg|jpeg|png|gif|webp|bmp|svg)(\?|$)/i.test(v)
) return 'image';
// PDF
if (/\.(pdf)(\?|$)/i.test(v) || /pdf|resume|cv\b/i.test(k)) return 'pdf';
// Generic document/upload (not image/pdf)
if (
/upload|document|file|attachment|certificate|license|govt_id|aadhaar|pan|passport|degree|transcript|portfolio|id_proof/i.test(k) ||
/\.(doc|docx|xls|xlsx|ppt|pptx|zip|rar)(\?|$)/i.test(v)
) return 'document';
// URL that isn't a file
if (value.startsWith('http') || value.startsWith('/')) return 'url';
return 'text';
}
// ── Data loaders ──────────────────────────────────────────────────────────
async function loadSubmission(args: { userId: string; roleKey: string }): Promise<SubmissionData | null> {
if (!args.userId) return null;
try {
const qs = args.roleKey ? `?roleKey=${encodeURIComponent(args.roleKey)}` : '';
const res = await fetch(`${API}/api/admin/approvals/submission/${args.userId}${qs}`);
if (!res.ok) return null;
return res.json();
} catch {
return null;
}
}
// ── Page ──────────────────────────────────────────────────────────
export default function ApprovalDetailPage() {
const params = useParams();
const [searchParams] = useSearchParams();
// params.id can be either:
// - a user UUID → we load the submission directly
// - an old approval request ID → shown as legacy fallback
const userId = () => params.id;
const roleKey = () => (searchParams.roleKey as string) || '';
const [data] = createResource(
() => ({ userId: userId(), roleKey: roleKey() }),
loadSubmission,
);
const [acting, setActing] = createSignal('');
const [actionError, setActionError] = createSignal('');
const [actionDone, setActionDone] = createSignal('');
const roleType = createMemo(() => inferRoleType(data()?.role_key, undefined));
const dest = createMemo(() => managementDest(roleType()));
// Flatten progress_json into displayable rows
const submittedRows = createMemo(() => {
const pj = data()?.onboarding?.progress_json;
if (!pj || typeof pj !== 'object') return [];
return flattenFields(pj as Record<string, unknown>)
.filter((f) => isSubmittedField(f.key));
});
// ── Approve / Reject ──
// Routes: POST /api/admin/approvals/profiles/professional/{role_key}/{user_id}/approve
// POST /api/admin/approvals/profiles/company/{user_id}/approve
// POST /api/admin/approvals/profiles/customer/{user_id}/approve
const getApprovalPath = (action: 'approve' | 'reject') => {
const rk = (roleKey() || '').toUpperCase();
const uid = userId();
if (rk === 'COMPANY') return `/api/admin/approvals/profiles/company/${uid}/${action}`;
if (rk === 'CUSTOMER') return `/api/admin/approvals/profiles/customer/${uid}/${action}`;
if (rk) return `/api/admin/approvals/profiles/professional/${rk}/${uid}/${action}`;
return null;
};
const handleApprove = async () => {
if (!confirm('Approve this profile submission?')) return;
const path = getApprovalPath('approve');
if (!path) { setActionError('Cannot resolve approval endpoint — roleKey missing in URL'); return; }
try {
setActing('APPROVE');
setActionError('');
const res = await fetch(`${API}${path}`, { method: 'POST' });
if (!res.ok) throw new Error('Failed to approve');
setActionDone('APPROVED');
} catch (err: any) {
setActionError(err.message || 'Failed to approve');
} finally {
setActing('');
}
};
const handleReject = async () => {
const reason = prompt('Rejection reason (required):');
if (!reason?.trim()) return;
const path = getApprovalPath('reject');
if (!path) { setActionError('Cannot resolve rejection endpoint — roleKey missing in URL'); return; }
try {
setActing('REJECT');
setActionError('');
const res = await fetch(`${API}${path}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ reason: reason.trim() }),
});
if (!res.ok) throw new Error('Failed to reject');
setActionDone('REJECTED');
} catch (err: any) {
setActionError(err.message || 'Failed to reject');
} finally {
setActing('');
}
};
return (
<AdminShell>
<div class="mb-6 flex items-start justify-between gap-4">
<div>
<h1 class="text-2xl font-bold text-gray-900">Submission Review</h1>
<p class="mt-1 text-sm text-gray-500">Review a user's onboarding form submission and take action.</p>
</div>
<A class="inline-flex items-center rounded-lg border border-gray-200 bg-white px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50 transition-colors" href="/admin/approval"> Back to Approvals</A>
</div>
<Show when={actionError()}>
<div class="mb-4 rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700" style="margin-bottom:12px">{actionError()}</div>
</Show>
<Show when={actionDone()}>
<div class="rounded-xl border border-gray-200 bg-white shadow-sm" style="margin-bottom:12px;border-left:4px solid #22c55e;padding:12px 16px">
<p style="margin:0;font-weight:600;color:#166534">
{actionDone() === 'APPROVED' ? '✓ Profile approved successfully.' : '✕ Profile rejected.'}
</p>
<A href={dest().href} style="font-size:13px;color:#15803d;text-decoration:underline">
Open {dest().label}
</A>
</div>
</Show>
<Show when={data.loading}>
<div class="rounded-xl border border-gray-200 bg-white shadow-sm"><p class="notice">Loading submission...</p></div>
</Show>
<Show when={!data.loading && !data()}>
<div class="rounded-xl border border-gray-200 bg-white shadow-sm">
<p class="notice">Submission not found or user does not have an onboarding record for this role.</p>
<p style="font-size:13px;color:#64748b;margin-top:8px">
Make sure the URL includes <code>?roleKey=PHOTOGRAPHER</code> (or the relevant role key).
</p>
</div>
</Show>
<Show when={data()}>
{/* ── Action bar ── */}
<Show when={!actionDone()}>
<div class="rounded-xl border border-gray-200 bg-white shadow-sm" style="display:flex;flex-wrap:wrap;align-items:center;gap:10px;margin-bottom:16px;padding:12px 16px">
<span style={`display:inline-block;padding:3px 10px;border-radius:999px;font-size:12px;font-weight:600;${ROLE_COLORS[roleType()]}`}>
{(roleKey() || 'UNKNOWN').replace(/_/g, ' ')}
</span>
<span style="font-size:13px;color:#64748b">
Onboarding: <strong style={`color:${data()!.onboarding?.status === 'COMPLETED' ? '#15803d' : '#c2410c'}`}>
{data()!.onboarding?.status ?? 'NO DATA'}
</strong>
</span>
<div style="flex:1" />
<Show when={data()!.onboarding?.status === 'COMPLETED' && !actionDone()}>
<button
class="inline-flex items-center rounded-lg border border-gray-200 bg-white px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50 transition-colors"
style="background:#f0fdf4;color:#15803d;border-color:#bbf7d0"
disabled={!!acting()}
onClick={handleApprove}
>
{acting() === 'APPROVE' ? 'Approving...' : '✓ Approve Profile'}
</button>
<button
class="inline-flex items-center rounded-lg border border-red-200 bg-red-50 px-4 py-2 text-sm font-medium text-red-600 hover:bg-red-100 transition-colors"
disabled={!!acting()}
onClick={handleReject}
>
{acting() === 'REJECT' ? 'Rejecting...' : '✕ Reject Profile'}
</button>
</Show>
</div>
</Show>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:16px;margin-bottom:16px">
{/* User info */}
<div class="rounded-xl border border-gray-200 bg-white shadow-sm">
<h3 style="margin:0 0 12px;font-size:15px;font-weight:600;color:#0f172a">User Info</h3>
<table style="width:100%;border-collapse:collapse;font-size:13px">
<tbody>
<tr><td style="color:#64748b;padding:5px 10px 5px 0;white-space:nowrap;width:40%">Name</td><td style="font-weight:500">{data()!.user.name || '—'}</td></tr>
<tr><td style="color:#64748b;padding:5px 10px 5px 0">Email</td><td style="color:#475569">{data()!.user.email}</td></tr>
<tr><td style="color:#64748b;padding:5px 10px 5px 0">Phone</td><td style="color:#475569">{data()!.user.phone || '—'}</td></tr>
<tr>
<td style="color:#64748b;padding:5px 10px 5px 0">Account Status</td>
<td>
<span class={`inline-flex items-center rounded-full bg-gray-100 px-2.5 py-0.5 text-xs font-medium text-gray-600${data()!.user.status === 'ACTIVE' ? ' active' : ''}`}>
{data()!.user.status}
</span>
</td>
</tr>
<tr>
<td style="color:#64748b;padding:5px 10px 5px 0">Email Verified</td>
<td style={`font-weight:500;color:${data()!.user.email_verified ? '#15803d' : '#b91c1c'}`}>
{data()!.user.email_verified ? '✓ Yes' : '✕ No'}
</td>
</tr>
<tr><td style="color:#64748b;padding:5px 10px 5px 0">Registered</td><td style="color:#475569">{new Date(data()!.user.created_at).toLocaleDateString()}</td></tr>
<tr><td style="color:#64748b;padding:5px 10px 5px 0">User ID</td><td style="font-family:ui-monospace,monospace;font-size:11px;color:#94a3b8">{data()!.user.id}</td></tr>
</tbody>
</table>
</div>
{/* Submission status */}
<div class="rounded-xl border border-gray-200 bg-white shadow-sm">
<h3 style="margin:0 0 12px;font-size:15px;font-weight:600;color:#0f172a">Submission Info</h3>
<Show when={data()!.onboarding} fallback={
<div style="background:#fef2f2;border:1px solid #fecaca;border-radius:8px;padding:14px">
<p style="margin:0;color:#b91c1c;font-weight:500">No onboarding data found</p>
<p style="margin:6px 0 0;font-size:13px;color:#7f1d1d">
This user has not started or submitted the onboarding form for role: <strong>{roleKey() || 'unknown'}</strong>
</p>
</div>
}>
<table style="width:100%;border-collapse:collapse;font-size:13px">
<tbody>
<tr><td style="color:#64748b;padding:5px 10px 5px 0;white-space:nowrap;width:40%">Role</td><td style="font-weight:500">{(roleKey() || '—').replace(/_/g, ' ')}</td></tr>
<tr>
<td style="color:#64748b;padding:5px 10px 5px 0">Status</td>
<td>
<span style={`display:inline-block;padding:2px 8px;border-radius:4px;font-size:12px;font-weight:600;${data()!.onboarding!.status === 'COMPLETED' ? 'background:#dcfce7;color:#166534' : 'background:#fef9c3;color:#713f12'}`}>
{data()!.onboarding!.status}
</span>
</td>
</tr>
<tr><td style="color:#64748b;padding:5px 10px 5px 0">Submitted</td><td style="color:#475569">{data()!.onboarding!.completed_at ? new Date(data()!.onboarding!.completed_at!).toLocaleString() : '—'}</td></tr>
<tr><td style="color:#64748b;padding:5px 10px 5px 0">Last Updated</td><td style="color:#475569">{new Date(data()!.onboarding!.updated_at).toLocaleString()}</td></tr>
<tr><td style="color:#64748b;padding:5px 10px 5px 0">Fields</td><td style="color:#475569">{submittedRows().length} fields submitted</td></tr>
</tbody>
</table>
</Show>
</div>
</div>
{/* ── Submitted form answers + media viewer ── */}
<Show when={submittedRows().length > 0}>
<SubmissionViewer rows={submittedRows()} />
</Show>
{/* ── No form data fallback ── */}
<Show when={data()!.onboarding && submittedRows().length === 0}>
<div class="rounded-xl border border-gray-200 bg-white shadow-sm" style="margin-bottom:16px">
<h3 style="margin:0 0 10px;font-size:15px;font-weight:600;color:#0f172a">Submitted Form Answers</h3>
<p class="notice">Onboarding state is present but contains no displayable field data.</p>
</div>
</Show>
</Show>
</AdminShell>
);
}
// ────────────────────────────── SubmissionViewer ──────────────────────────────
function SubmissionViewer(props: { rows: Array<{ key: string; value: string }> }) {
const [lightbox, setLightbox] = createSignal<{ src: string; label: string } | null>(null);
const [pdfViewer, setPdfViewer] = createSignal<{ src: string; label: string } | null>(null);
// Split rows into text fields vs media
const textFields = createMemo(() =>
props.rows.filter((r) => {
const kind = detectKind(r.key, r.value);
return kind === 'text' || kind === 'url';
})
);
const mediaFields = createMemo(() =>
props.rows.filter((r) => {
const kind = detectKind(r.key, r.value);
return kind === 'image' || kind === 'pdf' || kind === 'document';
})
);
return (
<>
{/* ── Text / data fields ── */}
<Show when={textFields().length > 0}>
<div class="rounded-xl border border-gray-200 bg-white shadow-sm" style="margin-bottom:16px">
<h3 style="margin:0 0 14px;font-size:15px;font-weight:600;color:#0f172a">
Submitted Form Data
<span style="margin-left:8px;font-size:12px;font-weight:400;color:#64748b">{textFields().length} fields</span>
</h3>
<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(240px,1fr));gap:8px">
<For each={textFields()}>
{(field) => {
const kind = detectKind(field.key, field.value);
return (
<div style="background:#f8fafc;border:1px solid #e2e8f0;border-radius:8px;padding:10px 12px">
<div style="font-size:10px;color:#94a3b8;margin-bottom:4px;font-weight:700;letter-spacing:0.05em">
{field.key.replace(/_/g, ' ').replace(/\./g, ' ').toUpperCase()}
</div>
<Show when={kind === 'url'} fallback={
<div style="font-size:13px;color:#0f172a;word-break:break-all;line-height:1.5">{field.value || '—'}</div>
}>
<a href={field.value} target="_blank" rel="noreferrer"
style="font-size:13px;color:#2563eb;word-break:break-all">
🔗 {field.value}
</a>
</Show>
</div>
);
}}
</For>
</div>
</div>
</Show>
{/* ── Documents & Images ── */}
<Show when={mediaFields().length > 0}>
<div class="rounded-xl border border-gray-200 bg-white shadow-sm" style="margin-bottom:16px">
<h3 style="margin:0 0 14px;font-size:15px;font-weight:600;color:#0f172a">
Documents & Media
<span style="margin-left:8px;font-size:12px;font-weight:400;color:#64748b">{mediaFields().length} file{mediaFields().length !== 1 ? 's' : ''}</span>
</h3>
<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(200px,1fr));gap:12px">
<For each={mediaFields()}>
{(field) => {
const kind = detectKind(field.key, field.value);
const label = field.key.replace(/_/g, ' ').replace(/\./g, ' ');
return (
<div style="border:1px solid #e2e8f0;border-radius:10px;overflow:hidden;background:#fff">
{/* Preview area */}
<Show when={kind === 'image'}>
<div
style="background:#f1f5f9;cursor:pointer;position:relative;height:140px;display:flex;align-items:center;justify-content:center;overflow:hidden"
onClick={() => setLightbox({ src: field.value, label })}
>
<img
src={field.value}
alt={label}
style="max-width:100%;max-height:140px;object-fit:contain"
onError={(e) => {
(e.target as HTMLImageElement).style.display = 'none';
((e.target as HTMLImageElement).nextElementSibling as HTMLElement)!.style.display = 'flex';
}}
/>
<div style="display:none;width:100%;height:100%;align-items:center;justify-content:center;flex-direction:column;gap:4px;color:#94a3b8">
<span style="font-size:32px">🖼</span>
<span style="font-size:11px">Preview unavailable</span>
</div>
<div style="position:absolute;top:6px;right:6px;background:rgba(0,0,0,.5);color:#fff;border-radius:4px;font-size:10px;padding:2px 6px">
🔍 Click to enlarge
</div>
</div>
</Show>
<Show when={kind === 'pdf'}>
<div
style="background:#fef2f2;cursor:pointer;height:140px;display:flex;align-items:center;justify-content:center;flex-direction:column;gap:6px"
onClick={() => setPdfViewer({ src: field.value, label })}
>
<span style="font-size:40px">📄</span>
<span style="font-size:12px;color:#b91c1c;font-weight:600">PDF Document</span>
<span style="font-size:11px;color:#94a3b8">Click to view</span>
</div>
</Show>
<Show when={kind === 'document'}>
<div style="background:#eff6ff;height:140px;display:flex;align-items:center;justify-content:center;flex-direction:column;gap:6px">
<span style="font-size:40px">📎</span>
<span style="font-size:12px;color:#1d4ed8;font-weight:600">Document</span>
<a href={field.value} target="_blank" rel="noreferrer"
style="font-size:11px;color:#2563eb;text-decoration:underline"
onClick={(e) => e.stopPropagation()}>
Download
</a>
</div>
</Show>
{/* Label + open link */}
<div style="padding:8px 10px;border-top:1px solid #e2e8f0">
<div style="font-size:11px;color:#0f172a;font-weight:600;margin-bottom:4px;text-transform:capitalize">
{label}
</div>
<div style="display:flex;gap:6px">
<Show when={kind === 'image'}>
<button
type="button"
style="font-size:11px;color:#2563eb;background:none;border:none;padding:0;cursor:pointer;text-decoration:underline"
onClick={() => setLightbox({ src: field.value, label })}
>
🔍 View Full
</button>
</Show>
<Show when={kind === 'pdf'}>
<button
type="button"
style="font-size:11px;color:#b91c1c;background:none;border:none;padding:0;cursor:pointer;text-decoration:underline"
onClick={() => setPdfViewer({ src: field.value, label })}
>
📄 Open PDF
</button>
</Show>
<a href={field.value} target="_blank" rel="noreferrer"
style="font-size:11px;color:#64748b;text-decoration:underline">
Download
</a>
</div>
</div>
</div>
);
}}
</For>
</div>
</div>
</Show>
{/* ── Image Lightbox ── */}
<Show when={lightbox()}>
<div
style="position:fixed;inset:0;background:rgba(0,0,0,.85);z-index:9999;display:flex;flex-direction:column;align-items:center;justify-content:center;padding:20px"
onClick={() => setLightbox(null)}
>
<div style="display:flex;align-items:center;justify-content:space-between;width:100%;max-width:900px;margin-bottom:12px">
<span style="color:#f8fafc;font-size:14px;font-weight:600">{lightbox()!.label}</span>
<div style="display:flex;gap:10px">
<a href={lightbox()!.src} target="_blank" rel="noreferrer"
style="color:#93c5fd;font-size:13px;text-decoration:underline"
onClick={(e) => e.stopPropagation()}>
Open original
</a>
<button
type="button"
style="color:#f8fafc;background:none;border:none;font-size:22px;cursor:pointer;line-height:1"
onClick={() => setLightbox(null)}
>
</button>
</div>
</div>
<img
src={lightbox()!.src}
alt={lightbox()!.label}
style="max-width:900px;max-height:80vh;object-fit:contain;border-radius:8px;box-shadow:0 0 40px rgba(0,0,0,.6)"
onClick={(e) => e.stopPropagation()}
/>
</div>
</Show>
{/* ── PDF Viewer Modal ── */}
<Show when={pdfViewer()}>
<div
style="position:fixed;inset:0;background:rgba(0,0,0,.75);z-index:9999;display:flex;flex-direction:column;align-items:center;justify-content:center;padding:20px"
>
<div style="background:#fff;border-radius:12px;overflow:hidden;width:100%;max-width:900px;max-height:90vh;display:flex;flex-direction:column;box-shadow:0 25px 60px rgba(0,0,0,.5)">
{/* Header */}
<div style="display:flex;align-items:center;justify-content:space-between;padding:12px 16px;border-bottom:1px solid #e2e8f0;background:#f8fafc">
<span style="font-size:14px;font-weight:600;color:#0f172a">📄 {pdfViewer()!.label}</span>
<div style="display:flex;gap:8px;align-items:center">
<a href={pdfViewer()!.src} target="_blank" rel="noreferrer"
style="font-size:12px;color:#2563eb;text-decoration:underline">
Open in new tab
</a>
<button
type="button"
class="inline-flex items-center rounded-lg border border-gray-200 bg-white px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50 transition-colors"
style="padding:4px 10px;font-size:13px"
onClick={() => setPdfViewer(null)}
>
Close
</button>
</div>
</div>
{/* PDF embed */}
<iframe
src={`${pdfViewer()!.src}#toolbar=1&view=FitH`}
style="flex:1;min-height:600px;width:100%;border:none"
title={pdfViewer()!.label}
/>
</div>
</div>
</Show>
</>
);
}