comitt inicial
This commit is contained in:
9
.env
Normal file
9
.env
Normal file
@@ -0,0 +1,9 @@
|
||||
VITE_FIREBASE_API_KEY="AIzaSyBBitFgNKnJ_3B0aqJgbbhGL_erufKd9lk"
|
||||
VITE_FIREBASE_AUTH_DOMAIN="mycloset1-864c4.firebaseapp.com"
|
||||
VITE_FIREBASE_PROJECT_ID="mycloset1-864c4"
|
||||
VITE_FIREBASE_STORAGE_BUCKET="mycloset1-864c4.firebasestorage.app"
|
||||
VITE_FIREBASE_MESSAGING_SENDER_ID="219982610263"
|
||||
VITE_FIREBASE_APP_ID="1:219982610263:web:0ebe67d9cf0e7d2753c812"
|
||||
|
||||
VITE_APP_ID=my-closet-app
|
||||
VITE_INITIAL_AUTH_TOKEN=
|
||||
9
.env.example
Normal file
9
.env.example
Normal file
@@ -0,0 +1,9 @@
|
||||
VITE_FIREBASE_API_KEY=your_api_key
|
||||
VITE_FIREBASE_AUTH_DOMAIN=your_auth_domain
|
||||
VITE_FIREBASE_PROJECT_ID=your_project_id
|
||||
VITE_FIREBASE_STORAGE_BUCKET=your_storage_bucket
|
||||
VITE_FIREBASE_MESSAGING_SENDER_ID=your_messaging_sender_id
|
||||
VITE_FIREBASE_APP_ID=your_firebase_app_id
|
||||
|
||||
VITE_APP_ID=my-closet-app
|
||||
VITE_INITIAL_AUTH_TOKEN=
|
||||
1
.gitignore
vendored
Normal file
1
.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
||||
node_modules
|
||||
18
README.md
Normal file
18
README.md
Normal file
@@ -0,0 +1,18 @@
|
||||
# MyCloset
|
||||
|
||||
## Environment Setup
|
||||
|
||||
1. Copy `.env.example` to `.env`
|
||||
2. Fill your Firebase configuration keys into the variables defined in `.env`.
|
||||
|
||||
## Development
|
||||
|
||||
1. Install dependencies:
|
||||
```bash
|
||||
npm install
|
||||
```
|
||||
|
||||
2. Run the development server:
|
||||
```bash
|
||||
npm run dev
|
||||
```
|
||||
1
dist/assets/index-ClW_vKX0.css
vendored
Normal file
1
dist/assets/index-ClW_vKX0.css
vendored
Normal file
File diff suppressed because one or more lines are too long
3274
dist/assets/index-DoOxILKU.js
vendored
Normal file
3274
dist/assets/index-DoOxILKU.js
vendored
Normal file
File diff suppressed because one or more lines are too long
13
dist/index.html
vendored
Normal file
13
dist/index.html
vendored
Normal file
@@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>MyCloset</title>
|
||||
<script type="module" crossorigin src="/assets/index-DoOxILKU.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-ClW_vKX0.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
</body>
|
||||
</html>
|
||||
12
index.html
Normal file
12
index.html
Normal file
@@ -0,0 +1,12 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>MyCloset</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.jsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
3641
package-lock.json
generated
Normal file
3641
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
24
package.json
Normal file
24
package.json
Normal file
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"name": "my-closet",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"firebase": "^10.8.0",
|
||||
"lucide-react": "^0.344.0",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-react": "^4.2.1",
|
||||
"autoprefixer": "^10.4.18",
|
||||
"postcss": "^8.4.35",
|
||||
"tailwindcss": "^3.4.1",
|
||||
"vite": "^5.1.4"
|
||||
}
|
||||
}
|
||||
6
postcss.config.js
Normal file
6
postcss.config.js
Normal file
@@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
680
src/App.jsx
Normal file
680
src/App.jsx
Normal file
@@ -0,0 +1,680 @@
|
||||
import React, { useState, useEffect, useMemo } from 'react';
|
||||
import {
|
||||
Plus, Search, LayoutDashboard, Shirt, LogOut,
|
||||
Trash2, Heart, Loader2, AlertCircle,
|
||||
UserCircle, Settings, Moon, Sun, ShieldAlert,
|
||||
Edit2, Image as ImageIcon, Check, RotateCcw, Trash,
|
||||
PanelLeftClose, PanelLeftOpen, Sparkles, CloudSun,
|
||||
ArrowRight, Droplets, CheckCircle2, PieChart, History,
|
||||
X
|
||||
} from 'lucide-react';
|
||||
|
||||
import {
|
||||
signInWithEmailAndPassword, createUserWithEmailAndPassword,
|
||||
onAuthStateChanged, signOut, signInAnonymously, signInWithCustomToken
|
||||
} from 'firebase/auth';
|
||||
import {
|
||||
collection, doc, onSnapshot, addDoc, updateDoc,
|
||||
deleteDoc, writeBatch
|
||||
} from 'firebase/firestore';
|
||||
|
||||
import { auth, db, appId } from './lib/firebase';
|
||||
import { Card } from './components/ui/Card';
|
||||
import { Badge } from './components/ui/Badge';
|
||||
import { Input } from './components/ui/Input';
|
||||
|
||||
export default function App() {
|
||||
const [view, setView] = useState('auth');
|
||||
const [user, setUser] = useState(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [clothes, setClothes] = useState([]);
|
||||
const [looks, setLooks] = useState([]);
|
||||
const [editingItem, setEditingItem] = useState(null);
|
||||
const [darkMode, setDarkMode] = useState(false);
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [imageUrlDraft, setImageUrlDraft] = useState('');
|
||||
const [sidebarOpen, setSidebarOpen] = useState(true);
|
||||
const [authMode, setAuthMode] = useState('login');
|
||||
const [authError, setAuthError] = useState('');
|
||||
const [categoryFilter, setCategoryFilter] = useState('Todos');
|
||||
|
||||
// Estado para criação de Looks
|
||||
const [selectedForLook, setSelectedForLook] = useState([]);
|
||||
|
||||
// 1. Inicializar Autenticação
|
||||
useEffect(() => {
|
||||
const initAuth = async () => {
|
||||
const token = import.meta.env.VITE_INITIAL_AUTH_TOKEN;
|
||||
if (token) {
|
||||
try { await signInWithCustomToken(auth, token); } catch (e) { }
|
||||
}
|
||||
};
|
||||
initAuth();
|
||||
const unsubscribe = onAuthStateChanged(auth, (currentUser) => {
|
||||
setUser(currentUser);
|
||||
currentUser ? setView('dashboard') : setView('auth');
|
||||
setLoading(false);
|
||||
});
|
||||
return () => unsubscribe();
|
||||
}, []);
|
||||
|
||||
// 2. Dados em Tempo Real (Roupas e Looks)
|
||||
useEffect(() => {
|
||||
if (!user) return;
|
||||
|
||||
// Roupas
|
||||
const clothesCol = collection(db, 'artifacts', appId, 'users', user.uid, 'clothes');
|
||||
const unsubClothes = onSnapshot(clothesCol, (snap) => {
|
||||
setClothes(snap.docs.map(d => ({ id: d.id, ...d.data() })));
|
||||
}, (err) => console.error(err));
|
||||
|
||||
// Looks
|
||||
const looksCol = collection(db, 'artifacts', appId, 'users', user.uid, 'looks');
|
||||
const unsubLooks = onSnapshot(looksCol, (snap) => {
|
||||
setLooks(snap.docs.map(d => ({ id: d.id, ...d.data() })));
|
||||
}, (err) => console.error(err));
|
||||
|
||||
return () => { unsubClothes(); unsubLooks(); };
|
||||
}, [user]);
|
||||
|
||||
// --- Lógicas de Negócio ---
|
||||
|
||||
const activeClothes = useMemo(() => clothes.filter(c => c.status === 'active'), [clothes]);
|
||||
const laundryClothes = useMemo(() => clothes.filter(c => c.status === 'laundry'), [clothes]);
|
||||
const trashClothes = useMemo(() => clothes.filter(c => c.status === 'trash'), [clothes]);
|
||||
|
||||
const filteredClothes = useMemo(() => {
|
||||
return activeClothes.filter(c => {
|
||||
const matchesSearch = (c.name || "").toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
(c.color || "").toLowerCase().includes(searchTerm.toLowerCase());
|
||||
const matchesCategory = categoryFilter === 'Todos' || c.category === categoryFilter;
|
||||
return matchesSearch && matchesCategory;
|
||||
});
|
||||
}, [activeClothes, searchTerm, categoryFilter]);
|
||||
|
||||
// Ações de Itens
|
||||
const handleItemAction = async (action, item) => {
|
||||
if (!user) return;
|
||||
const docRef = doc(db, 'artifacts', appId, 'users', user.uid, 'clothes', item.id || item);
|
||||
|
||||
switch (action) {
|
||||
case 'favorite': await updateDoc(docRef, { favorite: !item.favorite }); break;
|
||||
case 'trash': await updateDoc(docRef, { status: 'trash', trashedAt: new Date().getTime() }); break;
|
||||
case 'restore': await updateDoc(docRef, { status: 'active', trashedAt: null }); break;
|
||||
case 'laundry': await updateDoc(docRef, { status: 'laundry' }); break;
|
||||
case 'clean': await updateDoc(docRef, { status: 'active' }); break;
|
||||
case 'delete': if (window.confirm("Apagar permanentemente?")) await deleteDoc(docRef); break;
|
||||
}
|
||||
};
|
||||
|
||||
const saveItem = async (e) => {
|
||||
e.preventDefault();
|
||||
if (!user) return;
|
||||
setLoading(true);
|
||||
const formData = new FormData(e.target);
|
||||
const itemData = {
|
||||
name: formData.get('name'),
|
||||
category: formData.get('category'),
|
||||
color: formData.get('color'),
|
||||
imageUrl: formData.get('imageUrl') || 'https://images.unsplash.com/photo-1521572267360-ee0c2909d518?q=80&w=500&auto=format&fit=crop',
|
||||
status: 'active',
|
||||
favorite: editingItem ? (editingItem.favorite || false) : false,
|
||||
updatedAt: new Date().getTime()
|
||||
};
|
||||
|
||||
try {
|
||||
if (editingItem) {
|
||||
const docRef = doc(db, 'artifacts', appId, 'users', user.uid, 'clothes', editingItem.id);
|
||||
await updateDoc(docRef, itemData);
|
||||
} else {
|
||||
itemData.createdAt = new Date().getTime();
|
||||
const clothesCol = collection(db, 'artifacts', appId, 'users', user.uid, 'clothes');
|
||||
await addDoc(clothesCol, itemData);
|
||||
}
|
||||
setEditingItem(null);
|
||||
setImageUrlDraft('');
|
||||
setView('closet');
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const createLook = async (e) => {
|
||||
e.preventDefault();
|
||||
if (selectedForLook.length < 2) return;
|
||||
setLoading(true);
|
||||
const fd = new FormData(e.target);
|
||||
try {
|
||||
const looksCol = collection(db, 'artifacts', appId, 'users', user.uid, 'looks');
|
||||
await addDoc(looksCol, {
|
||||
name: fd.get('lookName'),
|
||||
items: selectedForLook,
|
||||
createdAt: new Date().getTime()
|
||||
});
|
||||
setSelectedForLook([]);
|
||||
setView('outfits');
|
||||
} catch (e) { console.error(e); }
|
||||
finally { setLoading(false); }
|
||||
};
|
||||
|
||||
const deleteLook = async (id) => {
|
||||
if (!window.confirm("Apagar este Look?")) return;
|
||||
const docRef = doc(db, 'artifacts', appId, 'users', user.uid, 'looks', id);
|
||||
await deleteDoc(docRef);
|
||||
};
|
||||
|
||||
const handleAuth = async (e) => {
|
||||
e.preventDefault();
|
||||
setAuthError('');
|
||||
setLoading(true);
|
||||
const fd = new FormData(e.target);
|
||||
const email = fd.get('email');
|
||||
const password = fd.get('password');
|
||||
|
||||
try {
|
||||
if (authMode === 'login') await signInWithEmailAndPassword(auth, email, password);
|
||||
else await createUserWithEmailAndPassword(auth, email, password);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
if (err.code === 'auth/operation-not-allowed') {
|
||||
setAuthError('O login por e-mail está desativado. Use o modo Convidado.');
|
||||
} else {
|
||||
setAuthError(err.message);
|
||||
}
|
||||
} finally { setLoading(false); }
|
||||
};
|
||||
|
||||
const handleGuestLogin = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
await signInAnonymously(auth);
|
||||
} catch (e) {
|
||||
setAuthError('Erro no modo convidado: ' + e.message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const emptyTrashPermanently = async () => {
|
||||
if (!user || !window.confirm("Esvaziar o lixo permanentemente?")) return;
|
||||
setLoading(true);
|
||||
try {
|
||||
const batch = writeBatch(db);
|
||||
trashClothes.forEach(item => {
|
||||
const docRef = doc(db, 'artifacts', appId, 'users', user.uid, 'clothes', item.id);
|
||||
batch.delete(docRef);
|
||||
});
|
||||
await batch.commit();
|
||||
} catch (e) { console.error(e); }
|
||||
finally { setLoading(false); }
|
||||
};
|
||||
|
||||
const clearAllToTrash = async () => {
|
||||
if (!user || !window.confirm("Mover todas as peças ativas para o lixo?")) return;
|
||||
setLoading(true);
|
||||
try {
|
||||
const batch = writeBatch(db);
|
||||
activeClothes.forEach(item => {
|
||||
const docRef = doc(db, 'artifacts', appId, 'users', user.uid, 'clothes', item.id);
|
||||
batch.update(docRef, { status: 'trash', trashedAt: new Date().getTime() });
|
||||
});
|
||||
await batch.commit();
|
||||
} catch (e) { console.error(e); }
|
||||
finally { setLoading(false); }
|
||||
};
|
||||
|
||||
if (loading && !user) return <div className="h-screen flex items-center justify-center bg-indigo-50 dark:bg-gray-950"><Loader2 className="animate-spin text-indigo-600" size={40} /></div>;
|
||||
|
||||
if (view === 'auth') {
|
||||
return (
|
||||
<div className={`min-h-screen bg-gradient-to-br from-indigo-100 via-white to-purple-50 dark:from-gray-950 dark:to-gray-900 flex items-center justify-center p-6 text-gray-900 ${darkMode ? 'dark' : ''}`}>
|
||||
<Card className="max-w-md w-full p-12 border-none shadow-2xl" darkMode={darkMode}>
|
||||
<div className="text-center mb-10">
|
||||
<div className="inline-flex p-5 bg-indigo-600 rounded-[2rem] shadow-2xl shadow-indigo-600/40 mb-6">
|
||||
<Shirt className="text-white w-12 h-12" />
|
||||
</div>
|
||||
<h1 className="text-5xl font-black tracking-tighter italic">MyCloset</h1>
|
||||
<p className="text-gray-400 mt-3 font-bold uppercase tracking-widest text-[10px]">O Futuro do Teu Estilo</p>
|
||||
</div>
|
||||
|
||||
{authError && <div className="mb-6 p-4 bg-red-50 text-red-600 text-[10px] rounded-2xl flex items-center gap-2 font-black uppercase tracking-widest border border-red-100"><AlertCircle size={16} /> {authError}</div>}
|
||||
|
||||
<form onSubmit={handleAuth} className="space-y-4">
|
||||
<input name="email" type="email" placeholder="E-mail" required className="w-full p-5 rounded-2xl bg-gray-50 dark:bg-gray-800 border-none focus:ring-2 focus:ring-indigo-500 outline-none font-bold" />
|
||||
<input name="password" type="password" placeholder="Palavra-passe" required className="w-full p-5 rounded-2xl bg-gray-50 dark:bg-gray-800 border-none focus:ring-2 focus:ring-indigo-500 outline-none font-bold" />
|
||||
<button className="w-full py-5 bg-indigo-600 text-white rounded-[2rem] font-black text-lg shadow-2xl hover:scale-[1.02] active:scale-95 transition-all">
|
||||
{authMode === 'login' ? 'ENTRAR' : 'REGISTAR'}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<button onClick={handleGuestLogin} className="w-full mt-4 py-4 text-indigo-600 rounded-2xl font-black text-xs uppercase tracking-widest hover:bg-indigo-50 transition-all flex items-center justify-center gap-2">
|
||||
<UserCircle size={18} /> Modo Convidado
|
||||
</button>
|
||||
|
||||
<div className="mt-10 text-center">
|
||||
<button onClick={() => setAuthMode(authMode === 'login' ? 'register' : 'login')} className="text-gray-400 font-black text-[10px] uppercase tracking-[0.3em] hover:text-indigo-600 transition-colors text-inherit">
|
||||
{authMode === 'login' ? 'Criar Nova Conta' : 'Já Tenho Conta'}
|
||||
</button>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`min-h-screen flex transition-all duration-700 ${darkMode ? 'bg-gray-950 text-white dark' : 'bg-[#FDFDFF] text-gray-900'}`}>
|
||||
|
||||
{/* Sidebar - Design Futurista */}
|
||||
<aside className={`
|
||||
fixed md:relative inset-y-0 left-0 z-[100] transition-all duration-500 ease-in-out border-r
|
||||
${darkMode ? 'bg-gray-900/80 border-gray-800' : 'bg-white border-gray-100'}
|
||||
${sidebarOpen ? 'w-80 translate-x-0' : 'w-0 -translate-x-full md:w-0 md:opacity-0'}
|
||||
`}>
|
||||
<div className="p-10 h-full flex flex-col backdrop-blur-xl">
|
||||
<div className="flex items-center gap-4 mb-16">
|
||||
<div className="p-3 bg-indigo-600 rounded-2xl shadow-xl shadow-indigo-600/30">
|
||||
<Shirt className="text-white" size={24} />
|
||||
</div>
|
||||
<span className="text-3xl font-black tracking-tighter italic">MyCloset</span>
|
||||
</div>
|
||||
|
||||
<nav className="flex-1 space-y-3">
|
||||
{[
|
||||
{ id: 'dashboard', label: 'Dashboard', icon: LayoutDashboard },
|
||||
{ id: 'closet', label: 'Armário', icon: Shirt },
|
||||
{ id: 'laundry', label: 'Lavandaria', icon: Droplets },
|
||||
{ id: 'outfits', label: 'Looks', icon: Sparkles },
|
||||
{ id: 'settings', label: 'Definições', icon: Settings },
|
||||
].map(item => (
|
||||
<button
|
||||
key={item.id}
|
||||
onClick={() => setView(item.id)}
|
||||
className={`w-full flex items-center gap-4 px-6 py-4 rounded-2xl transition-all font-black text-[11px] uppercase tracking-widest ${view === item.id ? 'bg-indigo-600 text-white shadow-2xl shadow-indigo-600/30 scale-105' : 'opacity-40 hover:opacity-100 hover:bg-indigo-500/5'}`}
|
||||
>
|
||||
<item.icon size={20} />
|
||||
<span>{item.label}</span>
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
<div className="mt-auto pt-10 border-t border-inherit">
|
||||
<div className="flex items-center gap-4 mb-8 px-2">
|
||||
<div className={`w-12 h-12 rounded-2xl flex items-center justify-center font-black text-white shadow-xl ${darkMode ? 'bg-indigo-500' : 'bg-indigo-600'}`}>
|
||||
{user?.isAnonymous ? 'C' : user?.email?.[0]?.toUpperCase()}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-black truncate">{user?.isAnonymous ? 'Convidado' : (user?.email?.split('@')[0] || 'Utilizador')}</p>
|
||||
<Badge variant="success">Online</Badge>
|
||||
</div>
|
||||
</div>
|
||||
<button onClick={() => signOut(auth)} className="w-full py-4 text-red-500 font-black uppercase tracking-widest text-[10px] hover:bg-red-500/10 rounded-2xl transition-all flex items-center justify-center gap-3">
|
||||
<LogOut size={16} /> Sair do Sistema
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
{/* Área Principal */}
|
||||
<main className="flex-1 flex flex-col h-screen overflow-hidden">
|
||||
|
||||
{/* Header Superior */}
|
||||
<header className={`h-24 shrink-0 flex items-center justify-between px-8 md:px-12 transition-all border-b border-inherit ${darkMode ? 'bg-gray-950/50' : 'bg-white/50'} backdrop-blur-xl`}>
|
||||
<div className="flex items-center gap-6">
|
||||
<button onClick={() => setSidebarOpen(!sidebarOpen)} className={`p-3 rounded-2xl transition-all border ${darkMode ? 'bg-gray-800 border-gray-700' : 'bg-gray-50 border-gray-200'}`}>
|
||||
{sidebarOpen ? <PanelLeftClose size={24} /> : <PanelLeftOpen size={24} />}
|
||||
</button>
|
||||
<h2 className="text-3xl font-black tracking-tighter">
|
||||
{view === 'dashboard' && 'Visão Geral'}
|
||||
{view === 'closet' && 'O Meu Armário'}
|
||||
{view === 'laundry' && 'Lavandaria'}
|
||||
{view === 'outfits' && 'Looks & Estilo'}
|
||||
{view === 'settings' && 'Definições'}
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex bg-gray-100 dark:bg-gray-800 p-1.5 rounded-2xl">
|
||||
<button onClick={() => setDarkMode(false)} className={`p-2 rounded-xl ${!darkMode ? 'bg-white shadow-md text-indigo-600' : 'text-gray-500'}`}><Sun size={18} /></button>
|
||||
<button onClick={() => setDarkMode(true)} className={`p-2 rounded-xl ${darkMode ? 'bg-gray-900 shadow-md text-indigo-400' : 'text-gray-500'}`}><Moon size={18} /></button>
|
||||
</div>
|
||||
<button onClick={() => { setEditingItem(null); setImageUrlDraft(''); setView('add'); }} className="p-4 bg-indigo-600 text-white rounded-2xl shadow-xl shadow-indigo-600/30 hover:scale-105 active:scale-95 transition-all">
|
||||
<Plus size={24} />
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Conteúdo Dinâmico */}
|
||||
<div className="flex-1 overflow-y-auto p-8 md:p-12 space-y-12">
|
||||
|
||||
{/* DASHBOARD */}
|
||||
{view === 'dashboard' && (
|
||||
<div className="space-y-12 animate-in fade-in duration-700">
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-8">
|
||||
{[
|
||||
{ label: 'Roupas Prontas', val: activeClothes.length, icon: Shirt, col: 'indigo' },
|
||||
{ label: 'Na Lavandaria', val: laundryClothes.length, icon: Droplets, col: 'blue' },
|
||||
{ label: 'Meus Looks', val: looks.length, icon: Sparkles, col: 'purple' },
|
||||
{ label: 'Favoritos', val: activeClothes.filter(c => c.favorite).length, icon: Heart, col: 'rose' },
|
||||
].map((s, i) => (
|
||||
<Card key={i} className="p-8 group hover:-translate-y-2" darkMode={darkMode}>
|
||||
<div className={`w-14 h-14 rounded-2xl flex items-center justify-center mb-6 shadow-inner ${darkMode ? 'bg-gray-700 text-indigo-400' : 'bg-indigo-50 text-indigo-600'}`}>
|
||||
<s.icon size={28} />
|
||||
</div>
|
||||
<p className="text-[10px] font-black uppercase tracking-widest opacity-40 mb-1">{s.label}</p>
|
||||
<h4 className="text-4xl font-black tracking-tight">{s.val}</h4>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
|
||||
<Card className="lg:col-span-2 p-10 bg-indigo-600 text-white border-none shadow-2xl shadow-indigo-600/40 relative overflow-hidden" darkMode={darkMode}>
|
||||
<div className="relative z-10 flex flex-col justify-between h-full">
|
||||
<div>
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<CloudSun size={28} className="text-indigo-200" />
|
||||
<Badge variant="warning">Hoje em Portugal</Badge>
|
||||
</div>
|
||||
<h3 className="text-5xl font-black tracking-tighter mb-4">22°C - Ensolarado</h3>
|
||||
<p className="text-indigo-100 text-lg font-medium max-w-lg leading-relaxed">
|
||||
Está um dia fantástico! Recomendamos as tuas peças leves. Que tal um visual casual com as tuas sapatilhas favoritas?
|
||||
</p>
|
||||
</div>
|
||||
<div className="mt-10 flex gap-4">
|
||||
{activeClothes.filter(c => c.category === 'Tops').slice(0, 2).map(c => (
|
||||
<div key={c.id} className="w-16 h-16 rounded-xl overflow-hidden border-2 border-indigo-400">
|
||||
<img src={c.imageUrl} className="w-full h-full object-cover" alt="" />
|
||||
</div>
|
||||
))}
|
||||
<button onClick={() => setView('closet')} className="flex items-center gap-2 font-black uppercase text-xs tracking-widest hover:translate-x-2 transition-transform">
|
||||
Explorar Sugestões <ArrowRight size={18} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<CloudSun size={350} className="absolute -bottom-20 -right-20 text-white/10" />
|
||||
</Card>
|
||||
|
||||
<Card className="p-8" darkMode={darkMode}>
|
||||
<h3 className="text-lg font-black tracking-tight mb-8 flex items-center gap-2 text-inherit"><PieChart size={20} /> Top Cores</h3>
|
||||
<div className="space-y-6">
|
||||
{['Preto', 'Branco', 'Azul'].map(color => (
|
||||
<div key={color} className="space-y-2">
|
||||
<div className="flex justify-between text-[10px] font-black uppercase tracking-widest opacity-50">
|
||||
<span>{color}</span>
|
||||
<span>{Math.floor(Math.random() * 40 + 20)}%</span>
|
||||
</div>
|
||||
<div className="h-2 w-full bg-gray-100 dark:bg-gray-700 rounded-full overflow-hidden">
|
||||
<div className="h-full bg-indigo-600" style={{ width: `${Math.floor(Math.random() * 40 + 20)}%` }}></div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ARMÁRIO */}
|
||||
{view === 'closet' && (
|
||||
<div className="space-y-10 animate-in slide-in-from-bottom-8 duration-700">
|
||||
<div className="flex flex-col xl:flex-row gap-8 items-center justify-between">
|
||||
<div className="relative w-full max-w-2xl">
|
||||
<Search className="absolute left-6 top-1/2 -translate-y-1/2 text-gray-400" size={24} />
|
||||
<input
|
||||
placeholder="Procurar no meu guarda-roupa..."
|
||||
className={`w-full pl-16 pr-8 py-6 rounded-[2rem] shadow-inner outline-none border-none focus:ring-4 focus:ring-indigo-500/10 font-bold text-lg ${darkMode ? 'bg-gray-800' : 'bg-gray-100'}`}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3 overflow-x-auto pb-4 w-full xl:w-auto custom-scrollbar">
|
||||
{['Todos', 'Tops', 'Bottoms', 'Calçado', 'Casacos', 'Acessórios'].map(cat => (
|
||||
<button
|
||||
key={cat}
|
||||
onClick={() => setCategoryFilter(cat)}
|
||||
className={`px-8 py-4 rounded-2xl font-black text-xs uppercase tracking-widest transition-all ${categoryFilter === cat ? 'bg-indigo-600 text-white shadow-xl shadow-indigo-600/30' : (darkMode ? 'bg-gray-800 text-gray-400' : 'bg-white text-gray-500 shadow-sm border border-gray-100')}`}
|
||||
>
|
||||
{cat}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 2xl:grid-cols-4 gap-10">
|
||||
{filteredClothes.map(item => (
|
||||
<div key={item.id} className="group">
|
||||
<Card className="overflow-hidden p-0 h-[480px] relative border-none hover:shadow-2xl transition-all duration-500" darkMode={darkMode}>
|
||||
<img src={item.imageUrl} className="w-full h-full object-cover transition-transform duration-1000 group-hover:scale-110" alt={item.name} />
|
||||
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-black/80 via-transparent opacity-0 group-hover:opacity-100 transition-all duration-300 flex flex-col justify-end p-8 text-white">
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<button onClick={() => { setEditingItem(item); setImageUrlDraft(''); setView('edit'); }} className="py-4 bg-white text-indigo-600 rounded-2xl font-black text-[10px] uppercase flex items-center justify-center gap-2 hover:bg-indigo-50"><Edit2 size={16} /> Editar</button>
|
||||
<button onClick={() => handleItemAction('laundry', item)} className="py-4 bg-blue-600 text-white rounded-2xl font-black text-[10px] uppercase flex items-center justify-center gap-2 hover:bg-blue-700"><Droplets size={16} /> Sujar</button>
|
||||
<button onClick={() => handleItemAction('trash', item)} className="py-4 bg-red-600/20 text-red-100 backdrop-blur-md rounded-2xl font-black text-[10px] uppercase hover:bg-red-600 transition-colors col-span-2">Mover para Lixo</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="absolute top-6 left-6"><Badge>{item.category}</Badge></div>
|
||||
<div className="absolute top-6 right-6">
|
||||
<button onClick={() => handleItemAction('favorite', item)} className={`p-3 rounded-2xl shadow-xl backdrop-blur-md transition-all ${item.favorite ? 'bg-rose-500 text-white' : 'bg-white/90 text-gray-400'}`}>
|
||||
<Heart size={18} fill={item.favorite ? "currentColor" : "none"} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="absolute bottom-6 left-6 right-6 p-6 bg-white/95 dark:bg-gray-900/95 backdrop-blur-2xl rounded-3xl shadow-2xl transform transition-transform group-hover:-translate-y-2">
|
||||
<h4 className="text-xl font-black tracking-tighter truncate">{item.name}</h4>
|
||||
<div className="flex items-center gap-3 mt-2">
|
||||
<div className="w-4 h-4 rounded-full border border-black/5" style={{ backgroundColor: (item.color || "").toLowerCase() }}></div>
|
||||
<span className="text-[10px] font-black opacity-40 uppercase tracking-widest">{item.color}</span>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* LAVANDARIA */}
|
||||
{view === 'laundry' && (
|
||||
<div className="space-y-12 animate-in fade-in duration-700">
|
||||
<div className="text-center max-w-2xl mx-auto space-y-4 text-inherit">
|
||||
<div className="w-20 h-20 bg-blue-100 dark:bg-blue-900/30 rounded-[2rem] flex items-center justify-center mx-auto text-blue-600 shadow-inner">
|
||||
<Droplets size={40} />
|
||||
</div>
|
||||
<h3 className="text-4xl font-black tracking-tight">Cesto da Roupa</h3>
|
||||
<p className="opacity-60 font-medium">Aqui encontras as peças que marcaste como sujas. Lava-as para que voltem ao armário principal.</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-8">
|
||||
{laundryClothes.map(item => (
|
||||
<Card key={item.id} className="p-6 flex items-center gap-6 border-blue-100" darkMode={darkMode}>
|
||||
<img src={item.imageUrl} className="w-20 h-20 rounded-2xl object-cover shadow-lg" alt="" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="font-black truncate">{item.name}</p>
|
||||
<Badge variant="warning">A lavar</Badge>
|
||||
</div>
|
||||
<button onClick={() => handleItemAction('clean', item)} className="p-4 bg-green-500 text-white rounded-2xl shadow-lg shadow-green-500/30 hover:scale-110 transition-all">
|
||||
<CheckCircle2 size={24} />
|
||||
</button>
|
||||
</Card>
|
||||
))}
|
||||
{laundryClothes.length === 0 && (
|
||||
<div className="col-span-full py-20 text-center opacity-20 font-black uppercase tracking-[0.5em] text-sm">Cesto Vazio</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* LOOKS */}
|
||||
{view === 'outfits' && (
|
||||
<div className="space-y-12 animate-in fade-in duration-700 pb-20">
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-12">
|
||||
<div className="lg:col-span-1 space-y-8">
|
||||
<Card className="p-8 border-indigo-200" darkMode={darkMode}>
|
||||
<h3 className="text-2xl font-black tracking-tighter mb-6 flex items-center gap-3 text-inherit"><Sparkles className="text-indigo-600" /> Criar Novo Look</h3>
|
||||
<form onSubmit={createLook} className="space-y-6">
|
||||
<input name="lookName" placeholder="Nome do Look" required className={`w-full p-4 rounded-xl border-none shadow-inner font-bold ${darkMode ? 'bg-gray-700' : 'bg-gray-100'}`} />
|
||||
<div className="space-y-3">
|
||||
<p className="text-[10px] font-black uppercase opacity-40 tracking-widest">Peças Selecionadas ({selectedForLook.length})</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{selectedForLook.map(id => {
|
||||
const item = clothes.find(c => c.id === id);
|
||||
return (
|
||||
<div key={id} className="relative group">
|
||||
<img src={item?.imageUrl} className="w-12 h-12 rounded-lg object-cover border-2 border-indigo-500" alt="" />
|
||||
<button onClick={() => setSelectedForLook(selectedForLook.filter(i => i !== id))} className="absolute -top-1 -right-1 bg-red-500 text-white rounded-full p-0.5 opacity-0 group-hover:opacity-100 transition-opacity"><X size={10} /></button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{selectedForLook.length === 0 && <p className="text-xs text-gray-400 italic">Seleciona peças...</p>}
|
||||
</div>
|
||||
</div>
|
||||
<button disabled={selectedForLook.length < 2} className="w-full py-4 bg-indigo-600 text-white rounded-2xl font-black uppercase tracking-widest text-xs shadow-xl shadow-indigo-600/30 disabled:opacity-30 transition-all">
|
||||
Guardar Look
|
||||
</button>
|
||||
</form>
|
||||
</Card>
|
||||
|
||||
<div className="space-y-4">
|
||||
<p className="text-xs font-black uppercase opacity-50 tracking-widest px-2">Armário</p>
|
||||
<div className="grid grid-cols-4 gap-3 max-h-96 overflow-y-auto pr-2 custom-scrollbar">
|
||||
{activeClothes.map(c => (
|
||||
<button key={c.id} onClick={() => !selectedForLook.includes(c.id) && setSelectedForLook([...selectedForLook, c.id])} className={`relative rounded-xl overflow-hidden aspect-square border-2 transition-all ${selectedForLook.includes(c.id) ? 'border-indigo-600 scale-90' : 'border-transparent hover:border-indigo-200'}`}>
|
||||
<img src={c.imageUrl} className="w-full h-full object-cover" alt="" />
|
||||
{selectedForLook.includes(c.id) && <div className="absolute inset-0 bg-indigo-600/40 flex items-center justify-center text-white"><Check size={20} /></div>}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="lg:col-span-2 space-y-8">
|
||||
<h3 className="text-2xl font-black tracking-tighter flex items-center gap-3 px-2 text-inherit"><History className="text-gray-400" /> Histórico de Looks</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-8">
|
||||
{looks.map(look => (
|
||||
<Card key={look.id} className="p-8 group hover:shadow-2xl transition-all border-none shadow-md" darkMode={darkMode}>
|
||||
<div className="flex justify-between items-start mb-6">
|
||||
<div className="text-inherit">
|
||||
<h4 className="text-xl font-black tracking-tight">{look.name}</h4>
|
||||
<p className="text-[10px] opacity-40 font-bold uppercase tracking-widest">{look.items.length} Peças • {new Date(look.createdAt).toLocaleDateString()}</p>
|
||||
</div>
|
||||
<button onClick={() => deleteLook(look.id)} className="p-2 text-gray-300 hover:text-red-500 transition-colors"><Trash size={18} /></button>
|
||||
</div>
|
||||
<div className="flex -space-x-4">
|
||||
{look.items.map(itemId => {
|
||||
const item = clothes.find(c => c.id === itemId);
|
||||
return (
|
||||
<div key={itemId} className="w-20 h-20 rounded-2xl border-4 border-white dark:border-gray-800 overflow-hidden shadow-lg transform group-hover:rotate-6 transition-transform">
|
||||
<img src={item?.imageUrl} className="w-full h-full object-cover" alt="" />
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ADICIONAR / EDITAR */}
|
||||
{(view === 'add' || view === 'edit') && (
|
||||
<div className="max-w-4xl mx-auto animate-in zoom-in-95 duration-500">
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-12 items-start">
|
||||
<div className="space-y-8">
|
||||
<h3 className="text-5xl font-black tracking-tighter text-inherit">{editingItem ? 'Editar' : 'Novo Item'}</h3>
|
||||
<Card className="aspect-[3/4] overflow-hidden shadow-2xl relative" darkMode={darkMode}>
|
||||
{editingItem?.imageUrl || imageUrlDraft.startsWith('http') ? (
|
||||
<img src={imageUrlDraft || editingItem?.imageUrl} className="w-full h-full object-cover" alt="" />
|
||||
) : (
|
||||
<div className="h-full flex flex-col items-center justify-center opacity-10">
|
||||
<ImageIcon size={100} />
|
||||
<p className="font-black uppercase tracking-[0.5em] mt-6">Preview</p>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<Card className="p-10 shadow-2xl" darkMode={darkMode}>
|
||||
<form onSubmit={saveItem} className="space-y-8">
|
||||
<Input label="Nome" name="name" defaultValue={editingItem?.name} required />
|
||||
<div className="grid grid-cols-2 gap-6">
|
||||
<div className="space-y-2">
|
||||
<label className="text-[10px] font-black uppercase opacity-40 tracking-widest ml-1 text-inherit">Categoria</label>
|
||||
<select name="category" defaultValue={editingItem?.category || 'Tops'} className={`w-full p-5 rounded-2xl border-none outline-none focus:ring-4 focus:ring-indigo-500/10 font-bold ${darkMode ? 'bg-gray-700 text-white' : 'bg-gray-100'}`}>
|
||||
<option>Tops</option><option>Bottoms</option><option>Calçado</option><option>Casacos</option><option>Acessórios</option>
|
||||
</select>
|
||||
</div>
|
||||
<Input label="Cor" name="color" defaultValue={editingItem?.color} required />
|
||||
</div>
|
||||
<Input label="URL da Imagem" name="imageUrl" defaultValue={editingItem?.imageUrl} onChange={(v) => setImageUrlDraft(v)} />
|
||||
|
||||
<div className="flex gap-4 pt-6">
|
||||
<button type="button" onClick={() => { setEditingItem(null); setImageUrlDraft(''); setView('closet'); }} className="flex-1 font-black uppercase text-[10px] opacity-40 hover:opacity-100 tracking-widest transition-all text-inherit">Cancelar</button>
|
||||
<button type="submit" className="flex-1 py-5 bg-indigo-600 text-white rounded-[2rem] font-black uppercase tracking-widest text-[10px] shadow-2xl shadow-indigo-600/40 hover:scale-[1.02] active:scale-95 transition-all">
|
||||
{editingItem ? 'Guardar' : 'Registar'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* DEFINIÇÕES */}
|
||||
{view === 'settings' && (
|
||||
<div className="max-w-4xl mx-auto space-y-12 animate-in fade-in duration-700 pb-20">
|
||||
<Card className="p-10 border-indigo-100 relative overflow-hidden" darkMode={darkMode}>
|
||||
<div className="flex items-center gap-8 relative z-10 text-inherit">
|
||||
<div className="w-24 h-24 rounded-[2.5rem] bg-indigo-600 flex items-center justify-center text-white text-4xl font-black shadow-2xl">
|
||||
{user?.isAnonymous ? 'C' : user?.email?.[0]?.toUpperCase()}
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-3xl font-black tracking-tighter">{user?.isAnonymous ? 'Convidado' : 'A Tua Conta'}</h3>
|
||||
<p className="opacity-60 font-bold text-sm">{user?.email || 'Modo PAP'}</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between px-2 text-inherit">
|
||||
<h3 className="text-xl font-black text-red-500 flex items-center gap-3 tracking-widest uppercase"><Trash2 size={24} /> Reciclagem</h3>
|
||||
{trashClothes.length > 0 && <button onClick={emptyTrashPermanently} className="text-[10px] font-black text-red-500 uppercase tracking-widest hover:underline">Esvaziar</button>}
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{trashClothes.map(item => (
|
||||
<Card key={item.id} className="p-4 flex items-center gap-5 border-red-50" darkMode={darkMode}>
|
||||
<img src={item.imageUrl} className="w-16 h-16 rounded-2xl object-cover grayscale opacity-40" alt="" />
|
||||
<div className="flex-1 min-w-0 text-inherit">
|
||||
<p className="font-black text-sm truncate">{item.name}</p>
|
||||
<p className="text-[10px] font-black text-red-400 uppercase tracking-tighter">Eliminado</p>
|
||||
</div>
|
||||
<div className="flex gap-1">
|
||||
<button onClick={() => handleItemAction('restore', item)} className="p-3 text-indigo-600 hover:bg-indigo-50 rounded-2xl transition-all"><RotateCcw size={18} /></button>
|
||||
<button onClick={() => handleItemAction('delete', item.id)} className="p-3 text-red-600 hover:bg-red-50 rounded-2xl transition-all"><Trash size={18} /></button>
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Card className="p-10 border-red-200 bg-red-50/10" darkMode={darkMode}>
|
||||
<div className="flex flex-col md:flex-row items-center justify-between gap-8">
|
||||
<div className="text-inherit">
|
||||
<h4 className="text-xl font-black text-red-700 flex items-center gap-3"><ShieldAlert /> Zona Crítica</h4>
|
||||
<p className="opacity-60 font-bold text-sm mt-2">Ações de limpeza total do armário.</p>
|
||||
</div>
|
||||
<button onClick={clearAllToTrash} className="px-8 py-4 bg-red-600 text-white rounded-2xl font-black uppercase text-[10px] tracking-widest hover:bg-red-700 transition-all">Limpar Tudo</button>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
15
src/components/ui/Badge.jsx
Normal file
15
src/components/ui/Badge.jsx
Normal file
@@ -0,0 +1,15 @@
|
||||
import React from 'react';
|
||||
|
||||
export const Badge = ({ children, variant = "default" }) => {
|
||||
const styles = {
|
||||
default: "bg-indigo-100 text-indigo-700 dark:bg-indigo-900/30 dark:text-indigo-300",
|
||||
success: "bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-300",
|
||||
danger: "bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-300",
|
||||
warning: "bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-300"
|
||||
};
|
||||
return (
|
||||
<span className={`px-3 py-1 rounded-full text-[10px] font-black uppercase tracking-widest ${styles[variant]}`}>
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
10
src/components/ui/Card.jsx
Normal file
10
src/components/ui/Card.jsx
Normal file
@@ -0,0 +1,10 @@
|
||||
import React from 'react';
|
||||
|
||||
export const Card = ({ children, className = "", darkMode }) => (
|
||||
<div className={`rounded-[2rem] border transition-all duration-300 ${darkMode
|
||||
? 'bg-gray-800/40 border-gray-700/50 backdrop-blur-md'
|
||||
: 'bg-white/80 border-gray-200/50 backdrop-blur-md shadow-sm'
|
||||
} ${className}`}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
21
src/components/ui/Input.jsx
Normal file
21
src/components/ui/Input.jsx
Normal file
@@ -0,0 +1,21 @@
|
||||
import React from 'react';
|
||||
|
||||
export const Input = ({ label, type = 'text', value, onChange, placeholder, required = false, name, minLength, defaultValue }) => {
|
||||
const isControlled = value !== undefined;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-1 w-full">
|
||||
{label && <label className="text-sm font-black uppercase tracking-widest opacity-40 ml-1">{label}</label>}
|
||||
<input
|
||||
type={type}
|
||||
name={name}
|
||||
{...(isControlled ? { value } : { defaultValue })}
|
||||
onChange={(e) => onChange ? onChange(e.target.value) : null}
|
||||
placeholder={placeholder}
|
||||
required={required}
|
||||
minLength={minLength}
|
||||
className="w-full p-5 rounded-2xl border-none outline-none focus:ring-4 focus:ring-indigo-500/10 font-bold bg-gray-100 dark:bg-gray-700 text-inherit transition-all"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
18
src/index.css
Normal file
18
src/index.css
Normal file
@@ -0,0 +1,18 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
.custom-scrollbar::-webkit-scrollbar {
|
||||
height: 6px;
|
||||
width: 6px;
|
||||
}
|
||||
.custom-scrollbar::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
.custom-scrollbar::-webkit-scrollbar-thumb {
|
||||
background: rgba(156, 163, 175, 0.3);
|
||||
border-radius: 10px;
|
||||
}
|
||||
.custom-scrollbar::-webkit-scrollbar-thumb:hover {
|
||||
background: rgba(156, 163, 175, 0.5);
|
||||
}
|
||||
20
src/lib/firebase.js
Normal file
20
src/lib/firebase.js
Normal file
@@ -0,0 +1,20 @@
|
||||
import { initializeApp } from 'firebase/app';
|
||||
import { getAuth } from 'firebase/auth';
|
||||
import { getFirestore } from 'firebase/firestore';
|
||||
|
||||
const firebaseConfig = {
|
||||
apiKey: import.meta.env.VITE_FIREBASE_API_KEY,
|
||||
authDomain: import.meta.env.VITE_FIREBASE_AUTH_DOMAIN,
|
||||
projectId: import.meta.env.VITE_FIREBASE_PROJECT_ID,
|
||||
storageBucket: import.meta.env.VITE_FIREBASE_STORAGE_BUCKET,
|
||||
messagingSenderId: import.meta.env.VITE_FIREBASE_MESSAGING_SENDER_ID,
|
||||
appId: import.meta.env.VITE_FIREBASE_APP_ID
|
||||
};
|
||||
|
||||
const app = initializeApp(firebaseConfig);
|
||||
const auth = getAuth(app);
|
||||
const db = getFirestore(app);
|
||||
|
||||
const appId = import.meta.env.VITE_APP_ID || 'my-closet-app';
|
||||
|
||||
export { app, auth, db, appId };
|
||||
10
src/main.jsx
Normal file
10
src/main.jsx
Normal file
@@ -0,0 +1,10 @@
|
||||
import React from 'react'
|
||||
import ReactDOM from 'react-dom/client'
|
||||
import App from './App.jsx'
|
||||
import './index.css'
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')).render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>,
|
||||
)
|
||||
12
tailwind.config.js
Normal file
12
tailwind.config.js
Normal file
@@ -0,0 +1,12 @@
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
export default {
|
||||
content: [
|
||||
"./index.html",
|
||||
"./src/**/*.{js,ts,jsx,tsx}",
|
||||
],
|
||||
darkMode: 'class',
|
||||
theme: {
|
||||
extend: {},
|
||||
},
|
||||
plugins: [],
|
||||
}
|
||||
7
vite.config.js
Normal file
7
vite.config.js
Normal file
@@ -0,0 +1,7 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
|
||||
// https://vitejs.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
})
|
||||
Reference in New Issue
Block a user