Adicionou CloudLoader ao XR
X-Lovable-Edit-ID: edt-b484a06d-4561-4772-babc-2a0f8f00a180 Co-authored-by: Reifonas <211114984+Reifonas@users.noreply.github.com>
This commit is contained in:
@@ -0,0 +1,214 @@
|
|||||||
|
import { useState, useCallback } from 'react';
|
||||||
|
import { Cloud, Loader2, Link2 } from 'lucide-react';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import {
|
||||||
|
Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter, DialogTrigger,
|
||||||
|
} from '@/components/ui/dialog';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { Label } from '@/components/ui/label';
|
||||||
|
import { useModelStore } from '@/stores/useModelStore';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
import { getSupportedExtension, convertToGLB } from '@/lib/convertToGLB';
|
||||||
|
import { convertIFCtoGLB } from '@/lib/convertIFC';
|
||||||
|
|
||||||
|
const DEFAULT_URL = 'https://store.tracksteel.com.br/demo.ifc';
|
||||||
|
|
||||||
|
const PRESETS: { label: string; url: string }[] = [
|
||||||
|
{ label: 'Demo IFC (TrackSteel)', url: DEFAULT_URL },
|
||||||
|
];
|
||||||
|
|
||||||
|
/** Normaliza URLs de Dropbox / OneDrive para apontar ao binário direto. */
|
||||||
|
function normalizeCloudUrl(input: string): string {
|
||||||
|
try {
|
||||||
|
const u = new URL(input);
|
||||||
|
if (/(^|\.)dropbox\.com$/.test(u.hostname)) {
|
||||||
|
u.searchParams.set('dl', '1');
|
||||||
|
return u.toString();
|
||||||
|
}
|
||||||
|
if (/(^|\.)1drv\.ms$|onedrive\.live\.com$/.test(u.hostname)) {
|
||||||
|
u.searchParams.set('download', '1');
|
||||||
|
return u.toString();
|
||||||
|
}
|
||||||
|
return input;
|
||||||
|
} catch {
|
||||||
|
return input;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Tenta obter um token JWT de uma instância FileBrowser NoAuth (POST /api/login vazio). */
|
||||||
|
async function tryFileBrowserToken(origin: string): Promise<string | null> {
|
||||||
|
try {
|
||||||
|
const r = await fetch(`${origin}/api/login`, { method: 'POST', mode: 'cors' });
|
||||||
|
if (!r.ok) return null;
|
||||||
|
const txt = (await r.text()).trim();
|
||||||
|
// FileBrowser devolve o JWT como texto puro
|
||||||
|
if (txt && txt.split('.').length === 3) return txt;
|
||||||
|
return null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Faz fetch tentando vários endpoints típicos de hosts que servem SPA (FileBrowser etc.). */
|
||||||
|
async function smartFetch(rawUrl: string): Promise<Response> {
|
||||||
|
const url = normalizeCloudUrl(rawUrl.trim());
|
||||||
|
let res = await fetch(url, { mode: 'cors' });
|
||||||
|
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||||
|
const ct = res.headers.get('content-type') || '';
|
||||||
|
if (!ct.includes('text/html')) return res;
|
||||||
|
|
||||||
|
// Servidor devolveu HTML — tentamos endpoints binários do FileBrowser
|
||||||
|
const u = new URL(url);
|
||||||
|
const path = u.pathname.startsWith('/api/raw/') ? u.pathname : `/api/raw${u.pathname}`;
|
||||||
|
|
||||||
|
// 1ª tentativa: /api/raw/... cru
|
||||||
|
try {
|
||||||
|
const r = await fetch(`${u.origin}${path}${u.search}`, { mode: 'cors' });
|
||||||
|
if (r.ok && !(r.headers.get('content-type') || '').includes('text/html')) return r;
|
||||||
|
if (r.status !== 401) {
|
||||||
|
// pode ser outro erro — segue para fluxo de token
|
||||||
|
}
|
||||||
|
} catch { /* continua */ }
|
||||||
|
|
||||||
|
// 2ª tentativa: pega token JWT NoAuth e usa ?auth=
|
||||||
|
const token = await tryFileBrowserToken(u.origin);
|
||||||
|
if (token) {
|
||||||
|
const sep = u.search ? '&' : '?';
|
||||||
|
const r = await fetch(`${u.origin}${path}${u.search}${sep}auth=${encodeURIComponent(token)}`, { mode: 'cors' });
|
||||||
|
if (r.ok && !(r.headers.get('content-type') || '').includes('text/html')) return r;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error(
|
||||||
|
'O servidor devolveu HTML. Use um link DIRETO do arquivo (ex.: Dropbox ?dl=1, OneDrive ?download=1, ' +
|
||||||
|
'ou habilite endpoint público no FileBrowser).'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CloudLoaderProps {
|
||||||
|
/** Renderiza como botão compacto (para HUD do AR) */
|
||||||
|
compact?: boolean;
|
||||||
|
/** Variante visual do botão */
|
||||||
|
variant?: 'default' | 'outline';
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CloudLoader({ compact = false, variant = 'outline', className }: CloudLoaderProps) {
|
||||||
|
const setModel = useModelStore((s) => s.setModel);
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [url, setUrl] = useState(DEFAULT_URL);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
const handleLoad = useCallback(async (rawUrl: string) => {
|
||||||
|
if (!rawUrl) return;
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const fileName = rawUrl.split('/').pop()?.split('?')[0] || 'modelo-cloud';
|
||||||
|
const ext = getSupportedExtension(fileName);
|
||||||
|
if (!ext) {
|
||||||
|
toast.error('URL deve apontar para .GLB, .OBJ, .STL ou .IFC');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await smartFetch(rawUrl);
|
||||||
|
const buffer = await response.arrayBuffer();
|
||||||
|
|
||||||
|
let blob: Blob, outName = fileName, outSize = buffer.byteLength;
|
||||||
|
if (ext === 'glb') {
|
||||||
|
blob = new Blob([buffer], { type: 'model/gltf-binary' });
|
||||||
|
} else if (ext === 'ifc') {
|
||||||
|
const r = await convertIFCtoGLB(buffer, fileName);
|
||||||
|
blob = r.blob; outName = r.fileName; outSize = r.fileSize;
|
||||||
|
} else {
|
||||||
|
const r = await convertToGLB(buffer, ext, fileName);
|
||||||
|
blob = r.blob; outName = r.fileName; outSize = r.fileSize;
|
||||||
|
}
|
||||||
|
const blobUrl = URL.createObjectURL(blob);
|
||||||
|
setModel({ fileName: outName, fileSize: outSize, url: blobUrl });
|
||||||
|
toast.success(`"${outName}" carregado da nuvem`);
|
||||||
|
setOpen(false);
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
toast.error(err instanceof Error ? err.message : 'Falha ao carregar da nuvem (verifique URL/CORS)');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [setModel]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={setOpen}>
|
||||||
|
<DialogTrigger asChild>
|
||||||
|
{compact ? (
|
||||||
|
<Button variant={variant} size="sm" className={`h-8 gap-1.5 text-[10px] font-mono ${className ?? ''}`} title="Carregar da Nuvem">
|
||||||
|
<Cloud className="h-3.5 w-3.5" />
|
||||||
|
Nuvem
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<Button
|
||||||
|
variant={variant}
|
||||||
|
className={`h-12 w-full gap-2 border-primary/40 text-primary hover:border-primary hover:bg-primary/10 transition-all text-xs ${className ?? ''}`}
|
||||||
|
>
|
||||||
|
<Cloud className="h-4 w-4" />
|
||||||
|
Carregar da Nuvem
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</DialogTrigger>
|
||||||
|
<DialogContent className="sm:max-w-md">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="flex items-center gap-2">
|
||||||
|
<Cloud className="h-4 w-4 text-primary" />
|
||||||
|
Carregar modelo da nuvem
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription className="text-xs">
|
||||||
|
URL direta para .GLB, .OBJ, .STL ou .IFC. Dropbox use <code>?dl=1</code>, OneDrive <code>?download=1</code>.
|
||||||
|
FileBrowser é detectado automaticamente.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label htmlFor="cloud-url" className="text-xs font-mono">URL do modelo</Label>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Link2 className="h-4 w-4 text-muted-foreground mt-2.5 shrink-0" />
|
||||||
|
<Input
|
||||||
|
id="cloud-url"
|
||||||
|
value={url}
|
||||||
|
onChange={(e) => setUrl(e.target.value)}
|
||||||
|
placeholder="https://exemplo.com/modelo.glb"
|
||||||
|
disabled={loading}
|
||||||
|
className="font-mono text-xs"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{PRESETS.length > 0 && (
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label className="text-xs font-mono text-muted-foreground">Templates prontos</Label>
|
||||||
|
<div className="flex flex-wrap gap-1.5">
|
||||||
|
{PRESETS.map((p) => (
|
||||||
|
<Button
|
||||||
|
key={p.url}
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="h-7 text-[10px] font-mono border border-border"
|
||||||
|
disabled={loading}
|
||||||
|
onClick={() => setUrl(p.url)}
|
||||||
|
>
|
||||||
|
{p.label}
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="ghost" onClick={() => setOpen(false)} disabled={loading}>Cancelar</Button>
|
||||||
|
<Button onClick={() => handleLoad(url.trim())} disabled={loading || !url.trim()} className="gap-2">
|
||||||
|
{loading ? <Loader2 className="h-4 w-4 animate-spin" /> : <Cloud className="h-4 w-4" />}
|
||||||
|
{loading ? 'Baixando…' : 'Carregar'}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -10,6 +10,7 @@ import { ScrollArea } from '@/components/ui/scroll-area';
|
|||||||
import { useModelStore } from '@/stores/useModelStore';
|
import { useModelStore } from '@/stores/useModelStore';
|
||||||
import { InspectionChecklist } from '@/components/InspectionChecklist';
|
import { InspectionChecklist } from '@/components/InspectionChecklist';
|
||||||
import { ScaleSelector } from '@/components/ScaleSelector';
|
import { ScaleSelector } from '@/components/ScaleSelector';
|
||||||
|
import { CloudLoader } from '@/components/CloudLoader';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
|
|
||||||
const POSITION_STEP = 0.001;
|
const POSITION_STEP = 0.001;
|
||||||
@@ -314,6 +315,9 @@ export function XRHud({ freeMove, onToggleFreeMove, placementMode = false, onTog
|
|||||||
{/* Scale presets */}
|
{/* Scale presets */}
|
||||||
<ScaleSelector variant="compact" />
|
<ScaleSelector variant="compact" />
|
||||||
|
|
||||||
|
{/* Cloud loader (acessível também no AR) */}
|
||||||
|
<CloudLoader compact />
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
variant={freeMove ? 'default' : 'outline'}
|
variant={freeMove ? 'default' : 'outline'}
|
||||||
size="sm" className="h-8 gap-1.5 text-[10px] font-mono"
|
size="sm" className="h-8 gap-1.5 text-[10px] font-mono"
|
||||||
|
|||||||
+5
-98
@@ -1,40 +1,19 @@
|
|||||||
import { useEffect, useRef, useCallback, useState } from 'react';
|
import { useEffect, useRef, useCallback, useState } from 'react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { Upload, Glasses, CheckCircle, XCircle, Loader2, Box, Package, Cloud } from 'lucide-react';
|
import { Upload, Glasses, CheckCircle, XCircle, Loader2, Box, Package } from 'lucide-react';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { useModelStore } from '@/stores/useModelStore';
|
import { useModelStore } from '@/stores/useModelStore';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import { getSupportedExtension, convertToGLB, ACCEPTED_EXTENSIONS } from '@/lib/convertToGLB';
|
import { getSupportedExtension, convertToGLB, ACCEPTED_EXTENSIONS } from '@/lib/convertToGLB';
|
||||||
import { convertIFCtoGLB } from '@/lib/convertIFC';
|
import { convertIFCtoGLB } from '@/lib/convertIFC';
|
||||||
|
import { CloudLoader } from '@/components/CloudLoader';
|
||||||
/**
|
|
||||||
* Normaliza URLs comuns para apontar ao binário direto.
|
|
||||||
* - Dropbox: força dl=1
|
|
||||||
* - OneDrive (1drv.ms / onedrive.live.com): força download=1
|
|
||||||
*/
|
|
||||||
function normalizeCloudUrl(input: string): string {
|
|
||||||
try {
|
|
||||||
const u = new URL(input);
|
|
||||||
if (/(^|\.)dropbox\.com$/.test(u.hostname)) {
|
|
||||||
u.searchParams.set('dl', '1');
|
|
||||||
return u.toString();
|
|
||||||
}
|
|
||||||
if (/(^|\.)1drv\.ms$|onedrive\.live\.com$/.test(u.hostname)) {
|
|
||||||
u.searchParams.set('download', '1');
|
|
||||||
return u.toString();
|
|
||||||
}
|
|
||||||
return input;
|
|
||||||
} catch {
|
|
||||||
return input;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const Index = () => {
|
const Index = () => {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
const [loadingDemoIFC, setLoadingDemoIFC] = useState(false);
|
const [loadingDemoIFC, setLoadingDemoIFC] = useState(false);
|
||||||
const [loadingDemoCotovelo, setLoadingDemoCotovelo] = useState(false);
|
const [loadingDemoCotovelo, setLoadingDemoCotovelo] = useState(false);
|
||||||
const [loadingCloud, setLoadingCloud] = useState(false);
|
|
||||||
const [converting, setConverting] = useState(false);
|
const [converting, setConverting] = useState(false);
|
||||||
const { model, setModel, xrSupported, setXrSupported } = useModelStore();
|
const { model, setModel, xrSupported, setXrSupported } = useModelStore();
|
||||||
|
|
||||||
@@ -84,72 +63,7 @@ const Index = () => {
|
|||||||
}
|
}
|
||||||
}, [setModel]);
|
}, [setModel]);
|
||||||
|
|
||||||
const handleLoadCloud = useCallback(async () => {
|
// Carregar da Nuvem agora é encapsulado em <CloudLoader />
|
||||||
const DEFAULT_URL = 'https://store.tracksteel.com.br/demo.ifc';
|
|
||||||
const raw = window.prompt(
|
|
||||||
'URL do modelo (GLB · OBJ · STL · IFC).\nDica: Dropbox use ?dl=1 · OneDrive ?download=1 · FileBrowser /api/raw/<arquivo>',
|
|
||||||
DEFAULT_URL,
|
|
||||||
);
|
|
||||||
if (!raw) return;
|
|
||||||
setLoadingCloud(true);
|
|
||||||
try {
|
|
||||||
// Normalizações automáticas para hosts comuns
|
|
||||||
const url = normalizeCloudUrl(raw.trim());
|
|
||||||
const fileName = url.split('/').pop()?.split('?')[0] || 'modelo-cloud';
|
|
||||||
const ext = getSupportedExtension(fileName);
|
|
||||||
if (!ext) {
|
|
||||||
toast.error('URL deve apontar para .GLB, .OBJ, .STL ou .IFC');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
let response = await fetch(url, { mode: 'cors' });
|
|
||||||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
|
||||||
|
|
||||||
// Detecta servidores que retornam HTML em vez do binário (ex.: FileBrowser SPA)
|
|
||||||
let ct = response.headers.get('content-type') || '';
|
|
||||||
if (ct.includes('text/html')) {
|
|
||||||
// Fallback: tenta endpoint /api/raw/ do FileBrowser
|
|
||||||
try {
|
|
||||||
const u = new URL(url);
|
|
||||||
if (!u.pathname.startsWith('/api/raw/')) {
|
|
||||||
const fbUrl = `${u.origin}/api/raw${u.pathname}${u.search}`;
|
|
||||||
const r2 = await fetch(fbUrl, { mode: 'cors' });
|
|
||||||
if (r2.ok) {
|
|
||||||
response = r2;
|
|
||||||
ct = r2.headers.get('content-type') || '';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch {}
|
|
||||||
}
|
|
||||||
if (ct.includes('text/html')) {
|
|
||||||
toast.error('O servidor devolveu uma página HTML, não o arquivo. Use o link DIRETO do arquivo (ex.: FileBrowser → /api/raw/arquivo.ifc).');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const buffer = await response.arrayBuffer();
|
|
||||||
|
|
||||||
let blob: Blob;
|
|
||||||
let outName = fileName;
|
|
||||||
let outSize = buffer.byteLength;
|
|
||||||
if (ext === 'glb') {
|
|
||||||
blob = new Blob([buffer], { type: 'model/gltf-binary' });
|
|
||||||
} else if (ext === 'ifc') {
|
|
||||||
const r = await convertIFCtoGLB(buffer, fileName);
|
|
||||||
blob = r.blob; outName = r.fileName; outSize = r.fileSize;
|
|
||||||
} else {
|
|
||||||
const r = await convertToGLB(buffer, ext, fileName);
|
|
||||||
blob = r.blob; outName = r.fileName; outSize = r.fileSize;
|
|
||||||
}
|
|
||||||
const blobUrl = URL.createObjectURL(blob);
|
|
||||||
setModel({ fileName: outName, fileSize: outSize, url: blobUrl });
|
|
||||||
toast.success(`"${outName}" carregado da nuvem (RAM)`);
|
|
||||||
} catch (err) {
|
|
||||||
console.error(err);
|
|
||||||
toast.error('Falha ao carregar da nuvem (verifique URL/CORS)');
|
|
||||||
} finally {
|
|
||||||
setLoadingCloud(false);
|
|
||||||
}
|
|
||||||
}, [setModel]);
|
|
||||||
|
|
||||||
const handleLoadDemoIFC = useCallback(async () => {
|
const handleLoadDemoIFC = useCallback(async () => {
|
||||||
setLoadingDemoIFC(true);
|
setLoadingDemoIFC(true);
|
||||||
@@ -234,14 +148,7 @@ const Index = () => {
|
|||||||
|
|
||||||
{/* Demo buttons */}
|
{/* Demo buttons */}
|
||||||
<div className="grid grid-cols-3 gap-2">
|
<div className="grid grid-cols-3 gap-2">
|
||||||
<Button
|
<CloudLoader />
|
||||||
variant="outline"
|
|
||||||
className="h-12 w-full gap-2 border-primary/40 text-primary hover:border-primary hover:bg-primary/10 transition-all text-xs"
|
|
||||||
onClick={handleLoadCloud}
|
|
||||||
disabled={loadingCloud}>
|
|
||||||
{loadingCloud ? <Loader2 className="h-4 w-4 animate-spin" /> : <Cloud className="h-4 w-4" />}
|
|
||||||
{loadingCloud ? 'Baixando…' : 'Carregar da Nuvem'}
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { XR, createXRStore, useXR, XROrigin } from '@react-three/xr';
|
|||||||
import * as THREE from 'three';
|
import * as THREE from 'three';
|
||||||
import { useModelStore } from '@/stores/useModelStore';
|
import { useModelStore } from '@/stores/useModelStore';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import { ArrowLeft, Download, QrCode, Crosshair } from 'lucide-react';
|
import { ArrowLeft, Download, QrCode, Crosshair, Home } from 'lucide-react';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { generateMarkerDownloadURL } from '@/lib/trackingMarker';
|
import { generateMarkerDownloadURL } from '@/lib/trackingMarker';
|
||||||
import { XRHud } from '@/components/XRHud';
|
import { XRHud } from '@/components/XRHud';
|
||||||
@@ -451,10 +451,13 @@ const XRSession = () => {
|
|||||||
<div className="flex h-screen flex-col bg-background">
|
<div className="flex h-screen flex-col bg-background">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<header className="flex items-center justify-between border-b bg-card px-4 py-3 z-10">
|
<header className="flex items-center justify-between border-b bg-card px-4 py-3 z-10">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-2">
|
||||||
<Button variant="ghost" size="icon" onClick={() => navigate('/viewer')}>
|
<Button variant="ghost" size="icon" onClick={() => navigate('/viewer')} title="Voltar ao Viewer">
|
||||||
<ArrowLeft className="h-5 w-5" />
|
<ArrowLeft className="h-5 w-5" />
|
||||||
</Button>
|
</Button>
|
||||||
|
<Button variant="ghost" size="icon" onClick={() => navigate('/')} title="Tela inicial">
|
||||||
|
<Home className="h-5 w-5" />
|
||||||
|
</Button>
|
||||||
<h1 className="font-mono text-sm font-semibold text-foreground">
|
<h1 className="font-mono text-sm font-semibold text-foreground">
|
||||||
Modo <span className="text-primary">XR Imersivo</span>
|
Modo <span className="text-primary">XR Imersivo</span>
|
||||||
</h1>
|
</h1>
|
||||||
|
|||||||
Reference in New Issue
Block a user