Compare commits

..

3 Commits

7 changed files with 229 additions and 213 deletions

3
app/lib/pocketbase.ts Normal file
View File

@ -0,0 +1,3 @@
import PocketBase from 'pocketbase';
export const pb = new PocketBase(import.meta.env.VITE_POCKETBASE_URL);

View File

@ -1,6 +0,0 @@
import { createClient } from '@supabase/supabase-js';
export const supabase = createClient(
import.meta.env.VITE_SUPABASE_URL,
import.meta.env.VITE_SUPABASE_ANON_KEY,
);

View File

@ -1,8 +1,8 @@
'use client';
import { useEffect, useRef, useState } from 'react';
import { useParams } from 'react-router';
import { supabase } from '~/lib/supabase';
import { Box } from 'lucide-react';
import { pb } from '~/lib/pocketbase';
// --- A-FRAME/AR.JS CUSTOM ELEMENTS TYPING FIX ---
declare module 'react' {
@ -47,30 +47,31 @@ declare module 'react' {
}
}
const AR_HINTS = [
'Arranging models to view…',
'Find a flat surface & step back…',
'Keep your camera steady…',
'Almost ready, clear some space…',
'Preparing your AR experience…',
];
export default function ARViewer() {
const { id } = useParams();
const modelViewerRef = useRef<any>(null);
const modelViewerRef = useRef<HTMLElement>(null);
const [asset, setAsset] = useState<any>(null);
const [scriptLoaded, setScriptLoaded] = useState(false);
const [arLoading, setArLoading] = useState(false);
const [showHints, setShowHints] = useState(false);
const [hintIndex, setHintIndex] = useState(0);
const hintIntervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
const hintDelayRef = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => {
if (!id) return;
const loadAsset = async () => {
const { data, error } = await supabase
.from('ar_assets')
.select('*')
.eq('id', id)
.single();
console.log('Supabase response:', { data, error });
if (!error && data) {
setAsset(data);
}
const data = await pb.collection('ar_assets').getOne(id);
if (data) setAsset(data);
};
loadAsset();
}, [id]);
@ -79,55 +80,140 @@ export default function ARViewer() {
setScriptLoaded(true);
return;
}
const script = document.createElement('script');
script.id = 'model-viewer-script';
script.type = 'module';
script.src =
'https://ajax.googleapis.com/ajax/libs/model-viewer/3.4.0/model-viewer.min.js';
script.onload = () => {
console.log('model-viewer loaded');
setScriptLoaded(true);
};
script.src = 'https://ajax.googleapis.com/ajax/libs/model-viewer/3.4.0/model-viewer.min.js';
script.onload = () => setScriptLoaded(true);
document.head.appendChild(script);
}, []);
if (!scriptLoaded) {
const handleARClick = () => {
const mv = modelViewerRef.current as any;
if (!mv?.activateAR || arLoading) return;
setArLoading(true);
setShowHints(false);
setHintIndex(0);
mv.activateAR();
const cleanup = () => {
setArLoading(false);
setShowHints(false);
if (hintIntervalRef.current) clearInterval(hintIntervalRef.current);
if (hintDelayRef.current) clearTimeout(hintDelayRef.current);
};
// Only show hints if AR is still loading after 1.5s
hintDelayRef.current = setTimeout(() => {
setShowHints(true);
hintIntervalRef.current = setInterval(() => {
setHintIndex((prev) => (prev + 1) % AR_HINTS.length);
}, 2000);
}, 1500);
const onArStatus = (e: any) => {
const { status } = e.detail ?? {};
if (
status === 'session-started' ||
status === 'object-placed' ||
status === 'failed' ||
status === 'not-presenting'
) {
cleanup();
mv.removeEventListener('ar-status', onArStatus);
}
};
mv.addEventListener('ar-status', onArStatus);
// Fallback reset after 15s
setTimeout(cleanup, 15000);
};
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;
if (!scriptLoaded || !asset) {
return (
<pre style={{ padding: 20 }}>
Loading AR Viewer
{'\n\n'}
asset: {JSON.stringify(asset, null, 2)}
{'\n'}
scriptLoaded: {String(scriptLoaded)}
{'\n'}
id: {String(id)}
</pre>
<div className="fixed inset-0 bg-black flex flex-col items-center justify-center">
<div className="w-10 h-10 rounded-full border-2 border-orange-500/20 border-t-orange-500 animate-spin mb-4" />
<p className="text-white/40 text-xs tracking-widest uppercase">Loading AR Experience</p>
</div>
);
}
return (
<model-viewer
ref={modelViewerRef}
src={asset?.glb_url}
ios-src={asset?.usdz_url}
ar
ar-modes='webxr scene-viewer quick-look'
camera-controls
auto-rotate
style={{ width: '100vw', height: '80vh' }}
onError={(e) => {
console.error('Model viewer error:', e);
console.error('Failed to load:', asset?.glb_url);
}}
onLoad={() => console.log('Model loaded successfully')}
/>
// <div className='bg-white text-2xl text-red-400'>
// Id : {asset?.id}
// AR Viewer for asset: {asset?.name}
// glb URL: {asset?.glb_url}
// usdz URL: {asset?.usdz_url}
// </div>
<div className="fixed inset-0 bg-black flex flex-col overflow-hidden">
<style>{`
model-viewer::part(default-ar-button) { display: none !important; }
`}</style>
<model-viewer
ref={modelViewerRef}
src={glbUrl}
ios-src={usdzUrl}
ar
ar-modes="webxr scene-viewer quick-look"
ar-scale="auto"
ar-placement="floor"
camera-controls
touch-action="none"
interaction-prompt="none"
shadow-intensity="1"
shadow-softness="0.8"
exposure="1"
className="w-full flex-1 min-h-0 block"
onError={(e: any) => {
console.error('Model viewer error:', e);
console.error('Failed to load:', glbUrl);
}}
onLoad={() => console.log('Model loaded successfully')}
/>
<div className="absolute top-0 left-0 right-0 flex items-start justify-between px-5 pt-5 pb-5 pointer-events-none">
{asset?.name && (
<span className="text-white text-sm font-bold tracking-widest uppercase">
{asset.name}
</span>
)}
<div className="pointer-events-auto w-9 h-9 bg-black border border-orange-500 rounded-lg flex items-center justify-center shrink-0 ml-3">
<span className="text-orange-500 font-black text-sm leading-none">t.</span>
</div>
</div>
<div className="absolute bottom-0 left-0 right-0 flex items-center justify-end px-5 pt-5 pb-6">
<button
onClick={handleARClick}
disabled={arLoading}
className={`flex items-center gap-2 text-black font-bold text-xs tracking-widest uppercase px-5 py-3 rounded-lg transition-all duration-150 shadow-[0_0_24px_rgba(249,115,22,0.4)]
${arLoading
? 'bg-orange-500/80 cursor-not-allowed'
: 'bg-orange-500 hover:bg-orange-400 active:scale-95'
} ${showHints ? 'min-w-[220px] justify-center' : ''}`}
>
{arLoading && showHints ? (
<>
<div className="w-3.5 h-3.5 rounded-full border-2 border-black/30 border-t-black animate-spin shrink-0" />
<span className="truncate">{AR_HINTS[hintIndex]}</span>
</>
) : arLoading ? (
<>
<div className="w-3.5 h-3.5 rounded-full border-2 border-black/30 border-t-black animate-spin shrink-0" />
View in AR
</>
) : (
<>
<Box size={16} />
View in AR
</>
)}
</button>
</div>
</div>
);
}
}

View File

@ -2,7 +2,8 @@
import { useEffect, useState } from 'react';
import QRCode from 'qrcode';
import { supabase } from '~/lib/supabase';
import { pb } from '~/lib/pocketbase';
import { ImageOff } from 'lucide-react';
export default function Dashboard() {
const [name, setName] = useState('');
@ -12,12 +13,15 @@ export default function Dashboard() {
const [loading, setLoading] = useState(false);
const fetchAssets = async () => {
const { data } = await supabase
.from('ar_assets')
.select('*')
.order('created_at', { ascending: false });
setAssets(data || []);
try {
const records = await pb.collection('ar_assets').getFullList({
sort: '-created',
});
setAssets(records);
} catch (err) {
console.error('Failed to fetch assets:', err);
setAssets([]);
}
};
useEffect(() => {
@ -25,62 +29,45 @@ export default function Dashboard() {
}, []);
const createAsset = async () => {
if (!name || !glbUrl || !usdzUrl) {
alert('Fill all fields');
if (!name || !glbUrl) {
alert('Please provide a name and a GLB URL');
return;
}
setLoading(true);
const { data, error } = await supabase
.from('ar_assets')
.insert({
try {
const record = await pb.collection('ar_assets').create({
name,
glb_url: glbUrl,
usdz_url: usdzUrl,
})
.select()
.single();
usdz_url: usdzUrl || '',
});
if (error || !data) {
alert(error?.message);
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);
setName('');
setGlbUrl('');
setUsdzUrl('');
fetchAssets();
} catch (err: any) {
alert(err?.message || 'Failed to create asset');
} finally {
setLoading(false);
return;
}
// const qrUrl = `http://10.20.2.107:5173/ar/${data.id}`;
const qrUrl = `${window.location.origin}/ar/${data.id}`;
const qrImage = await QRCode.toDataURL(qrUrl, {
width: 512,
margin: 2,
});
const qrBlob = await (await fetch(qrImage)).blob();
const filePath = `${data.id}.png`;
await supabase.storage.from('qr-codes').upload(filePath, qrBlob, {
upsert: true,
contentType: 'image/png',
});
const { data: publicUrl } = supabase.storage
.from('qr-codes')
.getPublicUrl(filePath);
await supabase
.from('ar_assets')
.update({
qr_url: qrUrl,
qr_image_url: publicUrl.publicUrl,
})
.eq('id', data.id);
setName('');
setGlbUrl('');
setUsdzUrl('');
setLoading(false);
fetchAssets();
};
return (
@ -101,24 +88,35 @@ export default function Dashboard() {
</h2>
<div className='grid gap-4 md:grid-cols-3 text-black'>
<input
className='rounded-lg border border-gray-300 px-4 py-2 text-sm focus:border-black focus:outline-none'
placeholder='Asset name'
value={name}
onChange={(e) => setName(e.target.value)}
/>
<input
className='rounded-lg border border-gray-300 px-4 py-2 text-sm focus:border-black focus:outline-none'
placeholder='GLB URL'
value={glbUrl}
onChange={(e) => setGlbUrl(e.target.value)}
/>
<input
className='rounded-lg border border-gray-300 px-4 py-2 text-sm focus:border-black focus:outline-none'
placeholder='USDZ URL'
value={usdzUrl}
onChange={(e) => setUsdzUrl(e.target.value)}
/>
<div className="space-y-1">
<label className="text-xs font-semibold text-gray-600 ml-1">Asset Name</label>
<input
className='w-full rounded-lg border border-gray-300 px-4 py-2 text-sm focus:border-black focus:outline-none'
placeholder='Asset name'
value={name}
onChange={(e) => setName(e.target.value)}
/>
</div>
<div className="space-y-1">
<label className="text-xs font-semibold text-gray-600 ml-1">GLB URL (Android/Web)</label>
<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='https://example.com/model.glb'
value={glbUrl}
onChange={(e) => setGlbUrl(e.target.value)}
/>
</div>
<div className="space-y-1">
<label className="text-xs font-semibold text-gray-600 ml-1">USDZ URL (iOS) - Optional</label>
<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='USDZ URL'
value={usdzUrl}
onChange={(e) => setUsdzUrl(e.target.value)}
/>
</div>
</div>
<button
@ -150,15 +148,16 @@ export default function Dashboard() {
{asset?.name}
</h3>
{asset?.qr_image_url ? (
{asset?.qr_image ? (
<img
src={asset?.qr_image_url}
src={pb.files.getURL(asset, asset.qr_image)}
alt={asset?.name}
className='mx-auto w-40 rounded-lg'
/>
) : (
<div className='flex h-40 items-center justify-center rounded-lg bg-gray-100 text-sm text-gray-400'>
No QR
<div className='mx-auto flex h-40 w-40 flex-col items-center justify-center space-y-2 rounded-lg bg-gray-50 text-gray-400'>
<ImageOff size={32} strokeWidth={1.5} />
<span className='text-xs font-medium'>No QR Image</span>
</div>
)}
</div>
@ -170,3 +169,4 @@ export default function Dashboard() {
</div>
);
}

View File

@ -11,9 +11,9 @@
"dependencies": {
"@react-router/node": "^7.9.2",
"@react-router/serve": "^7.9.2",
"@supabase/supabase-js": "^2.95.3",
"isbot": "^5.1.31",
"lucide-react": "^0.555.0",
"pocketbase": "^0.26.8",
"qrcode": "^1.5.4",
"react": "^19.1.1",
"react-dom": "^19.1.1",

BIN
pocketbase/pocketbase Executable file

Binary file not shown.

View File

@ -639,56 +639,6 @@
resolved "https://registry.yarnpkg.com/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.57.1.tgz#a03348e7b559c792b6277cc58874b89ef46e1e72"
integrity sha512-mxRFDdHIWRxg3UfIIAwCm6NzvxG0jDX/wBN6KsQFTvKFqqg9vTrWUE68qEjHt19A5wwx5X5aUi2zuZT7YR0jrA==
"@supabase/auth-js@2.95.3":
version "2.95.3"
resolved "https://registry.yarnpkg.com/@supabase/auth-js/-/auth-js-2.95.3.tgz#217883fafe10cad57ab3327bff3c0b3381fac5b1"
integrity sha512-vD2YoS8E2iKIX0F7EwXTmqhUpaNsmbU6X2R0/NdFcs02oEfnHyNP/3M716f3wVJ2E5XHGiTFXki6lRckhJ0Thg==
dependencies:
tslib "2.8.1"
"@supabase/functions-js@2.95.3":
version "2.95.3"
resolved "https://registry.yarnpkg.com/@supabase/functions-js/-/functions-js-2.95.3.tgz#4c133a31a897f626a3fe6cc692b85dfdfc934630"
integrity sha512-uTuOAKzs9R/IovW1krO0ZbUHSJnsnyJElTXIRhjJTqymIVGcHzkAYnBCJqd7468Fs/Foz1BQ7Dv6DCl05lr7ig==
dependencies:
tslib "2.8.1"
"@supabase/postgrest-js@2.95.3":
version "2.95.3"
resolved "https://registry.yarnpkg.com/@supabase/postgrest-js/-/postgrest-js-2.95.3.tgz#8eeb4a795e64923dac38e4c4c0fb4375fc2da09c"
integrity sha512-LTrRBqU1gOovxRm1vRXPItSMPBmEFqrfTqdPTRtzOILV4jPSueFz6pES5hpb4LRlkFwCPRmv3nQJ5N625V2Xrg==
dependencies:
tslib "2.8.1"
"@supabase/realtime-js@2.95.3":
version "2.95.3"
resolved "https://registry.yarnpkg.com/@supabase/realtime-js/-/realtime-js-2.95.3.tgz#aff3c95119fb93356971f117605ecec32a6dd0f3"
integrity sha512-D7EAtfU3w6BEUxDACjowWNJo/ZRo7sDIuhuOGKHIm9FHieGeoJV5R6GKTLtga/5l/6fDr2u+WcW/m8I9SYmaIw==
dependencies:
"@types/phoenix" "^1.6.6"
"@types/ws" "^8.18.1"
tslib "2.8.1"
ws "^8.18.2"
"@supabase/storage-js@2.95.3":
version "2.95.3"
resolved "https://registry.yarnpkg.com/@supabase/storage-js/-/storage-js-2.95.3.tgz#986772ed5a4761e6735b0efd9312d598caf44715"
integrity sha512-4GxkJiXI3HHWjxpC3sDx1BVrV87O0hfX+wvJdqGv67KeCu+g44SPnII8y0LL/Wr677jB7tpjAxKdtVWf+xhc9A==
dependencies:
iceberg-js "^0.8.1"
tslib "2.8.1"
"@supabase/supabase-js@^2.95.3":
version "2.95.3"
resolved "https://registry.yarnpkg.com/@supabase/supabase-js/-/supabase-js-2.95.3.tgz#a5dc4839ded76483402b8b1fbc1672fbad358330"
integrity sha512-Fukw1cUTQ6xdLiHDJhKKPu6svEPaCEDvThqCne3OaQyZvuq2qjhJAd91kJu3PXLG18aooCgYBaB6qQz35hhABg==
dependencies:
"@supabase/auth-js" "2.95.3"
"@supabase/functions-js" "2.95.3"
"@supabase/postgrest-js" "2.95.3"
"@supabase/realtime-js" "2.95.3"
"@supabase/storage-js" "2.95.3"
"@tailwindcss/node@4.1.18":
version "4.1.18"
resolved "https://registry.yarnpkg.com/@tailwindcss/node/-/node-4.1.18.tgz#9863be0d26178638794a38d6c7c14666fb992e8a"
@ -827,11 +777,6 @@
dependencies:
undici-types "~6.21.0"
"@types/phoenix@^1.6.6":
version "1.6.7"
resolved "https://registry.yarnpkg.com/@types/phoenix/-/phoenix-1.6.7.tgz#75137b7ecf732ceaca284cf10c1552071cfff12f"
integrity sha512-oN9ive//QSBkf19rfDv45M7eZPi0eEXylht2OLEXicu5b4KoQ1OzXIw+xDSGWxSxe1JmepRR/ZH283vsu518/Q==
"@types/qrcode@^1.5.6":
version "1.5.6"
resolved "https://registry.yarnpkg.com/@types/qrcode/-/qrcode-1.5.6.tgz#07c33cb9ec0ad88be4636e636e28e54d99b65f42"
@ -874,13 +819,6 @@
resolved "https://registry.yarnpkg.com/@types/webxr/-/webxr-0.5.24.tgz#734d5d90dadc5809a53e422726c60337fa2f4a44"
integrity sha512-h8fgEd/DpoS9CBrjEQXR+dIDraopAEfu4wYVNY2tEPwk60stPWhvZMf4Foo5FakuQ7HFZoa8WceaWFervK2Ovg==
"@types/ws@^8.18.1":
version "8.18.1"
resolved "https://registry.yarnpkg.com/@types/ws/-/ws-8.18.1.tgz#48464e4bf2ddfd17db13d845467f6070ffea4aa9"
integrity sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==
dependencies:
"@types/node" "*"
"@webgpu/types@*":
version "0.1.69"
resolved "https://registry.yarnpkg.com/@webgpu/types/-/types-0.1.69.tgz#6b849bf370a1f29c78bd3aeba8e84c1150b237f2"
@ -1423,11 +1361,6 @@ http-errors@~2.0.0, http-errors@~2.0.1:
statuses "~2.0.2"
toidentifier "~1.0.1"
iceberg-js@^0.8.1:
version "0.8.1"
resolved "https://registry.yarnpkg.com/iceberg-js/-/iceberg-js-0.8.1.tgz#47d893468293a010385e1c70123f29d0fc1d9912"
integrity sha512-1dhVQZXhcHje7798IVM+xoo/1ZdVfzOMIc8/rgVSijRK38EDqOJoGula9N/8ZI5RD8QTxNQtK/Gozpr+qUqRRA==
iconv-lite@~0.4.24:
version "0.4.24"
resolved "https://registry.yarnpkg.com/iconv-lite/-/iconv-lite-0.4.24.tgz#2022b4b25fbddc21d2f524974a474aafe733908b"
@ -1770,6 +1703,11 @@ pngjs@^5.0.0:
resolved "https://registry.yarnpkg.com/pngjs/-/pngjs-5.0.0.tgz#e79dd2b215767fd9c04561c01236df960bce7fbb"
integrity sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==
pocketbase@^0.26.8:
version "0.26.8"
resolved "https://registry.yarnpkg.com/pocketbase/-/pocketbase-0.26.8.tgz#1b800b5f69f2ab08c3e3da45fce25f45c6724c45"
integrity sha512-aQ/ewvS7ncvAE8wxoW10iAZu6ElgbeFpBhKPnCfvRovNzm2gW8u/sQNPGN6vNgVEagz44kK//C61oKjfa+7Low==
postcss@^8.5.6:
version "8.5.6"
resolved "https://registry.yarnpkg.com/postcss/-/postcss-8.5.6.tgz#2825006615a619b4f62a9e7426cc120b349a8f3c"
@ -2078,7 +2016,7 @@ tsconfck@^3.0.3:
resolved "https://registry.yarnpkg.com/tsconfck/-/tsconfck-3.1.6.tgz#da1f0b10d82237ac23422374b3fce1edb23c3ead"
integrity sha512-ks6Vjr/jEw0P1gmOVwutM3B7fWxoWBL2KRDb1JfqGVawBmO5UsvmWOQFGHBPl5yxYz4eERr19E6L7NMv+Fej4w==
tslib@2.8.1, tslib@^2.4.0:
tslib@^2.4.0:
version "2.8.1"
resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.8.1.tgz#612efe4ed235d567e8aba5f2a5fab70280ade83f"
integrity sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==
@ -2182,11 +2120,6 @@ wrap-ansi@^6.2.0:
string-width "^4.1.0"
strip-ansi "^6.0.0"
ws@^8.18.2:
version "8.19.0"
resolved "https://registry.yarnpkg.com/ws/-/ws-8.19.0.tgz#ddc2bdfa5b9ad860204f5a72a4863a8895fd8c8b"
integrity sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==
y18n@^4.0.0:
version "4.0.3"
resolved "https://registry.yarnpkg.com/y18n/-/y18n-4.0.3.tgz#b5f259c82cd6e336921efd7bfd8bf560de9eeedf"