forked from rajun/ar-test
feat: enable direct file uploads for AR assets
This commit is contained in:
parent
cb588b5caa
commit
29f002650f
@ -1,14 +1,27 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useRef, useState } from 'react';
|
||||||
import QRCode from 'qrcode';
|
import QRCode from 'qrcode';
|
||||||
import { pb } from '~/lib/pocketbase';
|
import { pb } from '~/lib/pocketbase';
|
||||||
import { ImageOff } from 'lucide-react';
|
import { ImageOff, Upload, Link, X } from 'lucide-react';
|
||||||
|
|
||||||
|
type InputMode = 'url' | 'file';
|
||||||
|
|
||||||
export default function Dashboard() {
|
export default function Dashboard() {
|
||||||
const [name, setName] = useState('');
|
const [name, setName] = useState('');
|
||||||
|
|
||||||
|
// GLB
|
||||||
|
const [glbMode, setGlbMode] = useState<InputMode>('file');
|
||||||
const [glbUrl, setGlbUrl] = useState('');
|
const [glbUrl, setGlbUrl] = useState('');
|
||||||
|
const [glbFile, setGlbFile] = useState<File | null>(null);
|
||||||
|
const glbInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
// USDZ
|
||||||
|
const [usdzMode, setUsdzMode] = useState<InputMode>('file');
|
||||||
const [usdzUrl, setUsdzUrl] = useState('');
|
const [usdzUrl, setUsdzUrl] = useState('');
|
||||||
|
const [usdzFile, setUsdzFile] = useState<File | null>(null);
|
||||||
|
const usdzInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
const [assets, setAssets] = useState<any[]>([]);
|
const [assets, setAssets] = useState<any[]>([]);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
@ -29,19 +42,32 @@ export default function Dashboard() {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const createAsset = async () => {
|
const createAsset = async () => {
|
||||||
if (!name || !glbUrl) {
|
const hasGlb = glbMode === 'file' ? !!glbFile : !!glbUrl.trim();
|
||||||
alert('Please provide a name and a GLB URL');
|
if (!name.trim() || !hasGlb) {
|
||||||
|
alert('Please provide a name and a GLB file or URL');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const record = await pb.collection('ar_assets').create({
|
// Build FormData so we can send files + fields in one request
|
||||||
name,
|
const formData = new FormData();
|
||||||
glb_url: glbUrl,
|
formData.append('name', name.trim());
|
||||||
usdz_url: usdzUrl || '',
|
|
||||||
});
|
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 qrUrl = `${import.meta.env.VITE_FRONTEND_URL}/ar/${record.id}`;
|
||||||
|
|
||||||
@ -58,9 +84,14 @@ export default function Dashboard() {
|
|||||||
|
|
||||||
await pb.collection('ar_assets').update(record.id, qrFormData);
|
await pb.collection('ar_assets').update(record.id, qrFormData);
|
||||||
|
|
||||||
|
// Reset all fields
|
||||||
setName('');
|
setName('');
|
||||||
setGlbUrl('');
|
setGlbUrl('');
|
||||||
|
setGlbFile(null);
|
||||||
setUsdzUrl('');
|
setUsdzUrl('');
|
||||||
|
setUsdzFile(null);
|
||||||
|
if (glbInputRef.current) glbInputRef.current.value = '';
|
||||||
|
if (usdzInputRef.current) usdzInputRef.current.value = '';
|
||||||
|
|
||||||
fetchAssets();
|
fetchAssets();
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
@ -83,13 +114,14 @@ export default function Dashboard() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className='rounded-2xl bg-white p-6 shadow-sm'>
|
<div className='rounded-2xl bg-white p-6 shadow-sm'>
|
||||||
<h2 className='mb-4 text-lg font-semibold text-gray-800'>
|
<h2 className='mb-5 text-lg font-semibold text-gray-800'>
|
||||||
Create New Asset
|
Create New Asset
|
||||||
</h2>
|
</h2>
|
||||||
|
|
||||||
<div className='grid gap-4 md:grid-cols-3 text-black'>
|
<div className='space-y-5 text-black'>
|
||||||
<div className="space-y-1">
|
{/* Asset Name */}
|
||||||
<label className="text-xs font-semibold text-gray-600 ml-1">Asset Name</label>
|
<div className='space-y-1'>
|
||||||
|
<label className='text-xs font-semibold text-gray-600 ml-1'>Asset Name</label>
|
||||||
<input
|
<input
|
||||||
className='w-full rounded-lg border border-gray-300 px-4 py-2 text-sm focus:border-black focus:outline-none'
|
className='w-full rounded-lg border border-gray-300 px-4 py-2 text-sm focus:border-black focus:outline-none'
|
||||||
placeholder='Asset name'
|
placeholder='Asset name'
|
||||||
@ -97,34 +129,48 @@ export default function Dashboard() {
|
|||||||
onChange={(e) => setName(e.target.value)}
|
onChange={(e) => setName(e.target.value)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-1">
|
|
||||||
<label className="text-xs font-semibold text-gray-600 ml-1">GLB URL (Android/Web)</label>
|
{/* GLB — Android / Web */}
|
||||||
<input
|
<FileOrUrlField
|
||||||
type="url"
|
label='GLB File (Android / Web)'
|
||||||
className='w-full rounded-lg border border-gray-300 px-4 py-2 text-sm focus:border-black focus:outline-none'
|
required
|
||||||
placeholder='https://example.com/model.glb'
|
mode={glbMode}
|
||||||
value={glbUrl}
|
onModeChange={setGlbMode}
|
||||||
onChange={(e) => setGlbUrl(e.target.value)}
|
accept='.glb'
|
||||||
/>
|
file={glbFile}
|
||||||
</div>
|
onFileChange={setGlbFile}
|
||||||
<div className="space-y-1">
|
inputRef={glbInputRef}
|
||||||
<label className="text-xs font-semibold text-gray-600 ml-1">USDZ URL (iOS) - Optional</label>
|
url={glbUrl}
|
||||||
<input
|
onUrlChange={setGlbUrl}
|
||||||
type="url"
|
urlPlaceholder='https://example.com/model.glb'
|
||||||
className='w-full rounded-lg border border-gray-300 px-4 py-2 text-sm focus:border-black focus:outline-none'
|
/>
|
||||||
placeholder='USDZ URL'
|
|
||||||
value={usdzUrl}
|
{/* USDZ — iOS */}
|
||||||
onChange={(e) => setUsdzUrl(e.target.value)}
|
<FileOrUrlField
|
||||||
/>
|
label='USDZ File (iOS) — Optional'
|
||||||
</div>
|
mode={usdzMode}
|
||||||
|
onModeChange={setUsdzMode}
|
||||||
|
accept='.usdz,.reality'
|
||||||
|
file={usdzFile}
|
||||||
|
onFileChange={setUsdzFile}
|
||||||
|
inputRef={usdzInputRef}
|
||||||
|
url={usdzUrl}
|
||||||
|
onUrlChange={setUsdzUrl}
|
||||||
|
urlPlaceholder='https://example.com/model.usdz'
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
onClick={createAsset}
|
onClick={createAsset}
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
className='mt-6 inline-flex items-center justify-center rounded-xl bg-black px-6 py-2.5 text-sm font-medium text-white transition hover:bg-gray-800 disabled:cursor-not-allowed disabled:opacity-60'
|
className='mt-6 inline-flex items-center justify-center gap-2 rounded-xl bg-black px-6 py-2.5 text-sm font-medium text-white transition hover:bg-gray-800 disabled:cursor-not-allowed disabled:opacity-60'
|
||||||
>
|
>
|
||||||
{loading ? 'Creating…' : 'Create Asset'}
|
{loading ? (
|
||||||
|
<>
|
||||||
|
<div className='w-3.5 h-3.5 rounded-full border-2 border-white/30 border-t-white animate-spin' />
|
||||||
|
Creating…
|
||||||
|
</>
|
||||||
|
) : 'Create Asset'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -176,3 +222,114 @@ export default function Dashboard() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── Reusable File-or-URL field ───────────────────────────────────────────────
|
||||||
|
interface FileOrUrlFieldProps {
|
||||||
|
label: string;
|
||||||
|
required?: boolean;
|
||||||
|
mode: InputMode;
|
||||||
|
onModeChange: (m: InputMode) => void;
|
||||||
|
accept: string;
|
||||||
|
file: File | null;
|
||||||
|
onFileChange: (f: File | null) => void;
|
||||||
|
inputRef: React.RefObject<HTMLInputElement | null>;
|
||||||
|
url: string;
|
||||||
|
onUrlChange: (u: string) => void;
|
||||||
|
urlPlaceholder: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function FileOrUrlField({
|
||||||
|
label, required, mode, onModeChange,
|
||||||
|
accept, file, onFileChange, inputRef,
|
||||||
|
url, onUrlChange, urlPlaceholder,
|
||||||
|
}: FileOrUrlFieldProps) {
|
||||||
|
return (
|
||||||
|
<div className='space-y-1.5'>
|
||||||
|
{/* Label + toggle */}
|
||||||
|
<div className='flex items-center justify-between ml-1'>
|
||||||
|
<label className='text-xs font-semibold text-gray-600'>
|
||||||
|
{label}{required && <span className='text-red-500 ml-0.5'>*</span>}
|
||||||
|
</label>
|
||||||
|
<div className='flex items-center gap-1 rounded-lg border border-gray-200 p-0.5 text-xs'>
|
||||||
|
<button
|
||||||
|
type='button'
|
||||||
|
onClick={() => onModeChange('file')}
|
||||||
|
className={`flex items-center gap-1 rounded-md px-2.5 py-1 transition-all font-medium ${
|
||||||
|
mode === 'file'
|
||||||
|
? 'bg-black text-white'
|
||||||
|
: 'text-gray-500 hover:text-gray-800'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Upload size={11} />
|
||||||
|
Upload
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type='button'
|
||||||
|
onClick={() => onModeChange('url')}
|
||||||
|
className={`flex items-center gap-1 rounded-md px-2.5 py-1 transition-all font-medium ${
|
||||||
|
mode === 'url'
|
||||||
|
? 'bg-black text-white'
|
||||||
|
: 'text-gray-500 hover:text-gray-800'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Link size={11} />
|
||||||
|
URL
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{mode === 'file' ? (
|
||||||
|
<div
|
||||||
|
onClick={() => inputRef.current?.click()}
|
||||||
|
className={`flex cursor-pointer items-center gap-3 rounded-lg border-2 border-dashed px-4 py-3 transition-colors ${
|
||||||
|
file ? 'border-black bg-gray-50' : 'border-gray-200 hover:border-gray-400'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
ref={inputRef}
|
||||||
|
type='file'
|
||||||
|
accept={accept}
|
||||||
|
className='hidden'
|
||||||
|
onChange={(e) => onFileChange(e.target.files?.[0] ?? null)}
|
||||||
|
/>
|
||||||
|
{file ? (
|
||||||
|
<>
|
||||||
|
<div className='flex flex-1 items-center gap-2 min-w-0'>
|
||||||
|
<div className='w-7 h-7 rounded bg-black flex items-center justify-center shrink-0'>
|
||||||
|
<Upload size={13} className='text-white' />
|
||||||
|
</div>
|
||||||
|
<span className='text-sm text-gray-800 font-medium truncate'>{file.name}</span>
|
||||||
|
<span className='text-xs text-gray-400 shrink-0'>
|
||||||
|
{(file.size / 1024 / 1024).toFixed(1)} MB
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type='button'
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onFileChange(null);
|
||||||
|
if (inputRef.current) inputRef.current.value = '';
|
||||||
|
}}
|
||||||
|
className='shrink-0 text-gray-400 hover:text-gray-700'
|
||||||
|
>
|
||||||
|
<X size={15} />
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<div className='flex flex-1 items-center gap-3 text-gray-400'>
|
||||||
|
<Upload size={16} />
|
||||||
|
<span className='text-sm'>Click to choose a file <span className='text-gray-300'>({accept})</span></span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<input
|
||||||
|
type='url'
|
||||||
|
className='w-full rounded-lg border border-gray-300 px-4 py-2 text-sm focus:border-black focus:outline-none'
|
||||||
|
placeholder={urlPlaceholder}
|
||||||
|
value={url}
|
||||||
|
onChange={(e) => onUrlChange(e.target.value)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user