'use client'; import { useEffect, useRef, useState, useCallback } from 'react'; import { useLoaderData, useSubmit, useNavigation, useActionData, useSearchParams, useNavigate, redirect } from 'react-router'; import type { Route } from './+types/dashboard'; import QRCode from 'qrcode'; import { toast } from 'sonner'; import { pb } from '~/lib/pocketbase'; import { ImageOff, LogOut, FileCode, Edit2, Trash2, X, Eye, RefreshCw } from 'lucide-react'; import { FileOrUrlField, type InputMode } from '~/components/FileOrUrlField'; import { Button } from '~/components/ui/button'; import { Input } from '~/components/ui/input'; import { Badge } from '~/components/ui/badge'; import { Label } from '~/components/ui/label'; import { Card, CardHeader, CardTitle, CardContent } from '~/components/ui/card'; import { ConfirmDialog } from '~/components/ui/confirm-dialog'; import { useForm, Controller } from 'react-hook-form'; import { zodResolver } from '@hookform/resolvers/zod'; import { z } from 'zod'; const DEFAULT_FORM_VALUES = { id: '', name: '', glbMode: 'file' as const, glbUrl: '', glbFile: null as File | null, existingGlbName: '', usdzMode: 'file' as const, usdzUrl: '', usdzFile: null as File | null, existingUsdzName: '', }; function getPaginationRange(current: number, total: number) { const range: (number | string)[] = []; const delta = 1; for (let i = 1; i <= total; i++) { if ( i === 1 || i === total || (i >= current - delta && i <= current + delta) ) { range.push(i); } else if (range[range.length - 1] !== '...') { range.push('...'); } } return range; } const formSchema = z.object({ id: z.string().optional(), name: z.string().min(1, 'Asset name is required'), glbMode: z.enum(['file', 'url']), glbUrl: z.string().optional(), glbFile: z.any().nullable().optional(), existingGlbName: z.string().optional(), usdzMode: z.enum(['file', 'url']), usdzUrl: z.string().optional(), usdzFile: z.any().nullable().optional(), existingUsdzName: z.string().optional(), }); const assetSchema = formSchema.superRefine((data, ctx) => { // GLB Validation rules if (data.glbMode === 'url') { if (!data.glbUrl || data.glbUrl.trim() === '') { ctx.addIssue({ code: 'custom', message: 'GLB URL is required when URL mode is active', path: ['glbUrl'], }); } else { try { new URL(data.glbUrl); } catch (_) { ctx.addIssue({ code: 'custom', message: 'GLB URL must be a valid absolute URL', path: ['glbUrl'], }); } } } else { if (!data.glbFile && !data.existingGlbName) { ctx.addIssue({ code: 'custom', message: 'GLB file is required when Upload mode is active', path: ['glbFile'], }); } } // USDZ optional URL Validation rules if (data.usdzMode === 'url' && data.usdzUrl && data.usdzUrl.trim() !== '') { try { new URL(data.usdzUrl); } catch (_) { ctx.addIssue({ code: 'custom', message: 'USDZ URL must be a valid absolute URL', path: ['usdzUrl'], }); } } }); type FormValues = z.infer; export async function clientLoader({ request }: Route.ClientLoaderArgs) { if (!pb.authStore.isValid) { return redirect('/login'); } const url = new URL(request.url); const page = parseInt(url.searchParams.get('page') || '1', 10); try { const resultList = await pb.collection('ar_assets').getList(page, 10, { sort: '-created', }); return { assets: resultList.items || [], page: resultList.page, totalPages: resultList.totalPages, totalItems: resultList.totalItems, error: null, }; } catch (err: any) { console.error('Failed to load assets in clientLoader:', err); return { assets: [], page: 1, totalPages: 1, totalItems: 0, error: err?.message || 'Failed to load assets', }; } } export async function clientAction({ request }: Route.ClientActionArgs) { if (!pb.authStore.isValid) { return redirect('/login'); } const formData = await request.formData(); const intent = formData.get('intent') as string; try { // --- DELETE INTENT --- if (intent === 'delete') { const id = formData.get('id') as string; if (!id) throw new Error('Missing asset ID'); await pb.collection('ar_assets').delete(id); return { success: true, intent, message: 'Asset deleted successfully' }; } // Parse fields for validation const glbFileVal = formData.get('glb_file'); const usdzFileVal = formData.get('usdz_file'); const rawFields = { id: (formData.get('id') as string) || undefined, name: (formData.get('name') as string) || undefined, glbMode: (formData.get('glbMode') as 'file' | 'url') || undefined, glbUrl: (formData.get('glb_url') as string) || undefined, glbFile: (glbFileVal instanceof File && glbFileVal.size > 0) ? glbFileVal : undefined, existingGlbName: (formData.get('existingGlbName') as string) || undefined, usdzMode: (formData.get('usdzMode') as 'file' | 'url') || undefined, usdzUrl: (formData.get('usdz_url') as string) || undefined, usdzFile: (usdzFileVal instanceof File && usdzFileVal.size > 0) ? usdzFileVal : undefined, existingUsdzName: (formData.get('existingUsdzName') as string) || undefined, }; const validatedResult = assetSchema.safeParse(rawFields); if (!validatedResult.success) { return { success: false, error: validatedResult.error.issues[0].message }; } const data = validatedResult.data; // --- CREATE INTENT --- if (intent === 'create') { const pbFormData = new FormData(); pbFormData.append('name', data.name.trim()); if (data.glbMode === 'file') { if (data.glbFile) pbFormData.append('glb_file', data.glbFile); } else { pbFormData.append('glb_url', (data.glbUrl || '').trim()); } if (data.usdzMode === 'file') { if (data.usdzFile) pbFormData.append('usdz_file', data.usdzFile); } else { pbFormData.append('usdz_url', (data.usdzUrl || '').trim()); } // Create record const record = await pb.collection('ar_assets').create(pbFormData); // Generate QR Code const qrUrl = `${import.meta.env.VITE_FRONTEND_URL}/ar/${record.id}`; const qrImage = await QRCode.toDataURL(qrUrl, { width: 512, margin: 2, }); const qrBlob = await (await fetch(qrImage)).blob(); const qrFormData = new FormData(); qrFormData.append('qr_url', qrUrl); qrFormData.append('qr_image', new File([qrBlob], `${record.id}.png`, { type: 'image/png' })); // Update QR code fields await pb.collection('ar_assets').update(record.id, qrFormData); return { success: true, intent, message: 'New AR Asset created successfully' }; } // --- UPDATE INTENT --- if (intent === 'update') { const id = data.id; if (!id) throw new Error('Missing asset ID'); const pbFormData = new FormData(); pbFormData.append('name', data.name.trim()); if (data.glbMode === 'file') { if (data.glbFile) { pbFormData.append('glb_file', data.glbFile); } pbFormData.append('glb_url', ''); } else { pbFormData.append('glb_url', (data.glbUrl || '').trim()); pbFormData.append('glb_file', ''); } if (data.usdzMode === 'file') { if (data.usdzFile) { pbFormData.append('usdz_file', data.usdzFile); } else if (!data.existingUsdzName) { pbFormData.append('usdz_file', ''); } pbFormData.append('usdz_url', ''); } else { pbFormData.append('usdz_url', (data.usdzUrl || '').trim()); pbFormData.append('usdz_file', ''); } await pb.collection('ar_assets').update(id, pbFormData); return { success: true, intent, message: 'Asset updated successfully' }; } return { success: false, error: 'Invalid intent' }; } catch (err: any) { console.error('Action failure:', err); return { success: false, error: err?.message || 'Something went wrong' }; } } export function HydrateFallback() { return (

Loading Dashboard…

); } export default function Dashboard() { const navigate = useNavigate(); const { assets, page, totalPages, totalItems, error } = useLoaderData(); const actionData = useActionData(); const submit = useSubmit(); const navigation = useNavigation(); const [, setSearchParams] = useSearchParams(); const [editingAsset, setEditingAsset] = useState(null); const [deleteTargetId, setDeleteTargetId] = useState(null); const glbInputRef = useRef(null); const usdzInputRef = useRef(null); const { register, handleSubmit, control, setValue, watch, reset, formState: { errors }, } = useForm({ resolver: zodResolver(assetSchema), defaultValues: DEFAULT_FORM_VALUES, }); const glbMode = watch('glbMode'); const usdzMode = watch('usdzMode'); const existingGlbName = watch('existingGlbName'); const existingUsdzName = watch('existingUsdzName'); const isSubmitting = navigation.state !== 'idle'; const handleLogout = useCallback(() => { pb.authStore.clear(); toast.success('Logged out successfully'); navigate('/login', { replace: true }); }, [navigate]); const cancelEditing = useCallback(() => { setEditingAsset(null); reset(DEFAULT_FORM_VALUES); if (glbInputRef.current) glbInputRef.current.value = ''; if (usdzInputRef.current) usdzInputRef.current.value = ''; }, [reset]); const startEditing = useCallback((asset: any) => { if (!asset) return; setEditingAsset(asset); reset({ id: asset.id, name: asset.name || '', glbMode: asset.glb_file ? 'file' : 'url', glbUrl: asset.glb_file ? '' : (asset.glb_url || ''), glbFile: null, existingGlbName: asset.glb_file || '', usdzMode: asset.usdz_file ? 'file' : 'url', usdzUrl: asset.usdz_file ? '' : (asset.usdz_url || ''), usdzFile: null, existingUsdzName: asset.usdz_file || '', }); window.scrollTo({ top: 0, behavior: 'smooth' }); }, [reset]); const handleDeleteConfirm = useCallback(() => { if (!deleteTargetId) return; const formData = new FormData(); formData.append('intent', 'delete'); formData.append('id', deleteTargetId); submit(formData, { method: 'post' }); if (editingAsset?.id === deleteTargetId) { cancelEditing(); } setDeleteTargetId(null); }, [deleteTargetId, submit, editingAsset, cancelEditing]); const onSubmit = useCallback((data: FormValues) => { const formData = new FormData(); formData.append('intent', editingAsset ? 'update' : 'create'); formData.append('name', data.name.trim()); formData.append('glbMode', data.glbMode); formData.append('usdzMode', data.usdzMode); if (editingAsset) { formData.append('id', editingAsset.id); } // GLB if (data.glbMode === 'file') { if (data.glbFile) { formData.append('glb_file', data.glbFile); } formData.append('existingGlbName', data.existingGlbName || ''); } else { formData.append('glb_url', (data.glbUrl || '').trim()); } // USDZ if (data.usdzMode === 'file') { if (data.usdzFile) { formData.append('usdz_file', data.usdzFile); } else { formData.append('existingUsdzName', data.existingUsdzName || ''); } } else { formData.append('usdz_url', (data.usdzUrl || '').trim()); } submit(formData, { method: 'post', encType: 'multipart/form-data' }); }, [editingAsset, submit]); const handlePageChange = useCallback((pageNum: number) => { setSearchParams({ page: pageNum.toString() }); }, [setSearchParams]); useEffect(() => { if (error) { toast.error(error); } }, [error]); useEffect(() => { if (actionData) { if (actionData.success) { toast.success(actionData.message); if (actionData.intent === 'create' || actionData.intent === 'update') { cancelEditing(); } } else if (actionData.error) { toast.error(actionData.error); } } }, [actionData, cancelEditing]); return (
t.

AR Asset Dashboard

Manage 3D assets and generate AR QR codes

{pb.authStore.model?.email && ( {pb.authStore.model.email} )}

{editingAsset ? `Edit Asset: ${editingAsset.name}` : 'Create New Asset'}

{editingAsset && ( )}
{errors.name && ( {errors.name.message} )}
{/* GLB — Android / Web */}
( setValue('glbMode', mode, { shouldValidate: true })} accept='.glb' file={field.value} onFileChange={field.onChange} inputRef={glbInputRef} url={watch('glbUrl') || ''} onUrlChange={(val) => setValue('glbUrl', val, { shouldValidate: true })} urlPlaceholder='https://example.com/model.glb' existingFileName={existingGlbName} onClearExistingFile={() => setValue('existingGlbName', '', { shouldValidate: true })} /> )} /> {errors.glbFile && ( {errors.glbFile.message as string} )} {errors.glbUrl && ( {errors.glbUrl.message} )}
{/* USDZ — iOS */}
( setValue('usdzMode', mode, { shouldValidate: true })} accept='.usdz,.reality' file={field.value} onFileChange={field.onChange} inputRef={usdzInputRef} url={watch('usdzUrl') || ''} onUrlChange={(val) => setValue('usdzUrl', val, { shouldValidate: true })} urlPlaceholder='https://example.com/model.usdz' existingFileName={existingUsdzName} onClearExistingFile={() => setValue('existingUsdzName', '', { shouldValidate: true })} /> )} /> {errors.usdzFile && ( {errors.usdzFile.message as string} )} {errors.usdzUrl && ( {errors.usdzUrl.message} )}
{editingAsset && ( )}

Generated Assets

{assets.length === 0 ? (

No assets created yet. Upload a model above to get started.

) : (
{assets.map((asset) => { if (!asset) return null; return (

{asset.name || 'Unnamed Asset'}

navigate('/preview/' + asset.id)} className="relative bg-white p-3.5 rounded-xl flex items-center justify-center max-w-[190px] w-full mx-auto shadow-md cursor-pointer hover:scale-[1.03] transition-transform duration-200 group/qr" >
Preview 3D & QR
{asset.qr_image ? ( {asset.name} ) : ( asset.qr_file ? ( {asset.name} ) : (
No QR Image
) )}
); })}
{totalPages > 1 && (
Showing {Math.min((page - 1) * 10 + 1, totalItems)} to {Math.min(page * 10, totalItems)} of {totalItems} assets
{getPaginationRange(page, totalPages).map((pageNum, idx) => { if (pageNum === '...') { return ( ); } return ( ); })}
)}
)}
!open && setDeleteTargetId(null)} title="Delete 3D Asset" description="Are you sure you want to delete this asset? This action cannot be undone and will permanently delete the model files." confirmText="Delete Asset" variant="destructive" onConfirm={handleDeleteConfirm} />
); }