diff --git a/app/routes/admin/asset-preview.tsx b/app/routes/admin/asset-preview.tsx new file mode 100644 index 0000000..93e7ab7 --- /dev/null +++ b/app/routes/admin/asset-preview.tsx @@ -0,0 +1,191 @@ +'use client'; + +import { useEffect, useState, useRef } from 'react'; +import { useLoaderData, Link, redirect, useNavigate } from 'react-router'; +import type { Route } from './+types/asset-preview'; +import { pb } from '~/lib/pocketbase'; +import { Button } from '~/components/ui/button'; +import { Badge } from '~/components/ui/badge'; +import { Card } from '~/components/ui/card'; +import { Download, ImageOff, FileCode, ArrowLeft, Box } from 'lucide-react'; +import { toast } from 'sonner'; + + +export async function clientLoader({ params }: Route.ClientLoaderArgs) { + if (!pb.authStore.isValid) { + return redirect('/login'); + } + try { + const asset = await pb.collection('ar_assets').getOne(params.id); + return { asset, error: null }; + } catch (err: any) { + console.error('Failed to load asset in clientLoader:', err); + return { asset: null, error: err?.message || 'Failed to fetch asset details' }; + } +} + +export default function Preview() { + const { asset, error } = useLoaderData(); + const navigate = useNavigate(); + + useEffect(() => { + if (error || !asset) { + toast.error(error || 'Asset not found'); + navigate('/', { replace: true }); + } + }, [error, asset, navigate]); + + if (!asset) return null; + + const glbUrl = asset.glb_file + ? pb.files.getURL(asset, asset.glb_file) + : asset.glb_url; + const usdzUrl = asset.usdz_file + ? pb.files.getURL(asset, asset.usdz_file) + : asset.usdz_url; + + const downloadQR = async () => { + const qrUrl = asset.qr_image + ? pb.files.getURL(asset, asset.qr_image) + : asset.qr_file + ? pb.files.getURL(asset, asset.qr_file) + : null; + if (!qrUrl) { + toast.error('No QR code image found for this asset'); + return; + } + + try { + const response = await fetch(qrUrl); + const blob = await response.blob(); + const url = window.URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = `${asset.name.replace(/\s+/g, '_')}_qr.png`; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + window.URL.revokeObjectURL(url); + toast.success('QR Code downloaded successfully'); + } catch (err) { + console.error('Failed to download QR code:', err); + toast.error('Failed to download QR code'); + } + }; + + return ( +
+
+
+ + + Back to Dashboard + +
+ t. +
+
+ + +
+ {glbUrl ? ( + + ) : ( +
+ + Initializing 3D viewport… +
+ )} +
+ +
+
+
+ Asset Details +

{asset.name}

+
+ +
+ {asset.qr_image ? ( + {asset.name} + ) : asset.qr_file ? ( + {asset.name} + ) : ( +
+ + No QR code +
+ )} +
+ +

+ Scan this QR code with a smartphone camera to instantly project the 3D model in your space using AR. +

+ +
+
+ GLB File Target +
+ {glbUrl} +
+
+ {usdzUrl && ( +
+ USDZ File Target +
+ {usdzUrl} +
+
+ )} +
+
+ +
+ + + + Launch AR View + +
+
+
+
+
+ ); +} diff --git a/app/routes/admin/dashboard.tsx b/app/routes/admin/dashboard.tsx new file mode 100644 index 0000000..f010464 --- /dev/null +++ b/app/routes/admin/dashboard.tsx @@ -0,0 +1,727 @@ +'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} + /> +
+ ); +} diff --git a/app/routes/login.tsx b/app/routes/admin/login.tsx similarity index 61% rename from app/routes/login.tsx rename to app/routes/admin/login.tsx index e9c15b1..2063d3b 100644 --- a/app/routes/login.tsx +++ b/app/routes/admin/login.tsx @@ -1,52 +1,91 @@ import { useState, useEffect } from 'react'; -import { useNavigate } from 'react-router'; +import { useSubmit, useNavigation, useActionData, useNavigate, redirect } from 'react-router'; +import type { Route } from './+types/login'; import { pb } from '~/lib/pocketbase'; import { Lock, Mail, Eye, EyeOff, Loader2, ArrowRight } from 'lucide-react'; +import { Input } from '~/components/ui/input'; +import { Button } from '~/components/ui/button'; +import { Label } from '~/components/ui/label'; +import { Card } from '~/components/ui/card'; +import { toast } from 'sonner'; + +export async function clientLoader() { + if (pb.authStore.isValid) { + return redirect('/'); + } + return null; +} + +export async function clientAction({ request }: Route.ClientActionArgs) { + const formData = await request.formData(); + const email = formData.get('email') as string; + const password = formData.get('password') as string; + + if (!email || !password) { + return { error: 'Please enter both email and password' }; + } + + try { + await pb.collection('users').authWithPassword(email, password); + return redirect('/'); + } catch (err: any) { + return { error: err?.message || 'Invalid email or password' }; + } +} export default function Login() { const navigate = useNavigate(); + const submit = useSubmit(); + const navigation = useNavigation(); + const actionData = useActionData(); + const [email, setEmail] = useState(''); const [password, setPassword] = useState(''); const [showPassword, setShowPassword] = useState(false); - const [error, setError] = useState(''); - const [loading, setLoading] = useState(false); + const [localError, setLocalError] = useState(''); - // If already logged in, redirect to dashboard + const isLoading = navigation.state !== 'idle'; + + // Already-authenticated redirect fallback useEffect(() => { if (pb.authStore.isValid) { navigate('/', { replace: true }); } }, [navigate]); - const handleLogin = async (e: React.FormEvent) => { + // Handle authentication feedback + useEffect(() => { + if (actionData) { + if (actionData.error) { + setLocalError(actionData.error); + toast.error(actionData.error); + } else { + toast.success('Logged in successfully'); + } + } + }, [actionData]); + + const handleLogin = (e: React.FormEvent) => { e.preventDefault(); if (!email || !password) { - setError('Please enter both email and password'); + setLocalError('Please enter both email and password'); return; } - setLoading(true); - setError(''); + setLocalError(''); + const formData = new FormData(); + formData.append('email', email); + formData.append('password', password); - try { - await pb.collection('users').authWithPassword(email, password); - navigate('/', { replace: true }); - } catch (err: any) { - setError(err?.message || 'Invalid email or password'); - } finally { - setLoading(false); - } + submit(formData, { method: 'post' }); }; return (
- {/* Background Decorative Orange Glow */}
- {/* Logo & Header */}
- {/* Logo styled exactly like the AR Viewer top-right logo */}
t.
@@ -56,28 +95,27 @@ export default function Login() {

- {/* Login Card */} -
+
- {error && ( + {(localError || actionData?.error) && (
- {error} + {localError || actionData?.error}
)} - {/* Email Input */}
-
- setEmail(e.target.value)} @@ -85,19 +123,19 @@ export default function Login() {
- {/* Password Input */}
-
- setPassword(e.target.value)} @@ -112,23 +150,22 @@ export default function Login() {
- {/* Submit Button */} - +
-
+
SYSTEM IP MONITORED SECURE PORTAL diff --git a/app/routes/dashboard.tsx b/app/routes/dashboard.tsx deleted file mode 100644 index 4ba3549..0000000 --- a/app/routes/dashboard.tsx +++ /dev/null @@ -1,277 +0,0 @@ -'use client'; - -import { useEffect, useRef, useState } from 'react'; -import { useNavigate } from 'react-router'; -import QRCode from 'qrcode'; -import { pb } from '~/lib/pocketbase'; -import { ImageOff, LogOut, FileCode } from 'lucide-react'; -import { FileOrUrlField, type InputMode } from '~/components/FileOrUrlField'; - -export default function Dashboard() { - const navigate = useNavigate(); - const [authCheckComplete, setAuthCheckComplete] = useState(false); - const [name, setName] = useState(''); - - // GLB - const [glbMode, setGlbMode] = useState('file'); - const [glbUrl, setGlbUrl] = useState(''); - const [glbFile, setGlbFile] = useState(null); - const glbInputRef = useRef(null); - - // USDZ - const [usdzMode, setUsdzMode] = useState('file'); - const [usdzUrl, setUsdzUrl] = useState(''); - const [usdzFile, setUsdzFile] = useState(null); - const usdzInputRef = useRef(null); - - const [assets, setAssets] = useState([]); - const [loading, setLoading] = useState(false); - - const fetchAssets = async () => { - try { - const records = await pb.collection('ar_assets').getFullList({ - sort: '-created', - }); - setAssets(records); - } catch (err) { - console.error('Failed to fetch assets:', err); - setAssets([]); - } - }; - - useEffect(() => { - if (!pb.authStore.isValid) { - navigate('/login', { replace: true }); - } else { - setAuthCheckComplete(true); - fetchAssets(); - } - }, [navigate]); - - const handleLogout = () => { - pb.authStore.clear(); - navigate('/login', { replace: true }); - }; - - const createAsset = async () => { - const hasGlb = glbMode === 'file' ? !!glbFile : !!glbUrl.trim(); - if (!name.trim() || !hasGlb) { - alert('Please provide a name and a GLB file or URL'); - return; - } - - setLoading(true); - - try { - const formData = new FormData(); - formData.append('name', name.trim()); - - if (glbMode === 'file' && glbFile) { - formData.append('glb_file', glbFile); - } else { - formData.append('glb_url', glbUrl.trim()); - } - - if (usdzMode === 'file' && usdzFile) { - formData.append('usdz_file', usdzFile); - } else if (usdzMode === 'url' && usdzUrl.trim()) { - formData.append('usdz_url', usdzUrl.trim()); - } - - const record = await pb.collection('ar_assets').create(formData); - - 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' })); - - await pb.collection('ar_assets').update(record.id, qrFormData); - - // Reset all fields - setName(''); - setGlbUrl(''); - setGlbFile(null); - setUsdzUrl(''); - setUsdzFile(null); - if (glbInputRef.current) glbInputRef.current.value = ''; - if (usdzInputRef.current) usdzInputRef.current.value = ''; - - fetchAssets(); - } catch (err: any) { - alert(err?.message || 'Failed to create asset'); - } finally { - setLoading(false); - } - }; - - if (!authCheckComplete) { - return ( -
-
-

Checking authorization…

-
- ); - } - - return ( -
-
- {/* Header */} -
-
-
- t. -
-
-

- AR Asset Dashboard -

-

- Manage 3D assets and generate AR QR codes -

-
-
-
- {pb.authStore.model?.email && ( - - {pb.authStore.model.email} - - )} - -
-
- - {/* Form Card */} -
-

- Create New Asset -

- -
- {/* Asset Name */} -
- - setName(e.target.value)} - /> -
- - {/* GLB — Android / Web */} - - - {/* USDZ — iOS */} - -
- - -
- - {/* Grid Header */} -
-

- Generated Assets -

- - {assets.length === 0 ? ( -
- -

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

-
- ) : ( -
- {assets.map((asset) => ( -
-

- {asset?.name} -

- -
- {asset?.qr_image ? ( - {asset?.name} - ) : ( - asset?.qr_file ? ( - {asset?.name} - ) : ( -
- - No QR Image -
- ) - )} -
-
- ))} -
- )} -
-
-
- ); -} -