feat: remove unused texture files and add model metadata
- Deleted various texture files related to the Raspberry Pi camera model. - Introduced a new JSON file to store model metadata including IDs, names, and URLs. - Implemented authentication and session management using Supabase. - Created a header component for navigation and user session display. - Added protected routes to restrict access to certain pages. - Developed a private layout for authenticated users. - Enhanced the home page to dynamically load models from the new JSON file. - Updated the login page to handle user authentication. - Created a model uploader for users to upload 3D models. - Improved overall styling and layout for better user experience.
This commit is contained in:
28
src/components/Header.tsx
Normal file
28
src/components/Header.tsx
Normal file
@@ -0,0 +1,28 @@
|
||||
import { supabase } from "@/lib/supabase";
|
||||
import { useAuth } from "@/lib/AuthProvider";
|
||||
import { useNavigate } from "react-router";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
export default function Header() {
|
||||
const navigate = useNavigate();
|
||||
const session = useAuth();
|
||||
|
||||
const handleLogout = async () => {
|
||||
await supabase.auth.signOut();
|
||||
navigate("/login");
|
||||
};
|
||||
|
||||
return (
|
||||
<header className="w-full flex justify-between items-center p-4 bg-white shadow-md rounded-b-lg">
|
||||
<div className="flex flex-col">
|
||||
<span className="text-xl font-bold text-gray-800">Visor 3D</span>
|
||||
{session?.session?.user?.email && (
|
||||
<span className="text-sm text-gray-500">Bienvenido, {session.session.user.email}</span>
|
||||
)}
|
||||
</div>
|
||||
<Button onClick={handleLogout} className="bg-red-500 hover:bg-red-600 text-white">
|
||||
Cerrar sesión
|
||||
</Button>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
17
src/components/ProtectedRoute.tsx
Normal file
17
src/components/ProtectedRoute.tsx
Normal file
@@ -0,0 +1,17 @@
|
||||
import { useAuth } from "@/lib/AuthProvider";
|
||||
import { JSX } from "react";
|
||||
import { Navigate } from "react-router";
|
||||
|
||||
export default function ProtectedRoute({ children }: { children: JSX.Element }) {
|
||||
const { session, loading } = useAuth();
|
||||
|
||||
if (loading) {
|
||||
return <div className="p-10 text-center text-gray-600">Cargando sesión...</div>;
|
||||
}
|
||||
|
||||
if (!session) {
|
||||
return <Navigate to="/login" replace />;
|
||||
}
|
||||
|
||||
return children;
|
||||
}
|
||||
11
src/layout/PrivateLayout.tsx
Normal file
11
src/layout/PrivateLayout.tsx
Normal file
@@ -0,0 +1,11 @@
|
||||
import Header from "@/components/Header";
|
||||
|
||||
|
||||
export default function PrivateLayout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-100">
|
||||
<Header />
|
||||
<main className="p-6">{children}</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
7
src/layout/RootLayout.tsx
Normal file
7
src/layout/RootLayout.tsx
Normal file
@@ -0,0 +1,7 @@
|
||||
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="min-h-screen flex flex-col bg-background text-foreground backdrop-blur-sm bg-gradient-to-br from-gray-100 to-gray-300/20 shadow-xl shadow-gray-400/10 p-5 gap-20 rounded-lg">
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
40
src/lib/AuthProvider.tsx
Normal file
40
src/lib/AuthProvider.tsx
Normal file
@@ -0,0 +1,40 @@
|
||||
import { createContext, useContext, useEffect, useState } from "react";
|
||||
import { supabase } from "./supabase";
|
||||
import { Session } from "@supabase/supabase-js";
|
||||
|
||||
type AuthContextType = {
|
||||
session: Session | null;
|
||||
loading: boolean;
|
||||
};
|
||||
|
||||
const AuthContext = createContext<AuthContextType>({ session: null, loading: true });
|
||||
|
||||
export function AuthProvider({ children }: { children: React.ReactNode }) {
|
||||
const [session, setSession] = useState<Session | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
supabase.auth.getSession().then(({ data: { session } }) => {
|
||||
setSession(session);
|
||||
setLoading(false);
|
||||
});
|
||||
|
||||
const { data: listener } = supabase.auth.onAuthStateChange((_event, session) => {
|
||||
setSession(session);
|
||||
});
|
||||
|
||||
return () => {
|
||||
listener.subscription.unsubscribe();
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<AuthContext.Provider value={{ session, loading }}>
|
||||
{children}
|
||||
</AuthContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useAuth() {
|
||||
return useContext(AuthContext);
|
||||
}
|
||||
6
src/lib/supabase.ts
Normal file
6
src/lib/supabase.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { createClient } from "@supabase/supabase-js";
|
||||
|
||||
const supabaseUrl = import.meta.env.VITE_SUPABASE_URL!;
|
||||
const supabaseAnonKey = import.meta.env.VITE_SUPABASE_ANON_KEY!;
|
||||
|
||||
export const supabase = createClient(supabaseUrl, supabaseAnonKey);
|
||||
39
src/main.tsx
39
src/main.tsx
@@ -5,15 +5,36 @@ import Login from './pages/Login.tsx';
|
||||
import Previewer from './pages/Previewer.tsx';
|
||||
import NotFound from './pages/NotFound.tsx';
|
||||
import Home from './pages/Home.tsx';
|
||||
import UploadModel from './pages/Upload3dmodel.tsx';
|
||||
import ProtectedRoute from "./components/ProtectedRoute";
|
||||
import { AuthProvider } from "./lib/AuthProvider";
|
||||
import PrivateLayout from './layout/PrivateLayout.tsx';
|
||||
import RootLayout from './layout/RootLayout.tsx';
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<BrowserRouter basename="/visor3d">
|
||||
<Routes>
|
||||
<Route path="/" element={<Home />} />
|
||||
<Route path="/login" element={<Login />} />
|
||||
<Route path="/previewer" element={<Previewer modelUrl="./3d/motor_de_combustion/scene.gltf" />} />
|
||||
<Route path="/previewer2" element={<Previewer modelUrl="./3d/raspberry_pi_cam_v2.1/scene.gltf" />} />
|
||||
<Route path="*" element={<NotFound />} />
|
||||
</Routes>
|
||||
</BrowserRouter>,
|
||||
)
|
||||
<AuthProvider>
|
||||
<RootLayout>
|
||||
<Routes>
|
||||
<Route path="/login" element={<Login />} />
|
||||
<Route path="/" element={<ProtectedRoute>
|
||||
<PrivateLayout>
|
||||
<Home />
|
||||
</PrivateLayout>
|
||||
</ProtectedRoute>} />
|
||||
<Route path="/previewer/:id" element={<ProtectedRoute>
|
||||
<PrivateLayout>
|
||||
<Previewer />
|
||||
</PrivateLayout>
|
||||
</ProtectedRoute>} />
|
||||
<Route path="/upload" element={<ProtectedRoute>
|
||||
<PrivateLayout>
|
||||
<UploadModel />
|
||||
</PrivateLayout>
|
||||
</ProtectedRoute>} />
|
||||
<Route path="*" element={<NotFound />} />
|
||||
</Routes>
|
||||
</RootLayout>
|
||||
</AuthProvider>
|
||||
</BrowserRouter >
|
||||
);
|
||||
|
||||
@@ -2,22 +2,9 @@ import { useNavigate } from "react-router";
|
||||
import { Canvas, } from "@react-three/fiber";
|
||||
import { Bounds, Environment, useGLTF } from "@react-three/drei";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useRef } from "react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { Group } from "three";
|
||||
|
||||
const models = [
|
||||
{
|
||||
name: "Raspberry Pi Cam V2.1",
|
||||
url: "./3d/raspberry_pi_cam_v2.1/scene.gltf",
|
||||
route: "./previewer2",
|
||||
},
|
||||
{
|
||||
name: "Motor de Combustión",
|
||||
url: "./3d/motor_de_combustion/scene.gltf",
|
||||
route: "./previewer",
|
||||
},
|
||||
];
|
||||
|
||||
import { Model } from "@/types";
|
||||
|
||||
function CenteredModel({ url }: { url: string }) {
|
||||
const group = useRef<Group>(null);
|
||||
@@ -32,6 +19,14 @@ function CenteredModel({ url }: { url: string }) {
|
||||
|
||||
export default function Home() {
|
||||
const navigate = useNavigate();
|
||||
const [models, setModels] = useState<Model[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
fetch(`${import.meta.env.BASE_URL}models.json`)
|
||||
.then(res => res.json())
|
||||
.then(setModels);
|
||||
}, []);
|
||||
|
||||
|
||||
return (
|
||||
<div className="w-full grow bg-gradient-to-br from-gray-100 to-gray-300/20 shadow-xl shadow-gray-400/10 flex flex-col items-center justify-center p-5 rounded-lg backdrop-blur-lg">
|
||||
@@ -45,7 +40,6 @@ export default function Home() {
|
||||
<div className="w-64 h-64 bg-white rounded-xl overflow-hidden border border-white/20 shadow-md">
|
||||
<Canvas >
|
||||
<Environment preset="city" />
|
||||
{/* Your rotating model */}
|
||||
<CenteredModel url={model.url} />
|
||||
</Canvas>
|
||||
</div>
|
||||
@@ -54,7 +48,8 @@ export default function Home() {
|
||||
|
||||
<Button
|
||||
className="mt-6 bg-gradient-to-r from-blue-600 to-purple-600 px-6 py-3 rounded-lg shadow-lg hover:from-blue-500 hover:to-purple-500 transition-all font-display"
|
||||
onClick={() => navigate(model.route)}
|
||||
onClick={() => navigate(`/previewer/${model.id}`)}
|
||||
|
||||
>
|
||||
Ver Modelo
|
||||
</Button>
|
||||
@@ -64,6 +59,3 @@ export default function Home() {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Preload models for performance
|
||||
models.forEach(({ url }) => useGLTF.preload(url));
|
||||
|
||||
@@ -3,9 +3,27 @@ import { Button } from '../components/ui/button';
|
||||
import { Label } from '../components/ui/label';
|
||||
import { Input } from '../components/ui/input';
|
||||
import { useNavigate } from 'react-router';
|
||||
import { useState } from 'react';
|
||||
import { supabase } from '@/lib/supabase';
|
||||
|
||||
function Login() {
|
||||
let navigate = useNavigate();
|
||||
const navigate = useNavigate();
|
||||
const [email, setEmail] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const handleLogin = async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
const { error } = await supabase.auth.signInWithPassword({ email, password });
|
||||
setLoading(false);
|
||||
if (error) {
|
||||
setError("Correo o contraseña incorrectos");
|
||||
} else {
|
||||
navigate('/');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="w-full grow bg-gradient-to-br from-gray-100 to-gray-300/20 shadow-xl shadow-gray-400/10 flex flex-col items-center justify-center p-5 gap-20 rounded-lg backdrop-blur-lg">
|
||||
@@ -15,26 +33,39 @@ function Login() {
|
||||
<CardDescription className="text-gray-500">Accede a tu cuenta para continuar</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form>
|
||||
<form onSubmit={e => e.preventDefault()}>
|
||||
<div className="grid gap-4">
|
||||
<div className="flex flex-col space-y-2">
|
||||
<Label htmlFor="clave" className="font-semibold text-gray-700">Clave La Salle</Label>
|
||||
<Input id="clave" type="clave" placeholder={`al${new Date().getFullYear().toString().slice(-2)}0000`} />
|
||||
<Label htmlFor="email" className="font-semibold text-gray-700">Correo Electrónico</Label>
|
||||
<Input
|
||||
id="email"
|
||||
type="email"
|
||||
placeholder={`juan.lasalle@lasalle.mx`}
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col space-y-2">
|
||||
<Label htmlFor="password" className="font-semibold text-gray-700">Contraseña</Label>
|
||||
<Input id="password" type="password" placeholder="••••••••" />
|
||||
<Input
|
||||
id="password"
|
||||
type="password"
|
||||
placeholder="•••••••••••••••"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
{error && <p className="text-sm text-red-600 text-center mt-2">{error}</p>}
|
||||
</div>
|
||||
</form>
|
||||
</CardContent>
|
||||
<CardFooter className="flex flex-col gap-4">
|
||||
<Button
|
||||
className="font-display px-6 py-3 text-lg font-bold text-white bg-gradient-to-b from-blue-500 to-blue-700 rounded-lg shadow-lg shadow-blue-500/50 transform transition-all duration-300 hover:shadow-xl hover:scale-105 active:translate-y-1 active:shadow-md cursor-pointer" onClick={() => {
|
||||
navigate("/");
|
||||
}}
|
||||
onClick={handleLogin}
|
||||
disabled={loading || !email || !password}
|
||||
className="font-display px-6 py-3 text-lg font-bold text-white bg-gradient-to-b from-blue-500 to-blue-700 rounded-lg shadow-lg shadow-blue-500/50 transform transition-all duration-300 hover:shadow-xl hover:scale-105 active:translate-y-1 active:shadow-md cursor-pointer"
|
||||
>
|
||||
Iniciar sesión
|
||||
{loading ? "Cargando..." : "Iniciar sesión"}
|
||||
</Button>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
@@ -42,4 +73,4 @@ function Login() {
|
||||
);
|
||||
}
|
||||
|
||||
export default Login;
|
||||
export default Login;
|
||||
@@ -14,10 +14,8 @@ import {
|
||||
} from "@/components/ui/popover";
|
||||
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { useNavigate } from 'react-router';
|
||||
import { useNavigate, useParams } from 'react-router';
|
||||
|
||||
|
||||
// Model component
|
||||
function Model({ url, onAnimationsLoaded, isAR }: {
|
||||
url: string;
|
||||
onAnimationsLoaded: (animations: string[], actions: Record<string, any>) => void;
|
||||
@@ -32,42 +30,49 @@ function Model({ url, onAnimationsLoaded, isAR }: {
|
||||
}
|
||||
|
||||
if (isAR) {
|
||||
// 100 times smaller
|
||||
scene.scale.set(0.01, 0.01, 0.01);
|
||||
scene.position.set(0, 0, 0);
|
||||
}
|
||||
else {
|
||||
scene.position.set(10, 0, 10);
|
||||
} else {
|
||||
scene.scale.set(1, 1, 1);
|
||||
scene.position.set(0, 0, 0);
|
||||
}
|
||||
|
||||
}, [actions, animations, onAnimationsLoaded, isAR]);
|
||||
|
||||
return (
|
||||
<Bounds fit margin={.9}>
|
||||
{/* find the object in the perfect angle */}
|
||||
|
||||
<primitive object={scene} />
|
||||
</Bounds>
|
||||
);
|
||||
}
|
||||
|
||||
const store = createXRStore();
|
||||
export default function Previewer({ modelUrl }: { modelUrl: string }) {
|
||||
|
||||
export default function Previewer() {
|
||||
const { modelId } = useParams<{ modelId: string }>();
|
||||
const controlsRef = useRef<OrbitControlsImpl>(null);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const [animations, setAnimations] = useState<string[]>([]);
|
||||
const [actions, setActions] = useState<Record<string, any>>({});
|
||||
const [currentAnimation, setCurrentAnimation] = useState<string | null>(null);
|
||||
const [isAR, setIsAR] = useState(false);
|
||||
|
||||
const [modelUrl, setModelUrl] = useState<string | null>(null);
|
||||
|
||||
const navigate = useNavigate();
|
||||
|
||||
console.log("ID del modelo:", modelId);
|
||||
|
||||
useEffect(() => {
|
||||
fetch(`${import.meta.env.BASE_URL}models.json`)
|
||||
.then(res => res.json())
|
||||
.then(data => {
|
||||
const model = data.find((m: { id: string }) => m.id === modelId);
|
||||
if (model) setModelUrl(model.url);
|
||||
});
|
||||
}, [modelId]);
|
||||
|
||||
const playAnimation = (name: string) => {
|
||||
if (actions[name]) {
|
||||
Object.values(actions).forEach(action => action.stop()); // Stop other animations
|
||||
Object.values(actions).forEach(action => action.stop());
|
||||
actions[name].play();
|
||||
setCurrentAnimation(name);
|
||||
}
|
||||
@@ -79,6 +84,9 @@ export default function Previewer({ modelUrl }: { modelUrl: string }) {
|
||||
setCurrentAnimation(null);
|
||||
}
|
||||
};
|
||||
|
||||
if (!modelUrl) return <div className="p-10 text-center">Cargando modelo...</div>;
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className="relative w-full grow bg-white/10 shadow-xl shadow-gray-300/25 rounded-lg p-6 backdrop-blur-lg h-100">
|
||||
<div className="z-10 absolute top-4 w-100 flex wrap gap-5">
|
||||
@@ -98,9 +106,9 @@ export default function Previewer({ modelUrl }: { modelUrl: string }) {
|
||||
className="w-full flex justify-between italic"
|
||||
onClick={() => {
|
||||
if (currentAnimation === anim) {
|
||||
pauseAnimation(); // Pause if it's already playing
|
||||
pauseAnimation();
|
||||
} else {
|
||||
playAnimation(anim); // Play if it's not currently playing
|
||||
playAnimation(anim);
|
||||
}
|
||||
}}
|
||||
>
|
||||
@@ -133,7 +141,6 @@ export default function Previewer({ modelUrl }: { modelUrl: string }) {
|
||||
>
|
||||
{isAR ? <Shrink className="w-5 h-5" /> : <Expand className="w-5 h-5" />}
|
||||
{isAR ? "Salir de AR" : "Ver en AR"}
|
||||
|
||||
</Button>
|
||||
</div>
|
||||
<div className="w-full h-full">
|
||||
@@ -144,19 +151,16 @@ export default function Previewer({ modelUrl }: { modelUrl: string }) {
|
||||
<OrbitControls ref={controlsRef} minPolarAngle={Math.PI / 4} maxPolarAngle={Math.PI / 1.5} />
|
||||
<Model
|
||||
url={modelUrl}
|
||||
|
||||
onAnimationsLoaded={(animNames, actionMap) => {
|
||||
setAnimations(animNames);
|
||||
setActions(actionMap);
|
||||
}}
|
||||
|
||||
isAR={isAR}
|
||||
/>
|
||||
</XR>
|
||||
</Canvas>
|
||||
</Suspense>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
104
src/pages/Upload3dmodel.tsx
Normal file
104
src/pages/Upload3dmodel.tsx
Normal file
@@ -0,0 +1,104 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Loader2 } from "lucide-react";
|
||||
import { Icon } from "@iconify/react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export default function UploadModel() {
|
||||
const [file, setFile] = useState<File | null>(null);
|
||||
const [modelName, setModelName] = useState("");
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [success, setSuccess] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const handleUpload = async () => {
|
||||
if (!file || !modelName.trim()) return;
|
||||
setUploading(true);
|
||||
setSuccess(false);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append("file", file);
|
||||
formData.append("name", modelName);
|
||||
|
||||
const response = await fetch("/api/upload", {
|
||||
method: "POST",
|
||||
body: formData,
|
||||
});
|
||||
|
||||
if (!response.ok) throw new Error("Upload failed");
|
||||
|
||||
setSuccess(true);
|
||||
setFile(null);
|
||||
setModelName("");
|
||||
} catch (err: any) {
|
||||
setError(err.message || "Error desconocido");
|
||||
} finally {
|
||||
setUploading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="max-w-xl mx-auto bg-white shadow-2xl rounded-3xl p-8 mt-10 text-center space-y-6 border border-gray-200">
|
||||
<h1 className="text-3xl font-bold text-gray-900">Sube tu Modelo 3D</h1>
|
||||
<p className="text-gray-600">Solo archivos <code>.glb</code> o <code>.gltf</code> (máx. 50 MB)</p>
|
||||
|
||||
<div className="flex flex-col gap-4">
|
||||
<div>
|
||||
<Label htmlFor="modelName" className="text-left block mb-1 text-gray-800">
|
||||
Nombre del modelo
|
||||
</Label>
|
||||
<Input
|
||||
id="modelName"
|
||||
type="text"
|
||||
value={modelName}
|
||||
onChange={(e) => setModelName(e.target.value)}
|
||||
placeholder="Ej. brazo-robotico"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col items-center gap-4">
|
||||
<Label htmlFor="file" className="cursor-pointer w-full">
|
||||
<div className={cn(
|
||||
"border-2 border-dashed rounded-xl p-6 w-full h-48 flex flex-col items-center justify-center transition-all",
|
||||
file ? "border-blue-500 bg-blue-50" : "border-gray-300 hover:border-blue-400 hover:bg-blue-50"
|
||||
)}>
|
||||
<Icon icon="mdi:cloud-upload-outline" className="w-10 h-10 text-blue-600 mb-2" />
|
||||
<span className="text-sm text-gray-700">
|
||||
{file ? file.name : "Haz clic o arrastra tu archivo aquí"}
|
||||
</span>
|
||||
</div>
|
||||
</Label>
|
||||
<Input
|
||||
id="file"
|
||||
type="file"
|
||||
accept=".glb,.gltf"
|
||||
className="hidden"
|
||||
onChange={(e) => {
|
||||
const f = e.target.files?.[0];
|
||||
if (f && f.size < 50 * 1024 * 1024) setFile(f);
|
||||
else setError("Archivo muy grande o inválido.");
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
onClick={handleUpload}
|
||||
disabled={!file || uploading || !modelName.trim()}
|
||||
className="w-full bg-gradient-to-r from-blue-600 to-purple-600 hover:from-blue-500 hover:to-purple-500 text-white"
|
||||
>
|
||||
{uploading ? <Loader2 className="animate-spin w-5 h-5 mr-2" /> : null}
|
||||
Subir modelo
|
||||
</Button>
|
||||
|
||||
{success && <p className="text-green-600">Modelo subido exitosamente</p>}
|
||||
{error && <p className="text-red-600">{error}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -141,13 +141,12 @@
|
||||
|
||||
#root {
|
||||
background-image: url('/la-salle.png');
|
||||
/* Replace with your image path */
|
||||
background-position: bottom right;
|
||||
background-repeat: no-repeat;
|
||||
background-size: contain;
|
||||
/* Adjust based on needs */
|
||||
}
|
||||
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border outline-ring/50;
|
||||
@@ -156,4 +155,11 @@
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
#root {
|
||||
background-size: 120px;
|
||||
background-position: bottom 10px right 10px;
|
||||
}
|
||||
}
|
||||
|
||||
5
src/types.ts
Normal file
5
src/types.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export type Model = {
|
||||
id: string;
|
||||
name: string;
|
||||
url: string;
|
||||
};
|
||||
Reference in New Issue
Block a user