Compare commits
	
		
			5 commits
		
	
	
		
			36b1092025
			...
			d2c44f8386
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
|   | d2c44f8386 | ||
|   | cfe7d840d6 | ||
|   | 54d4faca9d | ||
|   | 3ea07942c6 | ||
|   | 04267b3886 | 
					 103 changed files with 16641 additions and 2 deletions
				
			
		
							
								
								
									
										117
									
								
								README.md
									
										
									
									
									
								
							
							
						
						
									
										117
									
								
								README.md
									
										
									
									
									
								
							|  | @ -1,3 +1,116 @@ | |||
| # inventario | ||||
| # Hackerspace Inventory | ||||
| 
 | ||||
| Teniamo sotto controllo cos'abbiamo :) | ||||
| Full‑stack application for managing a small makerspace / hackerspace inventory. | ||||
| **Frontend:** Vite + React + TypeScript | **Backend:** Node 20 + Express + TypeScript | **Database:** MongoDB 6+ (Atlas or local)  | **Auth:** JWT (Bcrypt‑hashed passwords) | ||||
| 
 | ||||
| --- | ||||
| 
 | ||||
| ## ✨ Features | ||||
| 
 | ||||
| * **Item & Tag CRUD** – create, search, delete inventory items and their tags | ||||
| * **JWT authentication** – register, login, protected routes & actions | ||||
| * **Instant search** – query + tag filter (`/api/items/search`) | ||||
| * **Typed everywhere** – shared TypeScript types for client & server | ||||
| * **Docker‑ready** – multistage image for the production SPA (served by Nginx) | ||||
| 
 | ||||
| --- | ||||
| 
 | ||||
| ## 🗂️ Project Structure (monorepo) | ||||
| 
 | ||||
| ``` | ||||
| . | ||||
| ├── backend                # Express + TS + Mongoose | ||||
| │   ├── src | ||||
| │   │   ├── models/*.ts | ||||
| │   │   ├── routes/*.ts | ||||
| │   │   ├── middleware/auth.ts | ||||
| │   │   └── index.ts | ||||
| │   └── .env.example | ||||
| ├── frontend               # Vite + React + TS | ||||
| │   ├── src | ||||
| │   │   ├── services/inventoryService.ts | ||||
| │   │   ├── contexts/AuthProvider.tsx | ||||
| │   │   └── … | ||||
| │   ├── Dockerfile         # multistage build → Nginx | ||||
| └── README.md | ||||
| ``` | ||||
| 
 | ||||
| --- | ||||
| 
 | ||||
| ## 🏃♂️ Quick Start (local dev) | ||||
| 
 | ||||
| ### 1. Prerequisites | ||||
| 
 | ||||
| * Node **≥ 20** | ||||
| * MongoDB **≥ 6.0** (local or Atlas URI) | ||||
| * npm / pnpm / yarn | ||||
| 
 | ||||
| --- | ||||
| 
 | ||||
| ## 🐳 Docker (production SPA) | ||||
| 
 | ||||
| From `frontend/`: | ||||
| 
 | ||||
| ```bash | ||||
| docker build -t hackerspace-frontend . | ||||
| docker run -p 8080:80 hackerspace-frontend | ||||
| ``` | ||||
| 
 | ||||
| The built static site is served by Nginx at **[http://localhost:8080](http://localhost:8080)**. | ||||
| 
 | ||||
| > For a full stack containerised setup you can add a docker‑compose.yml with services for MongoDB, backend and frontend. | ||||
| 
 | ||||
| --- | ||||
| 
 | ||||
| ## 🔑 Environment Variables | ||||
| 
 | ||||
| | Variable       | Location        | Description                                                                                      | | ||||
| | -------------- | --------------- | ------------------------------------------------------------------------------------------------ | | ||||
| | `MONGODB_URI`  | backend `.env`  | Connection string to MongoDB                                                                     | | ||||
| | `JWT_SECRET`   | backend `.env`  | Secret key for signing JWTs                                                                      | | ||||
| | `PORT`         | backend `.env`  | Express port (default **3000**)                                                                  | | ||||
| | `VITE_API_URL` | frontend `.env` | Base URL of the backend API (default **[http://localhost:6789/api](http://localhost:6789/api)**) | | ||||
| 
 | ||||
| --- | ||||
| 
 | ||||
| ## 📚 API Reference | ||||
| 
 | ||||
| > All endpoints are prefixed with **/api/** | ||||
| 
 | ||||
| ### Auth | ||||
| 
 | ||||
| | Method | Endpoint         | Body                        | Response          | | ||||
| | ------ | ---------------- | --------------------------- | ----------------- | | ||||
| | POST   | `/auth/register` | `{ email, password, name }` | `{ token, user }` | | ||||
| | POST   | `/auth/login`    | `{ email, password }`       | `{ token, user }` | | ||||
| 
 | ||||
| ### Tags (protected *create/delete*) | ||||
| 
 | ||||
| | Method | Endpoint    | Description              | | ||||
| | ------ | ----------- | ------------------------ | | ||||
| | GET    | `/tags`     | list all tags            | | ||||
| | POST   | `/tags`     | create `{ name, color }` | | ||||
| | DELETE | `/tags/:id` | delete tag               | | ||||
| 
 | ||||
| ### Items (protected *create/delete*) | ||||
| 
 | ||||
| | Method | Endpoint                       | Description                              | | ||||
| | ------ | ------------------------------ | ---------------------------------------- | | ||||
| | GET    | `/items`                       | list items (populated tags)              | | ||||
| | GET    | `/items/search?query=&tagIds=` | search items                             | | ||||
| | POST   | `/items`                       | create `{ name, description, tagIds[] }` | | ||||
| | DELETE | `/items/:id`                   | delete item                              | | ||||
| 
 | ||||
| Add header `Authorization: Bearer <JWT>` for protected calls. | ||||
| 
 | ||||
| --- | ||||
| 
 | ||||
| ## 🛠️ Useful npm scripts | ||||
| 
 | ||||
| | Backend         | Frontend        | Description                                   | | ||||
| | --------------- | --------------- | --------------------------------------------- | | ||||
| | `npm run dev`   | `npm run dev`   | Hot‑reload development mode                   | | ||||
| | `npm run build` | `npm run build` | TypeScript → dist / Vite production bundle    | | ||||
| | `npm start`     | –               | Start compiled backend (`node dist/index.js`) | | ||||
| 
 | ||||
| --- | ||||
							
								
								
									
										3
									
								
								backend/.env.example
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										3
									
								
								backend/.env.example
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,3 @@ | |||
| MONGODB_URI=mongodb://mongo:27017/inventory | ||||
| JWT_SECRET=Example | ||||
| PORT=6789 | ||||
							
								
								
									
										3
									
								
								backend/.gitignore
									
										
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						
									
										3
									
								
								backend/.gitignore
									
										
									
									
										vendored
									
									
										Normal file
									
								
							|  | @ -0,0 +1,3 @@ | |||
| .env | ||||
| node_modules/ | ||||
| dist/ | ||||
							
								
								
									
										26
									
								
								backend/Dockerfile
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										26
									
								
								backend/Dockerfile
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,26 @@ | |||
| # -------- fase build -------- | ||||
| FROM node:20-alpine AS builder | ||||
| WORKDIR /app | ||||
| 
 | ||||
| COPY package*.json ./ | ||||
| RUN npm ci | ||||
| 
 | ||||
| COPY tsconfig.json ./tsconfig.json | ||||
| COPY src ./src | ||||
| RUN npm run build            # genera /app/dist | ||||
| 
 | ||||
| # -------- fase runtime ------ | ||||
| FROM node:20-alpine | ||||
| WORKDIR /app | ||||
| ENV NODE_ENV=production | ||||
| 
 | ||||
| # installa solo dipendenze runtime | ||||
| COPY --from=builder /app/package*.json ./ | ||||
| RUN npm ci --omit=dev | ||||
| 
 | ||||
| # copia codice compilato e variabili | ||||
| COPY --from=builder /app/dist ./dist | ||||
| COPY .env .env | ||||
| 
 | ||||
| EXPOSE 6789 | ||||
| CMD ["node", "dist/index.js"] | ||||
							
								
								
									
										2100
									
								
								backend/package-lock.json
									
										
									
										generated
									
									
									
										Normal file
									
								
							
							
						
						
									
										2100
									
								
								backend/package-lock.json
									
										
									
										generated
									
									
									
										Normal file
									
								
							
										
											
												File diff suppressed because it is too large
												Load diff
											
										
									
								
							
							
								
								
									
										27
									
								
								backend/package.json
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										27
									
								
								backend/package.json
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,27 @@ | |||
| { | ||||
|     "name": "inventory-backend-ts", | ||||
|     "version": "1.0.0", | ||||
|     "type": "module", | ||||
|     "scripts": { | ||||
|         "dev": "ts-node-dev --respawn src/index.ts", | ||||
|         "build": "tsc", | ||||
|         "start": "node dist/index.js" | ||||
|     }, | ||||
|     "dependencies": { | ||||
|         "bcrypt": "^6.0.0", | ||||
|         "cors": "^2.8.5", | ||||
|         "dotenv": "^17.0.0", | ||||
|         "express": "^5.1.0", | ||||
|         "jsonwebtoken": "^9.0.2", | ||||
|         "mongoose": "^8.16.1" | ||||
|     }, | ||||
|     "devDependencies": { | ||||
|         "@types/bcrypt": "^5.0.2", | ||||
|         "@types/cors": "^2.8.19", | ||||
|         "@types/express": "^5.0.3", | ||||
|         "@types/jsonwebtoken": "^9.0.10", | ||||
|         "@types/node": "^24.0.7", | ||||
|         "ts-node-dev": "^2.0.0", | ||||
|         "typescript": "^5.8.3" | ||||
|     } | ||||
| } | ||||
							
								
								
									
										28
									
								
								backend/src/index.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										28
									
								
								backend/src/index.ts
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,28 @@ | |||
| import express from 'express'; | ||||
| import mongoose from 'mongoose'; | ||||
| import cors from 'cors'; | ||||
| import dotenv from 'dotenv'; | ||||
| dotenv.config(); | ||||
| 
 | ||||
| import authRoutes from './routes/auth.js'; | ||||
| import tagRoutes  from './routes/tags.js'; | ||||
| import itemRoutes from './routes/items.js'; | ||||
| import authMiddleware from './middleware/auth.js'; | ||||
| 
 | ||||
| const app  = express(); | ||||
| const PORT = process.env.PORT || 3000; | ||||
| 
 | ||||
| app.use(cors()); | ||||
| app.use(express.json()); | ||||
| 
 | ||||
| app.use('/api/auth', authRoutes); | ||||
| 
 | ||||
| app.use('/api/tags', tagRoutes); | ||||
| app.use('/api/items', itemRoutes); | ||||
| 
 | ||||
| mongoose | ||||
|   .connect(process.env.MONGODB_URI!) | ||||
|   .then(() => { | ||||
|     app.listen(PORT, () => console.log(`✅ Server avviato su http://localhost:${PORT}`)); | ||||
|   }) | ||||
|   .catch(err => console.error('❌ Connessione MongoDB fallita:', err.message)); | ||||
							
								
								
									
										31
									
								
								backend/src/middleware/auth.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										31
									
								
								backend/src/middleware/auth.ts
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,31 @@ | |||
| import { RequestHandler } from 'express'; | ||||
| import jwt from 'jsonwebtoken'; | ||||
| import dotenv from 'dotenv'; | ||||
| dotenv.config(); | ||||
| 
 | ||||
| export interface JwtPayload { id: string; email: string; } | ||||
| 
 | ||||
| declare module 'express-serve-static-core' { | ||||
|   interface Request { | ||||
|     user?: JwtPayload; | ||||
|   } | ||||
| } | ||||
| 
 | ||||
| const auth: RequestHandler = (req, res, next) => { | ||||
|   const header = req.headers.authorization; | ||||
|   const token  = header?.split(' ')[1]; | ||||
| 
 | ||||
|   if (!token) { | ||||
|     res.status(401).json({ message: 'Token mancante' }); | ||||
|     return;                    // ← niente valore
 | ||||
|   } | ||||
| 
 | ||||
|   try { | ||||
|     req.user = jwt.verify(token, process.env.JWT_SECRET!) as JwtPayload; | ||||
|     next();                    // anche qui il valore è void
 | ||||
|   } catch { | ||||
|     res.status(401).json({ message: 'Token non valido' }); | ||||
|   } | ||||
| }; | ||||
| 
 | ||||
| export default auth; | ||||
							
								
								
									
										22
									
								
								backend/src/models/Item.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										22
									
								
								backend/src/models/Item.ts
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,22 @@ | |||
| import { Schema, model, Document, Types } from 'mongoose'; | ||||
| import { ITag } from './Tag.js'; | ||||
| 
 | ||||
| export interface IItem extends Document { | ||||
|   name:        string; | ||||
|   description: string; | ||||
|   tags:        Types.Array<ITag['_id']>; | ||||
|   quantity:    number;           // ← nuovo
 | ||||
|   dateAdded:   Date; | ||||
|   addedBy:     string; | ||||
| } | ||||
| 
 | ||||
| const itemSchema = new Schema<IItem>({ | ||||
|   name:        { type: String, required: true }, | ||||
|   description: { type: String, required: true }, | ||||
|   tags:        [{ type: Schema.Types.ObjectId, ref: 'Tag' }], | ||||
|   quantity:    { type: Number, default: 0, min: 0 },   // default 0, mai negativo
 | ||||
|   dateAdded:   { type: Date, default: Date.now }, | ||||
|   addedBy:     { type: String }, | ||||
| }); | ||||
| 
 | ||||
| export default model<IItem>('Item', itemSchema); | ||||
							
								
								
									
										13
									
								
								backend/src/models/Tag.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										13
									
								
								backend/src/models/Tag.ts
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,13 @@ | |||
| import { Schema, model, Document } from 'mongoose'; | ||||
| 
 | ||||
| export interface ITag extends Document { | ||||
|   name: string; | ||||
|   color: string; | ||||
| } | ||||
| 
 | ||||
| const tagSchema = new Schema<ITag>({ | ||||
|   name:  { type: String, required: true }, | ||||
|   color: { type: String, required: true } | ||||
| }); | ||||
| 
 | ||||
| export default model<ITag>('Tag', tagSchema); | ||||
							
								
								
									
										26
									
								
								backend/src/models/User.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										26
									
								
								backend/src/models/User.ts
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,26 @@ | |||
| import { Schema, model, Document } from 'mongoose'; | ||||
| import bcrypt from 'bcrypt'; | ||||
| 
 | ||||
| export interface IUser extends Document { | ||||
|   email: string; | ||||
|   name: string; | ||||
|   password: string; | ||||
|   comparePassword(candidate: string): Promise<boolean>; | ||||
| } | ||||
| 
 | ||||
| const userSchema = new Schema<IUser>({ | ||||
|   email:    { type: String, required: true, unique: true }, | ||||
|   name:     { type: String, required: true }, | ||||
|   password: { type: String, required: true } | ||||
| }); | ||||
| 
 | ||||
| userSchema.pre('save', async function () { | ||||
|   if (!this.isModified('password')) return; | ||||
|   this.password = await bcrypt.hash(this.password, 12); | ||||
| }); | ||||
| 
 | ||||
| userSchema.methods.comparePassword = function (candidate: string) { | ||||
|   return bcrypt.compare(candidate, this.password); | ||||
| }; | ||||
| 
 | ||||
| export default model<IUser>('User', userSchema); | ||||
							
								
								
									
										29
									
								
								backend/src/routes/auth.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										29
									
								
								backend/src/routes/auth.ts
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,29 @@ | |||
| import { Router } from 'express'; | ||||
| import jwt from 'jsonwebtoken'; | ||||
| import User, { IUser } from '../models/User.js'; | ||||
| const router = Router(); | ||||
| 
 | ||||
| router.post('/register', async (req, res) => { | ||||
|   try { | ||||
|     const { email, password, name } = req.body as IUser; | ||||
|     const user = await User.create({ email, password, name }); | ||||
|     const token = jwt.sign({ id: user.id, email }, process.env.JWT_SECRET!); | ||||
|     res.json({ token, user: { id: user.id, email, name } }); | ||||
|   } catch (err: any) { | ||||
|     res.status(400).json({ message: 'Registrazione fallita', error: err.message }); | ||||
|   } | ||||
| }); | ||||
| 
 | ||||
| router.post('/login', async (req, res) => { | ||||
|   const { email, password } = req.body as IUser; | ||||
|   const user = await User.findOne({ email }); | ||||
| 
 | ||||
|   if (!user || !(await user.comparePassword(password))) { | ||||
|     res.status(401).json({ message: 'Credenziali non valide' }); | ||||
|     return;               // ← esce senza value → tipo void
 | ||||
|   } | ||||
| 
 | ||||
|   const token = jwt.sign({ id: user.id, email }, process.env.JWT_SECRET!); | ||||
|   res.json({ token, user: { id: user.id, email, name: user.name } }); | ||||
| }); | ||||
| export default router; | ||||
							
								
								
									
										111
									
								
								backend/src/routes/items.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										111
									
								
								backend/src/routes/items.ts
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,111 @@ | |||
| import { Router } from 'express'; | ||||
| import Item from '../models/Item.js'; | ||||
| import Tag  from '../models/Tag.js'; | ||||
| import auth from '../middleware/auth.js'; | ||||
| 
 | ||||
| const router = Router(); | ||||
| 
 | ||||
| /* ──────────── GET: lista completa ──────────── */ | ||||
| router.get('/', async (_req, res) => { | ||||
|   const items = await Item.find().populate('tags'); | ||||
|   res.json(items); | ||||
| }); | ||||
| 
 | ||||
| /* ──────────── GET: ricerca ──────────── */ | ||||
| router.get('/search', async (req, res) => { | ||||
|   const { query = '', tagIds = '' } = req.query as { | ||||
|     query?: string; | ||||
|     tagIds?: string; | ||||
|   }; | ||||
| 
 | ||||
|   const regex  = new RegExp(query, 'i'); | ||||
|   const filter: any = { | ||||
|     $and: [ | ||||
|       { $or: [{ name: regex }, { description: regex }] }, | ||||
|       tagIds ? { tags: { $in: tagIds.split(',') } } : {}, | ||||
|     ], | ||||
|   }; | ||||
| 
 | ||||
|   res.json(await Item.find(filter).populate('tags')); | ||||
| }); | ||||
| 
 | ||||
| /* ──────────── POST: crea item ──────────── */ | ||||
| router.post('/', auth, async (req, res) => { | ||||
|   const { name, description, tagIds, quantity = 0 } = req.body as { | ||||
|     name: string; | ||||
|     description: string; | ||||
|     tagIds: string[]; | ||||
|     quantity?: number; | ||||
|   }; | ||||
| 
 | ||||
|   const tags = await Tag.find({ _id: { $in: tagIds } }); | ||||
|   const item = await Item.create({ | ||||
|     name, | ||||
|     description, | ||||
|     tags, | ||||
|     quantity, | ||||
|     addedBy: req.user!.email, | ||||
|   }); | ||||
| 
 | ||||
|   res.status(201).json(await item.populate('tags')); | ||||
| }); | ||||
| 
 | ||||
| /* ──────────── PUT: aggiorna intero item ──────────── */ | ||||
| router.put('/:id', auth, async (req, res) => { | ||||
|   const { id } = req.params; | ||||
|   const { name, description, tagIds, quantity } = req.body as { | ||||
|     name?: string; | ||||
|     description?: string; | ||||
|     tagIds?: string[]; | ||||
|     quantity?: number; | ||||
|   }; | ||||
| 
 | ||||
|   const update: any = {}; | ||||
|   if (name !== undefined)        update.name        = name; | ||||
|   if (description !== undefined) update.description = description; | ||||
|   if (quantity !== undefined)    update.quantity    = quantity; | ||||
|   if (tagIds)                    update.tags = await Tag.find({ _id: { $in: tagIds } }); | ||||
| 
 | ||||
|   const item = await Item.findByIdAndUpdate(id, update, { new: true }).populate('tags'); | ||||
|   if (!item) { | ||||
|     res.status(404).json({ message: 'Item non trovato' }); | ||||
|     return; | ||||
|   } | ||||
|   res.json(item); | ||||
| }); | ||||
| 
 | ||||
| /* ──────────── PATCH: modifica solo la quantità ──────────── */ | ||||
| router.patch('/:id/quantity', auth, async (req, res) => { | ||||
|   const { id } = req.params; | ||||
|   const { quantity } = req.body as { quantity: number }; | ||||
| 
 | ||||
|   if (quantity < 0) { | ||||
|     res.status(400).json({ message: 'La quantità non può essere negativa' }); | ||||
|     return; | ||||
|   } | ||||
| 
 | ||||
|   const item = await Item.findByIdAndUpdate( | ||||
|     id, | ||||
|     { quantity }, | ||||
|     { new: true } | ||||
|   ).populate('tags'); | ||||
| 
 | ||||
|   if (!item) { | ||||
|     res.status(404).json({ message: 'Item non trovato' }); | ||||
|     return; | ||||
|   } | ||||
|   res.json(item); | ||||
| }); | ||||
| 
 | ||||
| /* ──────────── DELETE: elimina item ──────────── */ | ||||
| router.delete('/:id', auth, async (req, res) => { | ||||
|   const { id } = req.params; | ||||
|   const deleted = await Item.findByIdAndDelete(id); | ||||
|   if (!deleted) { | ||||
|     res.status(404).json({ message: 'Item non trovato' }); | ||||
|     return; | ||||
|   } | ||||
|   res.status(204).end(); | ||||
| }); | ||||
| 
 | ||||
| export default router; | ||||
							
								
								
									
										40
									
								
								backend/src/routes/tags.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										40
									
								
								backend/src/routes/tags.ts
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,40 @@ | |||
| import { Router } from 'express'; | ||||
| import Tag from '../models/Tag.js'; | ||||
| import Item from '../models/Item.js'; | ||||
| import auth from '../middleware/auth.js'; | ||||
| 
 | ||||
| const router = Router(); | ||||
| 
 | ||||
| /* ──────────── GET: tutti i tag ──────────── */ | ||||
| router.get('/', async (_req, res) => { | ||||
|   res.json(await Tag.find()); | ||||
| }); | ||||
| 
 | ||||
| /* ──────────── POST: crea tag (protetto) ──────────── */ | ||||
| router.post('/', auth, async (req, res) => { | ||||
|   const { name, color } = req.body as { name: string; color: string }; | ||||
|   const tag = await Tag.create({ name, color }); | ||||
|   res.status(201).json(tag); | ||||
| }); | ||||
| 
 | ||||
| /* ──────────── DELETE: elimina tag (protetto) ──────────── */ | ||||
| router.delete('/:id', auth, async (req, res) => { | ||||
|   const { id } = req.params; | ||||
| 
 | ||||
|   // opzionale: blocca l’eliminazione se il tag è ancora assegnato a qualche item
 | ||||
|   const inUse = await Item.exists({ tags: id }); | ||||
|   if (inUse) { | ||||
|     res.status(409).json({ message: 'Tag in uso: rimuovilo dagli item prima di cancellarlo' }); | ||||
|     return; | ||||
|   } | ||||
| 
 | ||||
|   const deleted = await Tag.findByIdAndDelete(id); | ||||
|   if (!deleted) { | ||||
|     res.status(404).json({ message: 'Tag non trovato' }); | ||||
|     return; | ||||
|   } | ||||
| 
 | ||||
|   res.status(204).end(); | ||||
| }); | ||||
| 
 | ||||
| export default router; | ||||
							
								
								
									
										14
									
								
								backend/tsconfig.json
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										14
									
								
								backend/tsconfig.json
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,14 @@ | |||
| { | ||||
|   "compilerOptions": { | ||||
|     "target": "ES2020", | ||||
|     "module": "ESNext", | ||||
|     "moduleResolution": "Node", | ||||
|     "esModuleInterop": true, | ||||
|     "forceConsistentCasingInFileNames": true, | ||||
|     "strict": true, | ||||
|     "skipLibCheck": true, | ||||
|     "outDir": "dist", | ||||
|     "rootDir": "src" | ||||
|   }, | ||||
|   "include": ["src/**/*.ts"] | ||||
| } | ||||
							
								
								
									
										32
									
								
								docker-compose.yaml
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										32
									
								
								docker-compose.yaml
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,32 @@ | |||
| services: | ||||
|   # MongoDB | ||||
|   mongo: | ||||
|     image: mongo:7 | ||||
|     restart: unless-stopped | ||||
|     environment: | ||||
|       MONGO_INITDB_DATABASE: inventory | ||||
|     volumes: | ||||
|       - mongo_data:/data/db | ||||
|     ports: | ||||
|       - "27017:27017" | ||||
| 
 | ||||
|   # Backend TypeScript | ||||
|   backend: | ||||
|     build: ./backend | ||||
|     env_file: ./backend/.env | ||||
|     depends_on: | ||||
|       - mongo | ||||
|     ports: | ||||
|       - "6789:6789" | ||||
| 
 | ||||
|   # Front-end Vite statico | ||||
|   frontend: | ||||
|     build: ./frontend | ||||
|     depends_on: | ||||
|       - backend | ||||
|     ports: | ||||
|       - "80:80" | ||||
|        | ||||
| 
 | ||||
| volumes: | ||||
|   mongo_data: | ||||
							
								
								
									
										28
									
								
								frontend/.gitignore
									
										
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						
									
										28
									
								
								frontend/.gitignore
									
										
									
									
										vendored
									
									
										Normal file
									
								
							|  | @ -0,0 +1,28 @@ | |||
| # Logs | ||||
| logs | ||||
| *.log | ||||
| npm-debug.log* | ||||
| yarn-debug.log* | ||||
| yarn-error.log* | ||||
| pnpm-debug.log* | ||||
| lerna-debug.log* | ||||
| 
 | ||||
| node_modules | ||||
| dist | ||||
| dist-ssr | ||||
| *.local | ||||
| 
 | ||||
| # Editor directories and files | ||||
| .vscode/* | ||||
| !.vscode/extensions.json | ||||
| .idea | ||||
| .DS_Store | ||||
| *.suo | ||||
| *.ntvs* | ||||
| *.njsproj | ||||
| *.sln | ||||
| *.sw? | ||||
| 
 | ||||
| .git | ||||
| 
 | ||||
| .env | ||||
							
								
								
									
										40
									
								
								frontend/Dockerfile
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										40
									
								
								frontend/Dockerfile
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,40 @@ | |||
| ######################## | ||||
| # 1) Build stage | ||||
| ######################## | ||||
| FROM node:20-alpine AS builder | ||||
| 
 | ||||
| # cartella di lavoro | ||||
| WORKDIR /app | ||||
| 
 | ||||
| # dipendenze | ||||
| COPY package*.json ./ | ||||
| RUN npm ci         # usa pnpm/yarn se preferisci | ||||
| 
 | ||||
| # sorgenti | ||||
| COPY . . | ||||
| 
 | ||||
| # build ottimizzata in /dist | ||||
| RUN npm run build | ||||
| 
 | ||||
| 
 | ||||
| ######################## | ||||
| # 2) Runtime stage | ||||
| ######################## | ||||
| FROM nginx:alpine AS runner | ||||
| 
 | ||||
| # copia i file statici in Nginx | ||||
| COPY --from=builder /app/dist /usr/share/nginx/html | ||||
| 
 | ||||
| # configurazione SPA: se richiedi /qualunque/percorso restituisce index.html | ||||
| RUN printf 'server {\n\ | ||||
|   listen 80;\n\ | ||||
|   server_name _;\n\ | ||||
|   root /usr/share/nginx/html;\n\ | ||||
|   include /etc/nginx/mime.types;\n\ | ||||
|   location / {\n\ | ||||
|     try_files $uri $uri/ /index.html;\n\ | ||||
|   }\n\ | ||||
| }\n' > /etc/nginx/conf.d/default.conf | ||||
| 
 | ||||
| EXPOSE 80 | ||||
| CMD ["nginx", "-g", "daemon off;"] | ||||
							
								
								
									
										
											BIN
										
									
								
								frontend/bun.lockb
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										
											BIN
										
									
								
								frontend/bun.lockb
									
										
									
									
									
										Normal file
									
								
							
										
											Binary file not shown.
										
									
								
							
							
								
								
									
										20
									
								
								frontend/components.json
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										20
									
								
								frontend/components.json
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,20 @@ | |||
| { | ||||
|   "$schema": "https://ui.shadcn.com/schema.json", | ||||
|   "style": "default", | ||||
|   "rsc": false, | ||||
|   "tsx": true, | ||||
|   "tailwind": { | ||||
|     "config": "tailwind.config.ts", | ||||
|     "css": "src/index.css", | ||||
|     "baseColor": "slate", | ||||
|     "cssVariables": true, | ||||
|     "prefix": "" | ||||
|   }, | ||||
|   "aliases": { | ||||
|     "components": "@/components", | ||||
|     "utils": "@/lib/utils", | ||||
|     "ui": "@/components/ui", | ||||
|     "lib": "@/lib", | ||||
|     "hooks": "@/hooks" | ||||
|   } | ||||
| } | ||||
							
								
								
									
										29
									
								
								frontend/eslint.config.js
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										29
									
								
								frontend/eslint.config.js
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,29 @@ | |||
| import js from "@eslint/js"; | ||||
| import globals from "globals"; | ||||
| import reactHooks from "eslint-plugin-react-hooks"; | ||||
| import reactRefresh from "eslint-plugin-react-refresh"; | ||||
| import tseslint from "typescript-eslint"; | ||||
| 
 | ||||
| export default tseslint.config( | ||||
|   { ignores: ["dist"] }, | ||||
|   { | ||||
|     extends: [js.configs.recommended, ...tseslint.configs.recommended], | ||||
|     files: ["**/*.{ts,tsx}"], | ||||
|     languageOptions: { | ||||
|       ecmaVersion: 2020, | ||||
|       globals: globals.browser, | ||||
|     }, | ||||
|     plugins: { | ||||
|       "react-hooks": reactHooks, | ||||
|       "react-refresh": reactRefresh, | ||||
|     }, | ||||
|     rules: { | ||||
|       ...reactHooks.configs.recommended.rules, | ||||
|       "react-refresh/only-export-components": [ | ||||
|         "warn", | ||||
|         { allowConstantExport: true }, | ||||
|       ], | ||||
|       "@typescript-eslint/no-unused-vars": "off", | ||||
|     }, | ||||
|   } | ||||
| ); | ||||
							
								
								
									
										24
									
								
								frontend/index.html
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										24
									
								
								frontend/index.html
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,24 @@ | |||
| <!DOCTYPE html> | ||||
| <html lang="en"> | ||||
|   <head> | ||||
|     <meta charset="UTF-8" /> | ||||
|     <meta name="viewport" content="width=device-width, initial-scale=1.0" /> | ||||
|     <title>Hackinpovo inventory</title> | ||||
|     <meta name="description" content="App to manage Hackerspace inventory" /> | ||||
|     <meta name="author" content="Lievito" /> | ||||
| 
 | ||||
|     <meta property="og:title" content="Hackinpovo inventory" /> | ||||
|     <meta property="og:description" content="App to manage Hackerspace inventory" /> | ||||
|     <!-- <meta property="og:type" content="website" /> | ||||
|     <meta property="og:image" content="https://lovable.dev/opengraph-image-p98pqg.png" /> | ||||
| 
 | ||||
|     <meta name="twitter:card" content="summary_large_image" /> | ||||
|     <meta name="twitter:site" content="@lovable_dev" /> | ||||
|     <meta name="twitter:image" content="https://lovable.dev/opengraph-image-p98pqg.png" /> --> | ||||
|   </head> | ||||
| 
 | ||||
|   <body> | ||||
|     <div id="root"></div> | ||||
|     <script type="module" src="/src/main.tsx"></script> | ||||
|   </body> | ||||
| </html> | ||||
							
								
								
									
										6859
									
								
								frontend/package-lock.json
									
										
									
										generated
									
									
									
										Normal file
									
								
							
							
						
						
									
										6859
									
								
								frontend/package-lock.json
									
										
									
										generated
									
									
									
										Normal file
									
								
							
										
											
												File diff suppressed because it is too large
												Load diff
											
										
									
								
							
							
								
								
									
										83
									
								
								frontend/package.json
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										83
									
								
								frontend/package.json
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,83 @@ | |||
| { | ||||
|   "name": "hackinpovo-inventory", | ||||
|   "private": true, | ||||
|   "version": "0.0.0", | ||||
|   "type": "module", | ||||
|   "scripts": { | ||||
|     "dev": "vite", | ||||
|     "build": "vite build", | ||||
|     "build:dev": "vite build --mode development", | ||||
|     "lint": "eslint .", | ||||
|     "preview": "vite preview" | ||||
|   }, | ||||
|   "dependencies": { | ||||
|     "@hookform/resolvers": "^3.9.0", | ||||
|     "@radix-ui/react-accordion": "^1.2.0", | ||||
|     "@radix-ui/react-alert-dialog": "^1.1.1", | ||||
|     "@radix-ui/react-aspect-ratio": "^1.1.0", | ||||
|     "@radix-ui/react-avatar": "^1.1.0", | ||||
|     "@radix-ui/react-checkbox": "^1.1.1", | ||||
|     "@radix-ui/react-collapsible": "^1.1.0", | ||||
|     "@radix-ui/react-context-menu": "^2.2.1", | ||||
|     "@radix-ui/react-dialog": "^1.1.2", | ||||
|     "@radix-ui/react-dropdown-menu": "^2.1.1", | ||||
|     "@radix-ui/react-hover-card": "^1.1.1", | ||||
|     "@radix-ui/react-label": "^2.1.0", | ||||
|     "@radix-ui/react-menubar": "^1.1.1", | ||||
|     "@radix-ui/react-navigation-menu": "^1.2.0", | ||||
|     "@radix-ui/react-popover": "^1.1.1", | ||||
|     "@radix-ui/react-progress": "^1.1.0", | ||||
|     "@radix-ui/react-radio-group": "^1.2.0", | ||||
|     "@radix-ui/react-scroll-area": "^1.1.0", | ||||
|     "@radix-ui/react-select": "^2.1.1", | ||||
|     "@radix-ui/react-separator": "^1.1.0", | ||||
|     "@radix-ui/react-slider": "^1.2.0", | ||||
|     "@radix-ui/react-slot": "^1.1.0", | ||||
|     "@radix-ui/react-switch": "^1.1.0", | ||||
|     "@radix-ui/react-tabs": "^1.1.0", | ||||
|     "@radix-ui/react-toast": "^1.2.1", | ||||
|     "@radix-ui/react-toggle": "^1.1.0", | ||||
|     "@radix-ui/react-toggle-group": "^1.1.0", | ||||
|     "@radix-ui/react-tooltip": "^1.1.4", | ||||
|     "@tanstack/react-query": "^5.56.2", | ||||
|     "class-variance-authority": "^0.7.1", | ||||
|     "clsx": "^2.1.1", | ||||
|     "cmdk": "^1.0.0", | ||||
|     "date-fns": "^3.6.0", | ||||
|     "embla-carousel-react": "^8.3.0", | ||||
|     "input-otp": "^1.2.4", | ||||
|     "lucide-react": "^0.462.0", | ||||
|     "next-themes": "^0.3.0", | ||||
|     "react": "^18.3.1", | ||||
|     "react-day-picker": "^8.10.1", | ||||
|     "react-dom": "^18.3.1", | ||||
|     "react-hook-form": "^7.53.0", | ||||
|     "react-resizable-panels": "^2.1.3", | ||||
|     "react-router-dom": "^6.26.2", | ||||
|     "recharts": "^2.12.7", | ||||
|     "sonner": "^1.5.0", | ||||
|     "tailwind-merge": "^2.5.2", | ||||
|     "tailwindcss-animate": "^1.0.7", | ||||
|     "vaul": "^0.9.3", | ||||
|     "zod": "^3.23.8" | ||||
|   }, | ||||
|   "devDependencies": { | ||||
|     "@eslint/js": "^9.9.0", | ||||
|     "@tailwindcss/typography": "^0.5.15", | ||||
|     "@types/node": "^22.5.5", | ||||
|     "@types/react": "^18.3.3", | ||||
|     "@types/react-dom": "^18.3.0", | ||||
|     "@vitejs/plugin-react-swc": "^3.5.0", | ||||
|     "autoprefixer": "^10.4.20", | ||||
|     "eslint": "^9.9.0", | ||||
|     "eslint-plugin-react-hooks": "^5.1.0-rc.0", | ||||
|     "eslint-plugin-react-refresh": "^0.4.9", | ||||
|     "globals": "^15.9.0", | ||||
|     "lovable-tagger": "^1.1.7", | ||||
|     "postcss": "^8.4.47", | ||||
|     "tailwindcss": "^3.4.11", | ||||
|     "typescript": "^5.5.3", | ||||
|     "typescript-eslint": "^8.0.1", | ||||
|     "vite": "^5.4.1" | ||||
|   } | ||||
| } | ||||
							
								
								
									
										6
									
								
								frontend/postcss.config.js
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										6
									
								
								frontend/postcss.config.js
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,6 @@ | |||
| export default { | ||||
|   plugins: { | ||||
|     tailwindcss: {}, | ||||
|     autoprefixer: {}, | ||||
|   }, | ||||
| } | ||||
							
								
								
									
										
											BIN
										
									
								
								frontend/public/favicon.ico
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										
											BIN
										
									
								
								frontend/public/favicon.ico
									
										
									
									
									
										Normal file
									
								
							
										
											Binary file not shown.
										
									
								
							| After Width: | Height: | Size: 7.5 KiB | 
							
								
								
									
										1
									
								
								frontend/public/placeholder.svg
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										1
									
								
								frontend/public/placeholder.svg
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1 @@ | |||
| <svg xmlns="http://www.w3.org/2000/svg" width="1200" height="1200" fill="none"><rect width="1200" height="1200" fill="#EAEAEA" rx="3"/><g opacity=".5"><g opacity=".5"><path fill="#FAFAFA" d="M600.709 736.5c-75.454 0-136.621-61.167-136.621-136.62 0-75.454 61.167-136.621 136.621-136.621 75.453 0 136.62 61.167 136.62 136.621 0 75.453-61.167 136.62-136.62 136.62Z"/><path stroke="#C9C9C9" stroke-width="2.418" d="M600.709 736.5c-75.454 0-136.621-61.167-136.621-136.62 0-75.454 61.167-136.621 136.621-136.621 75.453 0 136.62 61.167 136.62 136.621 0 75.453-61.167 136.62-136.62 136.62Z"/></g><path stroke="url(#a)" stroke-width="2.418" d="M0-1.209h553.581" transform="scale(1 -1) rotate(45 1163.11 91.165)"/><path stroke="url(#b)" stroke-width="2.418" d="M404.846 598.671h391.726"/><path stroke="url(#c)" stroke-width="2.418" d="M599.5 795.742V404.017"/><path stroke="url(#d)" stroke-width="2.418" d="m795.717 796.597-391.441-391.44"/><path fill="#fff" d="M600.709 656.704c-31.384 0-56.825-25.441-56.825-56.824 0-31.384 25.441-56.825 56.825-56.825 31.383 0 56.824 25.441 56.824 56.825 0 31.383-25.441 56.824-56.824 56.824Z"/><g clip-path="url(#e)"><path fill="#666" fill-rule="evenodd" d="M616.426 586.58h-31.434v16.176l3.553-3.554.531-.531h9.068l.074-.074 8.463-8.463h2.565l7.18 7.181V586.58Zm-15.715 14.654 3.698 3.699 1.283 1.282-2.565 2.565-1.282-1.283-5.2-5.199h-6.066l-5.514 5.514-.073.073v2.876a2.418 2.418 0 0 0 2.418 2.418h26.598a2.418 2.418 0 0 0 2.418-2.418v-8.317l-8.463-8.463-7.181 7.181-.071.072Zm-19.347 5.442v4.085a6.045 6.045 0 0 0 6.046 6.045h26.598a6.044 6.044 0 0 0 6.045-6.045v-7.108l1.356-1.355-1.282-1.283-.074-.073v-17.989h-38.689v23.43l-.146.146.146.147Z" clip-rule="evenodd"/></g><path stroke="#C9C9C9" stroke-width="2.418" d="M600.709 656.704c-31.384 0-56.825-25.441-56.825-56.824 0-31.384 25.441-56.825 56.825-56.825 31.383 0 56.824 25.441 56.824 56.825 0 31.383-25.441 56.824-56.824 56.824Z"/></g><defs><linearGradient id="a" x1="554.061" x2="-.48" y1=".083" y2=".087" gradientUnits="userSpaceOnUse"><stop stop-color="#C9C9C9" stop-opacity="0"/><stop offset=".208" stop-color="#C9C9C9"/><stop offset=".792" stop-color="#C9C9C9"/><stop offset="1" stop-color="#C9C9C9" stop-opacity="0"/></linearGradient><linearGradient id="b" x1="796.912" x2="404.507" y1="599.963" y2="599.965" gradientUnits="userSpaceOnUse"><stop stop-color="#C9C9C9" stop-opacity="0"/><stop offset=".208" stop-color="#C9C9C9"/><stop offset=".792" stop-color="#C9C9C9"/><stop offset="1" stop-color="#C9C9C9" stop-opacity="0"/></linearGradient><linearGradient id="c" x1="600.792" x2="600.794" y1="403.677" y2="796.082" gradientUnits="userSpaceOnUse"><stop stop-color="#C9C9C9" stop-opacity="0"/><stop offset=".208" stop-color="#C9C9C9"/><stop offset=".792" stop-color="#C9C9C9"/><stop offset="1" stop-color="#C9C9C9" stop-opacity="0"/></linearGradient><linearGradient id="d" x1="404.85" x2="796.972" y1="403.903" y2="796.02" gradientUnits="userSpaceOnUse"><stop stop-color="#C9C9C9" stop-opacity="0"/><stop offset=".208" stop-color="#C9C9C9"/><stop offset=".792" stop-color="#C9C9C9"/><stop offset="1" stop-color="#C9C9C9" stop-opacity="0"/></linearGradient><clipPath id="e"><path fill="#fff" d="M581.364 580.535h38.689v38.689h-38.689z"/></clipPath></defs></svg> | ||||
| After Width: | Height: | Size: 3.2 KiB | 
							
								
								
									
										14
									
								
								frontend/public/robots.txt
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										14
									
								
								frontend/public/robots.txt
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,14 @@ | |||
| User-agent: Googlebot | ||||
| Allow: / | ||||
| 
 | ||||
| User-agent: Bingbot | ||||
| Allow: / | ||||
| 
 | ||||
| User-agent: Twitterbot | ||||
| Allow: / | ||||
| 
 | ||||
| User-agent: facebookexternalhit | ||||
| Allow: / | ||||
| 
 | ||||
| User-agent: * | ||||
| Allow: / | ||||
							
								
								
									
										42
									
								
								frontend/src/App.css
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										42
									
								
								frontend/src/App.css
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,42 @@ | |||
| #root { | ||||
|   max-width: 1280px; | ||||
|   margin: 0 auto; | ||||
|   padding: 2rem; | ||||
|   text-align: center; | ||||
| } | ||||
| 
 | ||||
| .logo { | ||||
|   height: 6em; | ||||
|   padding: 1.5em; | ||||
|   will-change: filter; | ||||
|   transition: filter 300ms; | ||||
| } | ||||
| .logo:hover { | ||||
|   filter: drop-shadow(0 0 2em #646cffaa); | ||||
| } | ||||
| .logo.react:hover { | ||||
|   filter: drop-shadow(0 0 2em #61dafbaa); | ||||
| } | ||||
| 
 | ||||
| @keyframes logo-spin { | ||||
|   from { | ||||
|     transform: rotate(0deg); | ||||
|   } | ||||
|   to { | ||||
|     transform: rotate(360deg); | ||||
|   } | ||||
| } | ||||
| 
 | ||||
| @media (prefers-reduced-motion: no-preference) { | ||||
|   a:nth-of-type(2) .logo { | ||||
|     animation: logo-spin infinite 20s linear; | ||||
|   } | ||||
| } | ||||
| 
 | ||||
| .card { | ||||
|   padding: 2em; | ||||
| } | ||||
| 
 | ||||
| .read-the-docs { | ||||
|   color: #888; | ||||
| } | ||||
							
								
								
									
										30
									
								
								frontend/src/App.tsx
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										30
									
								
								frontend/src/App.tsx
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,30 @@ | |||
| 
 | ||||
| import { Toaster } from "@/components/ui/toaster"; | ||||
| import { Toaster as Sonner } from "@/components/ui/sonner"; | ||||
| import { TooltipProvider } from "@/components/ui/tooltip"; | ||||
| import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; | ||||
| import { BrowserRouter, Routes, Route } from "react-router-dom"; | ||||
| import { AuthProvider } from "@/hooks/useAuth"; | ||||
| import Index from "./pages/Index"; | ||||
| import NotFound from "./pages/NotFound"; | ||||
| 
 | ||||
| const queryClient = new QueryClient(); | ||||
| 
 | ||||
| const App = () => ( | ||||
|   <QueryClientProvider client={queryClient}> | ||||
|     <TooltipProvider> | ||||
|       <AuthProvider> | ||||
|         <Toaster /> | ||||
|         <Sonner /> | ||||
|         <BrowserRouter> | ||||
|           <Routes> | ||||
|             <Route path="/" element={<Index />} /> | ||||
|             <Route path="*" element={<NotFound />} /> | ||||
|           </Routes> | ||||
|         </BrowserRouter> | ||||
|       </AuthProvider> | ||||
|     </TooltipProvider> | ||||
|   </QueryClientProvider> | ||||
| ); | ||||
| 
 | ||||
| export default App; | ||||
							
								
								
									
										161
									
								
								frontend/src/components/AddItemDialog.tsx
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										161
									
								
								frontend/src/components/AddItemDialog.tsx
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,161 @@ | |||
| 
 | ||||
| import { useState } from 'react'; | ||||
| import { Button } from '@/components/ui/button'; | ||||
| import { Input } from '@/components/ui/input'; | ||||
| import { Textarea } from '@/components/ui/textarea'; | ||||
| import { Badge } from '@/components/ui/badge'; | ||||
| import { | ||||
|   Dialog, | ||||
|   DialogContent, | ||||
|   DialogDescription, | ||||
|   DialogFooter, | ||||
|   DialogHeader, | ||||
|   DialogTitle, | ||||
| } from '@/components/ui/dialog'; | ||||
| import { Tag, InventoryItem } from '@/types/inventory'; | ||||
| import { inventoryService } from '@/services/inventoryService'; | ||||
| import { useAuth } from '@/hooks/useAuth'; | ||||
| import { useToast } from '@/hooks/use-toast'; | ||||
| 
 | ||||
| interface AddItemDialogProps { | ||||
|   open: boolean; | ||||
|   onOpenChange: (open: boolean) => void; | ||||
|   tags: Tag[]; | ||||
|   onItemAdded: (item: InventoryItem) => void; | ||||
| } | ||||
| 
 | ||||
| export const AddItemDialog = ({ open, onOpenChange, tags, onItemAdded }: AddItemDialogProps) => { | ||||
|   const [name, setName] = useState(''); | ||||
|   const [description, setDescription] = useState(''); | ||||
|   const [selectedTagIds, setSelectedTagIds] = useState<string[]>([]); | ||||
|   const [isLoading, setIsLoading] = useState(false); | ||||
| 
 | ||||
|   const { user } = useAuth(); | ||||
|   const { toast } = useToast(); | ||||
| 
 | ||||
|   const handleSubmit = async (e: React.FormEvent) => { | ||||
|     e.preventDefault(); | ||||
| 
 | ||||
|     if (!name.trim()) { | ||||
|       toast({ | ||||
|         title: "Errore", | ||||
|         description: "Il nome dell'oggetto è obbligatorio", | ||||
|         variant: "destructive", | ||||
|       }); | ||||
|       return; | ||||
|     } | ||||
| 
 | ||||
|     setIsLoading(true); | ||||
| 
 | ||||
|     try { | ||||
|       const newItem = await inventoryService.createItem({ | ||||
|         name: name.trim(), | ||||
|         description: description.trim(), | ||||
|         quantity: 1, // Default quantity set to 1
 | ||||
|         tagIds: selectedTagIds | ||||
|       }); | ||||
| 
 | ||||
|       onItemAdded(newItem); | ||||
| 
 | ||||
|       // Reset form
 | ||||
|       setName(''); | ||||
|       setDescription(''); | ||||
|       setSelectedTagIds([]); | ||||
|     } catch (error) { | ||||
|       toast({ | ||||
|         title: "Errore", | ||||
|         description: "Impossibile aggiungere l'oggetto", | ||||
|         variant: "destructive", | ||||
|       }); | ||||
|     } finally { | ||||
|       setIsLoading(false); | ||||
|     } | ||||
|   }; | ||||
| 
 | ||||
|   const toggleTag = (tagId: string) => { | ||||
|     setSelectedTagIds(prev => | ||||
|       prev.includes(tagId) | ||||
|         ? prev.filter(id => id !== tagId) | ||||
|         : [...prev, tagId] | ||||
|     ); | ||||
|   }; | ||||
| 
 | ||||
|   return ( | ||||
|     <Dialog open={open} onOpenChange={onOpenChange}> | ||||
|       <DialogContent className="sm:max-w-md"> | ||||
|         <DialogHeader> | ||||
|           <DialogTitle>Aggiungi Nuovo Oggetto</DialogTitle> | ||||
|           <DialogDescription> | ||||
|             Inserisci i dettagli del nuovo oggetto per l'inventario | ||||
|           </DialogDescription> | ||||
|         </DialogHeader> | ||||
| 
 | ||||
|         <form onSubmit={handleSubmit} className="space-y-4"> | ||||
|           <div className="space-y-2"> | ||||
|             <label className="text-sm font-medium text-gray-700">Nome *</label> | ||||
|             <Input | ||||
|               value={name} | ||||
|               onChange={(e) => setName(e.target.value)} | ||||
|               placeholder="es. Cavo LAN Cat6" | ||||
|               required | ||||
|             /> | ||||
|           </div> | ||||
| 
 | ||||
|           <div className="space-y-2"> | ||||
|             <label className="text-sm font-medium text-gray-700">Descrizione</label> | ||||
|             <Textarea | ||||
|               value={description} | ||||
|               onChange={(e) => setDescription(e.target.value)} | ||||
|               placeholder="Descrizione dettagliata dell'oggetto..." | ||||
|               rows={3} | ||||
|             /> | ||||
|           </div> | ||||
| 
 | ||||
|           <div className="space-y-2"> | ||||
|             <label className="text-sm font-medium text-gray-700">Tag</label> | ||||
|             <div className="flex flex-wrap gap-2 p-3 border rounded-md bg-gray-50 min-h-[60px]"> | ||||
|               {tags.map(tag => ( | ||||
|                 <Badge | ||||
|                   key={tag._id} | ||||
|                   variant="outline" | ||||
|                   className={`cursor-pointer transition-all ${selectedTagIds.includes(tag._id) | ||||
|                       ? 'bg-blue-100 border-blue-300' | ||||
|                       : 'hover:bg-gray-100' | ||||
|                     }`}
 | ||||
|                   onClick={() => toggleTag(tag._id)} | ||||
|                 > | ||||
|                   <div | ||||
|                     className="w-3 h-3 rounded-full mr-2" | ||||
|                     style={{ backgroundColor: tag.color }} | ||||
|                   /> | ||||
|                   {tag.name} | ||||
|                 </Badge> | ||||
|               ))} | ||||
|             </div> | ||||
|             <p className="text-xs text-gray-500"> | ||||
|               Clicca sui tag per selezionarli | ||||
|             </p> | ||||
|           </div> | ||||
| 
 | ||||
|           <DialogFooter> | ||||
|             <Button | ||||
|               type="button" | ||||
|               variant="outline" | ||||
|               onClick={() => onOpenChange(false)} | ||||
|               disabled={isLoading} | ||||
|             > | ||||
|               Annulla | ||||
|             </Button> | ||||
|             <Button | ||||
|               type="submit" | ||||
|               disabled={isLoading} | ||||
|               className="bg-green-600 hover:bg-green-700" | ||||
|             > | ||||
|               {isLoading ? "Aggiunta..." : "Aggiungi Oggetto"} | ||||
|             </Button> | ||||
|           </DialogFooter> | ||||
|         </form> | ||||
|       </DialogContent> | ||||
|     </Dialog> | ||||
|   ); | ||||
| }; | ||||
							
								
								
									
										57
									
								
								frontend/src/components/DeleteItemDialog.tsx
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										57
									
								
								frontend/src/components/DeleteItemDialog.tsx
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,57 @@ | |||
| 
 | ||||
| import { | ||||
|   AlertDialog, | ||||
|   AlertDialogAction, | ||||
|   AlertDialogCancel, | ||||
|   AlertDialogContent, | ||||
|   AlertDialogDescription, | ||||
|   AlertDialogFooter, | ||||
|   AlertDialogHeader, | ||||
|   AlertDialogTitle, | ||||
| } from '@/components/ui/alert-dialog'; | ||||
| import { InventoryItem } from '@/types/inventory'; | ||||
| 
 | ||||
| interface DeleteItemDialogProps { | ||||
|   open: boolean; | ||||
|   onOpenChange: (open: boolean) => void; | ||||
|   item: InventoryItem | null; | ||||
|   onConfirm: () => void; | ||||
|   isLoading: boolean; | ||||
| } | ||||
| 
 | ||||
| export const DeleteItemDialog = ({  | ||||
|   open,  | ||||
|   onOpenChange,  | ||||
|   item,  | ||||
|   onConfirm,  | ||||
|   isLoading  | ||||
| }: DeleteItemDialogProps) => { | ||||
|   if (!item) return null; | ||||
| 
 | ||||
|   return ( | ||||
|     <AlertDialog open={open} onOpenChange={onOpenChange}> | ||||
|       <AlertDialogContent> | ||||
|         <AlertDialogHeader> | ||||
|           <AlertDialogTitle>Conferma eliminazione</AlertDialogTitle> | ||||
|           <AlertDialogDescription> | ||||
|             Sei sicuro di voler eliminare l'oggetto <strong>"{item.name}"</strong>? | ||||
|             <br /> | ||||
|             Questa azione non può essere annullata. | ||||
|           </AlertDialogDescription> | ||||
|         </AlertDialogHeader> | ||||
|         <AlertDialogFooter> | ||||
|           <AlertDialogCancel disabled={isLoading}> | ||||
|             Annulla | ||||
|           </AlertDialogCancel> | ||||
|           <AlertDialogAction  | ||||
|             onClick={onConfirm} | ||||
|             disabled={isLoading} | ||||
|             className="bg-red-600 hover:bg-red-700" | ||||
|           > | ||||
|             {isLoading ? "Eliminazione..." : "Elimina"} | ||||
|           </AlertDialogAction> | ||||
|         </AlertDialogFooter> | ||||
|       </AlertDialogContent> | ||||
|     </AlertDialog> | ||||
|   ); | ||||
| }; | ||||
							
								
								
									
										171
									
								
								frontend/src/components/EditItemDialog.tsx
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										171
									
								
								frontend/src/components/EditItemDialog.tsx
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,171 @@ | |||
| 
 | ||||
| import { useState, useEffect } from 'react'; | ||||
| import { Button } from '@/components/ui/button'; | ||||
| import { Input } from '@/components/ui/input'; | ||||
| import { Textarea } from '@/components/ui/textarea'; | ||||
| import { Badge } from '@/components/ui/badge'; | ||||
| import { | ||||
|   Dialog, | ||||
|   DialogContent, | ||||
|   DialogHeader, | ||||
|   DialogTitle, | ||||
|   DialogFooter, | ||||
| } from '@/components/ui/dialog'; | ||||
| import { Label } from '@/components/ui/label'; | ||||
| import { InventoryItem, Tag, UpdateItemData } from '@/types/inventory'; | ||||
| import { inventoryService } from '@/services/inventoryService'; | ||||
| import { useToast } from '@/hooks/use-toast'; | ||||
| 
 | ||||
| interface EditItemDialogProps { | ||||
|   open: boolean; | ||||
|   onOpenChange: (open: boolean) => void; | ||||
|   item: InventoryItem | null; | ||||
|   tags: Tag[]; | ||||
|   onItemUpdated: (updatedItem: InventoryItem) => void; | ||||
| } | ||||
| 
 | ||||
| export const EditItemDialog = ({ | ||||
|   open, | ||||
|   onOpenChange, | ||||
|   item, | ||||
|   tags, | ||||
|   onItemUpdated, | ||||
| }: EditItemDialogProps) => { | ||||
|   const [formData, setFormData] = useState({ | ||||
|     name: '', | ||||
|     description: '', | ||||
|     quantity: 0, | ||||
|     selectedTagIds: [] as string[], | ||||
|   }); | ||||
|   const [isLoading, setIsLoading] = useState(false); | ||||
|   const { toast } = useToast(); | ||||
| 
 | ||||
|   useEffect(() => { | ||||
|     if (item) { | ||||
|       setFormData({ | ||||
|         name: item.name, | ||||
|         description: item.description, | ||||
|         quantity: item.quantity, | ||||
|         selectedTagIds: item.tags.map(tag => tag._id), | ||||
|       }); | ||||
|     } | ||||
|   }, [item]); | ||||
| 
 | ||||
|   const handleSubmit = async (e: React.FormEvent) => { | ||||
|     e.preventDefault(); | ||||
|     if (!item) return; | ||||
| 
 | ||||
|     setIsLoading(true); | ||||
|     try { | ||||
|       const updateData: UpdateItemData = { | ||||
|         name: formData.name, | ||||
|         description: formData.description, | ||||
|         quantity: formData.quantity, | ||||
|         tagIds: formData.selectedTagIds, | ||||
|       }; | ||||
| 
 | ||||
|       const updatedItem = await inventoryService.updateItem(item._id, updateData); | ||||
|       onItemUpdated(updatedItem); | ||||
|       onOpenChange(false); | ||||
|       toast({ | ||||
|         title: "Oggetto aggiornato", | ||||
|         description: `${updatedItem.name} è stato modificato con successo`, | ||||
|       }); | ||||
|     } catch (error) { | ||||
|       toast({ | ||||
|         title: "Errore", | ||||
|         description: "Impossibile aggiornare l'oggetto", | ||||
|         variant: "destructive", | ||||
|       }); | ||||
|     } finally { | ||||
|       setIsLoading(false); | ||||
|     } | ||||
|   }; | ||||
| 
 | ||||
|   const toggleTag = (tagId: string) => { | ||||
|     setFormData(prev => ({ | ||||
|       ...prev, | ||||
|       selectedTagIds: prev.selectedTagIds.includes(tagId) | ||||
|         ? prev.selectedTagIds.filter(id => id !== tagId) | ||||
|         : [...prev.selectedTagIds, tagId] | ||||
|     })); | ||||
|   }; | ||||
| 
 | ||||
|   if (!item) return null; | ||||
| 
 | ||||
|   return ( | ||||
|     <Dialog open={open} onOpenChange={onOpenChange}> | ||||
|       <DialogContent className="sm:max-w-[425px]"> | ||||
|         <DialogHeader> | ||||
|           <DialogTitle>Modifica Oggetto</DialogTitle> | ||||
|         </DialogHeader> | ||||
|         <form onSubmit={handleSubmit} className="space-y-4"> | ||||
|           <div className="space-y-2"> | ||||
|             <Label htmlFor="name">Nome</Label> | ||||
|             <Input | ||||
|               id="name" | ||||
|               value={formData.name} | ||||
|               onChange={(e) => setFormData(prev => ({ ...prev, name: e.target.value }))} | ||||
|               required | ||||
|             /> | ||||
|           </div> | ||||
| 
 | ||||
|           <div className="space-y-2"> | ||||
|             <Label htmlFor="description">Descrizione</Label> | ||||
|             <Textarea | ||||
|               id="description" | ||||
|               value={formData.description} | ||||
|               onChange={(e) => setFormData(prev => ({ ...prev, description: e.target.value }))} | ||||
|               rows={3} | ||||
|             /> | ||||
|           </div> | ||||
| 
 | ||||
|           <div className="space-y-2"> | ||||
|             <Label htmlFor="quantity">Quantità</Label> | ||||
|             <Input | ||||
|               id="quantity" | ||||
|               type="number" | ||||
|               min="0" | ||||
|               value={formData.quantity} | ||||
|               onChange={(e) => setFormData(prev => ({ ...prev, quantity: parseInt(e.target.value) || 0 }))} | ||||
|               required | ||||
|             /> | ||||
|           </div> | ||||
| 
 | ||||
|           <div className="space-y-2"> | ||||
|             <Label>Tag</Label> | ||||
|             <div className="flex flex-wrap gap-2"> | ||||
|               {tags.map(tag => ( | ||||
|                 <Badge | ||||
|                   key={tag._id} | ||||
|                   variant="outline" | ||||
|                   className={`cursor-pointer transition-all ${ | ||||
|                     formData.selectedTagIds.includes(tag._id) | ||||
|                       ? 'bg-blue-100 border-blue-300' | ||||
|                       : 'hover:bg-gray-100' | ||||
|                   }`}
 | ||||
|                   onClick={() => toggleTag(tag._id)} | ||||
|                 > | ||||
|                   <div | ||||
|                     className="w-3 h-3 rounded-full mr-2" | ||||
|                     style={{ backgroundColor: tag.color }} | ||||
|                   /> | ||||
|                   {tag.name} | ||||
|                 </Badge> | ||||
|               ))} | ||||
|             </div> | ||||
|           </div> | ||||
| 
 | ||||
|           <DialogFooter> | ||||
|             <Button type="button" variant="outline" onClick={() => onOpenChange(false)}> | ||||
|               Annulla | ||||
|             </Button> | ||||
|             <Button type="submit" disabled={isLoading}> | ||||
|               {isLoading ? 'Salvataggio...' : 'Salva Modifiche'} | ||||
|             </Button> | ||||
|           </DialogFooter> | ||||
|         </form> | ||||
|       </DialogContent> | ||||
|     </Dialog> | ||||
|   ); | ||||
| }; | ||||
							
								
								
									
										415
									
								
								frontend/src/components/InventoryDashboard.tsx
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										415
									
								
								frontend/src/components/InventoryDashboard.tsx
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,415 @@ | |||
| import { useState, useEffect, useMemo } from 'react'; | ||||
| import { Button } from '@/components/ui/button'; | ||||
| import { Input } from '@/components/ui/input'; | ||||
| import { Badge } from '@/components/ui/badge'; | ||||
| import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; | ||||
| import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'; | ||||
| import { Search, Plus, Tag as TagIcon, User, Trash2, Edit } from 'lucide-react'; | ||||
| import { InventoryItem, Tag } from '@/types/inventory'; | ||||
| import { inventoryService } from '@/services/inventoryService'; | ||||
| import { useAuth } from '@/hooks/useAuth'; | ||||
| import { AddItemDialog } from '@/components/AddItemDialog'; | ||||
| import { ManageTagsDialog } from '@/components/ManageTagsDialog'; | ||||
| import { DeleteItemDialog } from '@/components/DeleteItemDialog'; | ||||
| import { EditItemDialog } from '@/components/EditItemDialog'; | ||||
| import { QuantityControls } from '@/components/QuantityControls'; | ||||
| import { useToast } from '@/hooks/use-toast'; | ||||
| 
 | ||||
| export const InventoryDashboard = () => { | ||||
|   const [items, setItems] = useState<InventoryItem[]>([]); | ||||
|   const [tags, setTags] = useState<Tag[]>([]); | ||||
|   const [searchQuery, setSearchQuery] = useState(''); | ||||
|   const [selectedTagIds, setSelectedTagIds] = useState<string[]>([]); | ||||
|   const [isLoading, setIsLoading] = useState(true); | ||||
|   const [isSearching, setIsSearching] = useState(false); | ||||
|   const [showAddDialog, setShowAddDialog] = useState(false); | ||||
|   const [showTagsDialog, setShowTagsDialog] = useState(false); | ||||
|   const [itemToDelete, setItemToDelete] = useState<InventoryItem | null>(null); | ||||
|   const [itemToEdit, setItemToEdit] = useState<InventoryItem | null>(null); | ||||
|   const [isDeletingItem, setIsDeletingItem] = useState(false); | ||||
| 
 | ||||
|   const { user, logout } = useAuth(); | ||||
|   const { toast } = useToast(); | ||||
| 
 | ||||
|   useEffect(() => { | ||||
|     loadInitialData(); | ||||
|   }, []); | ||||
| 
 | ||||
|   const loadInitialData = async () => { | ||||
|     try { | ||||
|       const [itemsData, tagsData] = await Promise.all([ | ||||
|         inventoryService.getItems(), | ||||
|         inventoryService.getTags() | ||||
|       ]); | ||||
|       setItems(itemsData); | ||||
|       setTags(tagsData); | ||||
|     } catch (error) { | ||||
|       toast({ | ||||
|         title: "Errore", | ||||
|         description: "Impossibile caricare i dati dell'inventario", | ||||
|         variant: "destructive", | ||||
|       }); | ||||
|     } finally { | ||||
|       setIsLoading(false); | ||||
|     } | ||||
|   }; | ||||
| 
 | ||||
|   const handleSearch = async () => { | ||||
|     setIsSearching(true); | ||||
|     try { | ||||
|       const results = await inventoryService.searchItems(searchQuery, selectedTagIds); | ||||
|       setItems(results); | ||||
|     } catch (error) { | ||||
|       toast({ | ||||
|         title: "Errore di ricerca", | ||||
|         description: "Impossibile completare la ricerca", | ||||
|         variant: "destructive", | ||||
|       }); | ||||
|     } finally { | ||||
|       setIsSearching(false); | ||||
|     } | ||||
|   }; | ||||
| 
 | ||||
|   const handleAddItem = async (newItem: InventoryItem) => { | ||||
|     setItems(prev => [newItem, ...prev]); | ||||
|     setShowAddDialog(false); | ||||
|     toast({ | ||||
|       title: "Oggetto aggiunto", | ||||
|       description: `${newItem.name} è stato aggiunto all'inventario`, | ||||
|     }); | ||||
|   }; | ||||
| 
 | ||||
|   const handleDeleteItem = async () => { | ||||
|     if (!itemToDelete) return; | ||||
| 
 | ||||
|     setIsDeletingItem(true); | ||||
|      | ||||
|     try { | ||||
|       await inventoryService.deleteItem(itemToDelete._id); | ||||
|       setItems(prev => prev.filter(item => item._id !== itemToDelete._id)); | ||||
|       setItemToDelete(null); | ||||
|        | ||||
|       toast({ | ||||
|         title: "Oggetto eliminato", | ||||
|         description: `${itemToDelete.name} è stato eliminato dall'inventario`, | ||||
|       }); | ||||
|     } catch (error) { | ||||
|       toast({ | ||||
|         title: "Errore", | ||||
|         description: "Impossibile eliminare l'oggetto", | ||||
|         variant: "destructive", | ||||
|       }); | ||||
|     } finally { | ||||
|       setIsDeletingItem(false); | ||||
|     } | ||||
|   }; | ||||
| 
 | ||||
|   const handleTagCreated = (newTag: Tag) => { | ||||
|     setTags(prev => [...prev, newTag]); | ||||
|   }; | ||||
| 
 | ||||
|   const handleTagDeleted = (tagId: string) => { | ||||
|     setTags(prev => prev.filter(tag => tag._id !== tagId)); | ||||
|     // Rimuovi il tag dai filtri se selezionato
 | ||||
|     setSelectedTagIds(prev => prev.filter(id => id !== tagId)); | ||||
|     // Ricarica gli items per aggiornare quelli che avevano questo tag
 | ||||
|     loadInitialData(); | ||||
|   }; | ||||
| 
 | ||||
|   const toggleTagFilter = (tagId: string) => { | ||||
|     setSelectedTagIds(prev => | ||||
|       prev.includes(tagId) | ||||
|         ? prev.filter(id => id !== tagId) | ||||
|         : [...prev, tagId] | ||||
|     ); | ||||
|   }; | ||||
| 
 | ||||
|   const handleItemUpdated = (updatedItem: InventoryItem) => { | ||||
|     setItems(prev => prev.map(item =>  | ||||
|       item._id === updatedItem._id ? updatedItem : item | ||||
|     )); | ||||
|   }; | ||||
| 
 | ||||
|   const handleQuantityUpdate = (updatedItem: InventoryItem) => { | ||||
|     setItems(prev => prev.map(item =>  | ||||
|       item._id === updatedItem._id ? updatedItem : item | ||||
|     )); | ||||
|   }; | ||||
| 
 | ||||
|   const filteredItems = useMemo(() => { | ||||
|     if (!searchQuery && selectedTagIds.length === 0) return items; | ||||
| 
 | ||||
|     return items.filter(item => { | ||||
|       const matchesQuery = !searchQuery || | ||||
|         item.name.toLowerCase().includes(searchQuery.toLowerCase()) || | ||||
|         item.description.toLowerCase().includes(searchQuery.toLowerCase()) || | ||||
|         item.tags.some(tag => tag.name.toLowerCase().includes(searchQuery.toLowerCase())); | ||||
| 
 | ||||
|       const matchesTags = selectedTagIds.length === 0 || | ||||
|         selectedTagIds.some(tagId => item.tags.some(tag => tag._id === tagId)); | ||||
| 
 | ||||
|       return matchesQuery && matchesTags; | ||||
|     }); | ||||
|   }, [items, searchQuery, selectedTagIds]); | ||||
| 
 | ||||
|   const formatDate = (dateString: string) => { | ||||
|     return new Date(dateString).toLocaleDateString('it-IT', { | ||||
|       year: 'numeric', | ||||
|       month: 'short', | ||||
|       day: 'numeric' | ||||
|     }); | ||||
|   }; | ||||
| 
 | ||||
|   if (isLoading) { | ||||
|     return ( | ||||
|       <div className="min-h-screen bg-slate-50 flex items-center justify-center"> | ||||
|         <div className="text-center"> | ||||
|           <div className="w-12 h-12 bg-gradient-to-r from-blue-600 to-green-500 rounded-full animate-pulse mx-auto mb-4"></div> | ||||
|           <p className="text-gray-600">Caricamento inventario...</p> | ||||
|         </div> | ||||
|       </div> | ||||
|     ); | ||||
|   } | ||||
| 
 | ||||
|   return ( | ||||
|     <div className="min-h-screen bg-slate-50"> | ||||
|       {/* Header */} | ||||
|       <div className="bg-white border-b shadow-sm"> | ||||
|         <div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8"> | ||||
|           <div className="flex justify-between items-center py-4"> | ||||
|             <div className="flex items-center space-x-4"> | ||||
|               <div className="w-10 h-10 bg-gradient-to-br from-blue-600 to-green-500 rounded-xl flex items-center justify-center"> | ||||
|                 <span className="text-white font-bold">HS</span> | ||||
|               </div> | ||||
|               <div> | ||||
|                 <h1 className="text-2xl font-bold text-gray-900">Inventario Hackinpovo</h1> | ||||
|                 <p className="text-sm text-gray-500">Gestisci oggetti e componenti</p> | ||||
|               </div> | ||||
|             </div> | ||||
|             <div className="flex items-center space-x-4"> | ||||
|               <div className="flex items-center space-x-2 text-sm text-gray-600"> | ||||
|                 <User className="w-4 h-4" /> | ||||
|                 <span>{user?.name}</span> | ||||
|               </div> | ||||
|               <Button variant="outline" onClick={logout}> | ||||
|                 Logout | ||||
|               </Button> | ||||
|             </div> | ||||
|           </div> | ||||
|         </div> | ||||
|       </div> | ||||
| 
 | ||||
|       <div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8"> | ||||
|         {/* Controls */} | ||||
|         <Card className="mb-8"> | ||||
|           <CardContent className="pt-6"> | ||||
|             <div className="space-y-4"> | ||||
|               {/* Search Bar */} | ||||
|               <div className="flex gap-4"> | ||||
|                 <div className="flex-1 relative"> | ||||
|                   <Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 w-4 h-4" /> | ||||
|                   <Input | ||||
|                     placeholder="Cerca oggetti per nome, descrizione o tag..." | ||||
|                     value={searchQuery} | ||||
|                     onChange={(e) => setSearchQuery(e.target.value)} | ||||
|                     className="pl-10 h-12" | ||||
|                     onKeyDown={(e) => { | ||||
|                       if (e.key === 'Enter') { | ||||
|                         handleSearch(); | ||||
|                       } | ||||
|                     }} | ||||
|                   /> | ||||
|                 </div> | ||||
|                 <Button | ||||
|                   onClick={handleSearch} | ||||
|                   disabled={isSearching} | ||||
|                   className="h-12 px-6 bg-blue-600 hover:bg-blue-700" | ||||
|                 > | ||||
|                   {isSearching ? "Ricerca..." : "Cerca"} | ||||
|                 </Button> | ||||
|               </div> | ||||
| 
 | ||||
|               {/* Tag Filters */} | ||||
|               <div className="space-y-2"> | ||||
|                 <div className="flex items-center justify-between"> | ||||
|                   <label className="text-sm font-medium text-gray-700">Filtra per tag:</label> | ||||
|                   <div className="flex gap-2"> | ||||
|                     <Button | ||||
|                       variant="outline" | ||||
|                       size="sm" | ||||
|                       onClick={() => setShowTagsDialog(true)} | ||||
|                       className="text-xs" | ||||
|                     > | ||||
|                       <TagIcon className="w-3 h-3 mr-1" /> | ||||
|                       Gestisci Tag | ||||
|                     </Button> | ||||
|                     <Button | ||||
|                       variant="outline" | ||||
|                       size="sm" | ||||
|                       onClick={() => setShowAddDialog(true)} | ||||
|                       className="text-xs bg-green-50 hover:bg-green-100 text-green-700 border-green-300" | ||||
|                     > | ||||
|                       <Plus className="w-3 h-3 mr-1" /> | ||||
|                       Aggiungi Oggetto | ||||
|                     </Button> | ||||
|                   </div> | ||||
|                 </div> | ||||
|                 <div className="flex flex-wrap gap-2"> | ||||
|                   {tags.map(tag => { | ||||
|                     console.log(tag); | ||||
|                     return <Badge | ||||
|                     key={tag._id} | ||||
|                     variant="outline" | ||||
|                     className={`cursor-pointer transition-all ${ | ||||
|                       selectedTagIds.includes(tag._id) | ||||
|                         ? 'bg-blue-100 border-blue-300' | ||||
|                         : 'hover:bg-gray-100' | ||||
|                     }`}
 | ||||
|                     onClick={() => toggleTagFilter(tag._id)} | ||||
|                   > | ||||
|                     <div | ||||
|                       className="w-3 h-3 rounded-full mr-2" | ||||
|                       style={{ backgroundColor: tag.color }} | ||||
|                     /> | ||||
|                     {tag.name} | ||||
|                   </Badge> | ||||
|                   })} | ||||
|                 </div> | ||||
|               </div> | ||||
|             </div> | ||||
|           </CardContent> | ||||
|         </Card> | ||||
| 
 | ||||
|         {/* Items Table */} | ||||
|         <Card> | ||||
|           <CardHeader> | ||||
|             <CardTitle className="flex items-center justify-between"> | ||||
|               <span>Oggetti ({filteredItems.length})</span> | ||||
|               {selectedTagIds.length > 0 && ( | ||||
|                 <Button | ||||
|                   variant="ghost" | ||||
|                   size="sm" | ||||
|                   onClick={() => setSelectedTagIds([])} | ||||
|                   className="text-xs text-gray-500" | ||||
|                 > | ||||
|                   Rimuovi filtri | ||||
|                 </Button> | ||||
|               )} | ||||
|             </CardTitle> | ||||
|           </CardHeader> | ||||
|           <CardContent> | ||||
|             <div className="rounded-md border overflow-hidden"> | ||||
|               <Table> | ||||
|                 <TableHeader> | ||||
|                   <TableRow className="bg-slate-50"> | ||||
|                     <TableHead className="font-semibold">Nome</TableHead> | ||||
|                     <TableHead className="font-semibold">Descrizione</TableHead> | ||||
|                     <TableHead className="font-semibold">Quantità</TableHead> | ||||
|                     <TableHead className="font-semibold">Tag</TableHead> | ||||
|                     <TableHead className="font-semibold">Data Aggiunta</TableHead> | ||||
|                     <TableHead className="font-semibold">Aggiunto da</TableHead> | ||||
|                     <TableHead className="font-semibold">Azioni</TableHead> | ||||
|                   </TableRow> | ||||
|                 </TableHeader> | ||||
|                 <TableBody> | ||||
|                   {filteredItems.length === 0 ? ( | ||||
|                     <TableRow> | ||||
|                       <TableCell colSpan={7} className="text-center py-8 text-gray-500"> | ||||
|                         {searchQuery || selectedTagIds.length > 0 | ||||
|                           ? "Nessun oggetto trovato con i filtri applicati" | ||||
|                           : "Nessun oggetto nell'inventario" | ||||
|                         } | ||||
|                       </TableCell> | ||||
|                     </TableRow> | ||||
|                   ) : ( | ||||
|                     filteredItems.map((item) => ( | ||||
|                       <TableRow key={item._id} className="hover:bg-slate-50 transition-colors"> | ||||
|                         <TableCell className="font-medium">{item.name}</TableCell> | ||||
|                         <TableCell className="text-gray-600">{item.description}</TableCell> | ||||
|                         <TableCell> | ||||
|                           <QuantityControls  | ||||
|                             item={item}  | ||||
|                             onQuantityUpdate={handleQuantityUpdate} | ||||
|                           /> | ||||
|                         </TableCell> | ||||
|                         <TableCell> | ||||
|                           <div className="flex flex-wrap gap-1"> | ||||
|                             {item.tags.map(tag => ( | ||||
|                               <Badge | ||||
|                               key={tag._id} | ||||
|                               variant="outline" | ||||
|                             > | ||||
|                               <div | ||||
|                                 className="w-3 h-3 rounded-full mr-2" | ||||
|                                 style={{ backgroundColor: tag.color }} | ||||
|                               /> | ||||
|                               {tag.name} | ||||
|                             </Badge> | ||||
|                             ))} | ||||
|                           </div> | ||||
|                         </TableCell> | ||||
|                         <TableCell className="text-gray-600">{formatDate(item.dateAdded)}</TableCell> | ||||
|                         <TableCell className="text-gray-600">{item.addedBy}</TableCell> | ||||
|                         <TableCell> | ||||
|                           <div className="flex gap-1"> | ||||
|                             <Button | ||||
|                               variant="ghost" | ||||
|                               size="sm" | ||||
|                               onClick={() => setItemToEdit(item)} | ||||
|                               className="text-blue-600 hover:text-blue-700 hover:bg-blue-50" | ||||
|                             > | ||||
|                               <Edit className="w-4 h-4" /> | ||||
|                             </Button> | ||||
|                             <Button | ||||
|                               variant="ghost" | ||||
|                               size="sm" | ||||
|                               onClick={() => setItemToDelete(item)} | ||||
|                               className="text-red-600 hover:text-red-700 hover:bg-red-50" | ||||
|                             > | ||||
|                               <Trash2 className="w-4 h-4" /> | ||||
|                             </Button> | ||||
|                           </div> | ||||
|                         </TableCell> | ||||
|                       </TableRow> | ||||
|                     )) | ||||
|                   )} | ||||
|                 </TableBody> | ||||
|               </Table> | ||||
|             </div> | ||||
|           </CardContent> | ||||
|         </Card> | ||||
|       </div> | ||||
| 
 | ||||
|       <AddItemDialog | ||||
|         open={showAddDialog} | ||||
|         onOpenChange={setShowAddDialog} | ||||
|         tags={tags} | ||||
|         onItemAdded={handleAddItem} | ||||
|       /> | ||||
| 
 | ||||
|       <ManageTagsDialog | ||||
|         open={showTagsDialog} | ||||
|         onOpenChange={setShowTagsDialog} | ||||
|         tags={tags} | ||||
|         onTagCreated={handleTagCreated} | ||||
|         onTagDeleted={handleTagDeleted} | ||||
|       /> | ||||
| 
 | ||||
|       <DeleteItemDialog | ||||
|         open={!!itemToDelete} | ||||
|         onOpenChange={() => setItemToDelete(null)} | ||||
|         item={itemToDelete} | ||||
|         onConfirm={handleDeleteItem} | ||||
|         isLoading={isDeletingItem} | ||||
|       /> | ||||
| 
 | ||||
|       <EditItemDialog | ||||
|         open={!!itemToEdit} | ||||
|         onOpenChange={() => setItemToEdit(null)} | ||||
|         item={itemToEdit} | ||||
|         tags={tags} | ||||
|         onItemUpdated={handleItemUpdated} | ||||
|       /> | ||||
|     </div> | ||||
|   ); | ||||
| }; | ||||
							
								
								
									
										160
									
								
								frontend/src/components/LoginForm.tsx
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										160
									
								
								frontend/src/components/LoginForm.tsx
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,160 @@ | |||
| 
 | ||||
| import { useState } from 'react'; | ||||
| import { Button } from '@/components/ui/button'; | ||||
| import { Input } from '@/components/ui/input'; | ||||
| import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; | ||||
| import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; | ||||
| import { useAuth } from '@/hooks/useAuth'; | ||||
| import { useToast } from '@/hooks/use-toast'; | ||||
| 
 | ||||
| export const LoginForm = () => { | ||||
|   const [isLoading, setIsLoading] = useState(false); | ||||
|   const { login, register } = useAuth(); | ||||
|   const { toast } = useToast(); | ||||
| 
 | ||||
|   const handleLogin = async (e: React.FormEvent<HTMLFormElement>) => { | ||||
|     e.preventDefault(); | ||||
|     setIsLoading(true); | ||||
|      | ||||
|     const formData = new FormData(e.currentTarget); | ||||
|     const email = formData.get('email') as string; | ||||
|     const password = formData.get('password') as string; | ||||
| 
 | ||||
|     try { | ||||
|       await login(email, password); | ||||
|       toast({ | ||||
|         title: "Login effettuato", | ||||
|         description: "Benvenuto nell'inventario Hackinpovo!", | ||||
|       }); | ||||
|     } catch (error) { | ||||
|       toast({ | ||||
|         title: "Errore di login", | ||||
|         description: "Controlla le tue credenziali.", | ||||
|         variant: "destructive", | ||||
|       }); | ||||
|     } finally { | ||||
|       setIsLoading(false); | ||||
|     } | ||||
|   }; | ||||
| 
 | ||||
|   const handleRegister = async (e: React.FormEvent<HTMLFormElement>) => { | ||||
|     e.preventDefault(); | ||||
|     setIsLoading(true); | ||||
|      | ||||
|     const formData = new FormData(e.currentTarget); | ||||
|     const email = formData.get('email') as string; | ||||
|     const password = formData.get('password') as string; | ||||
|     const name = formData.get('name') as string; | ||||
| 
 | ||||
|     try { | ||||
|       await register(email, password, name); | ||||
|       toast({ | ||||
|         title: "Account creato", | ||||
|         description: "Benvenuto nell'inventario Hackinpovo!", | ||||
|       }); | ||||
|     } catch (error) { | ||||
|       toast({ | ||||
|         title: "Errore di registrazione", | ||||
|         description: "Si è verificato un errore durante la registrazione.", | ||||
|         variant: "destructive", | ||||
|       }); | ||||
|     } finally { | ||||
|       setIsLoading(false); | ||||
|     } | ||||
|   }; | ||||
| 
 | ||||
|   return ( | ||||
|     <div className="min-h-screen bg-gradient-to-br from-slate-900 via-blue-900 to-slate-800 flex items-center justify-center p-4"> | ||||
|       <Card className="w-full max-w-md shadow-2xl border-0 bg-white/95 backdrop-blur"> | ||||
|         <CardHeader className="text-center pb-2"> | ||||
|           <div className="w-16 h-16 bg-gradient-to-br from-blue-600 to-green-500 rounded-2xl mx-auto mb-4 flex items-center justify-center"> | ||||
|             <span className="text-white font-bold text-xl">HS</span> | ||||
|           </div> | ||||
|           <CardTitle className="text-2xl bg-gradient-to-r from-blue-600 to-green-500 bg-clip-text text-transparent"> | ||||
|             Inventario Hackinpovo | ||||
|           </CardTitle> | ||||
|           <CardDescription> | ||||
|             Gestisci l'inventario di Hackinpovo | ||||
|           </CardDescription> | ||||
|         </CardHeader> | ||||
|         <CardContent> | ||||
|           <Tabs defaultValue="login" className="w-full"> | ||||
|             <TabsList className="grid w-full grid-cols-2"> | ||||
|               <TabsTrigger value="login">Login</TabsTrigger> | ||||
|               <TabsTrigger value="register">Registrati</TabsTrigger> | ||||
|             </TabsList> | ||||
|              | ||||
|             <TabsContent value="login" className="space-y-4"> | ||||
|               <form onSubmit={handleLogin} className="space-y-4"> | ||||
|                 <div className="space-y-2"> | ||||
|                   <Input | ||||
|                     name="email" | ||||
|                     type="email" | ||||
|                     placeholder="Email" | ||||
|                     required | ||||
|                     className="h-11" | ||||
|                   /> | ||||
|                 </div> | ||||
|                 <div className="space-y-2"> | ||||
|                   <Input | ||||
|                     name="password" | ||||
|                     type="password" | ||||
|                     placeholder="Password" | ||||
|                     required | ||||
|                     className="h-11" | ||||
|                   /> | ||||
|                 </div> | ||||
|                 <Button  | ||||
|                   type="submit"  | ||||
|                   className="w-full h-11 bg-gradient-to-r from-blue-600 to-green-500 hover:from-blue-700 hover:to-green-600" | ||||
|                   disabled={isLoading} | ||||
|                 > | ||||
|                   {isLoading ? "Accesso in corso..." : "Accedi"} | ||||
|                 </Button> | ||||
|               </form> | ||||
|             </TabsContent> | ||||
|              | ||||
|             <TabsContent value="register" className="space-y-4"> | ||||
|               <form onSubmit={handleRegister} className="space-y-4"> | ||||
|                 <div className="space-y-2"> | ||||
|                   <Input | ||||
|                     name="name" | ||||
|                     type="text" | ||||
|                     placeholder="Nome completo" | ||||
|                     required | ||||
|                     className="h-11" | ||||
|                   /> | ||||
|                 </div> | ||||
|                 <div className="space-y-2"> | ||||
|                   <Input | ||||
|                     name="email" | ||||
|                     type="email" | ||||
|                     placeholder="Email" | ||||
|                     required | ||||
|                     className="h-11" | ||||
|                   /> | ||||
|                 </div> | ||||
|                 <div className="space-y-2"> | ||||
|                   <Input | ||||
|                     name="password" | ||||
|                     type="password" | ||||
|                     placeholder="Password" | ||||
|                     required | ||||
|                     className="h-11" | ||||
|                   /> | ||||
|                 </div> | ||||
|                 <Button  | ||||
|                   type="submit"  | ||||
|                   className="w-full h-11 bg-gradient-to-r from-blue-600 to-green-500 hover:from-blue-700 hover:to-green-600" | ||||
|                   disabled={isLoading} | ||||
|                 > | ||||
|                   {isLoading ? "Registrazione..." : "Registrati"} | ||||
|                 </Button> | ||||
|               </form> | ||||
|             </TabsContent> | ||||
|           </Tabs> | ||||
|         </CardContent> | ||||
|       </Card> | ||||
|     </div> | ||||
|   ); | ||||
| }; | ||||
							
								
								
									
										231
									
								
								frontend/src/components/ManageTagsDialog.tsx
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										231
									
								
								frontend/src/components/ManageTagsDialog.tsx
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,231 @@ | |||
| import { useState } from 'react'; | ||||
| import { Button } from '@/components/ui/button'; | ||||
| import { Input } from '@/components/ui/input'; | ||||
| import { Badge } from '@/components/ui/badge'; | ||||
| import { | ||||
|   Dialog, | ||||
|   DialogContent, | ||||
|   DialogDescription, | ||||
|   DialogHeader, | ||||
|   DialogTitle, | ||||
| } from '@/components/ui/dialog'; | ||||
| import { | ||||
|   AlertDialog, | ||||
|   AlertDialogAction, | ||||
|   AlertDialogCancel, | ||||
|   AlertDialogContent, | ||||
|   AlertDialogDescription, | ||||
|   AlertDialogFooter, | ||||
|   AlertDialogHeader, | ||||
|   AlertDialogTitle, | ||||
| } from '@/components/ui/alert-dialog'; | ||||
| import { Tag as TagIcon, Plus, Trash2 } from 'lucide-react'; | ||||
| import { Tag } from '@/types/inventory'; | ||||
| import { inventoryService } from '@/services/inventoryService'; | ||||
| import { useToast } from '@/hooks/use-toast'; | ||||
| 
 | ||||
| interface ManageTagsDialogProps { | ||||
|   open: boolean; | ||||
|   onOpenChange: (open: boolean) => void; | ||||
|   tags: Tag[]; | ||||
|   onTagCreated: (tag: Tag) => void; | ||||
|   onTagDeleted: (tagId: string) => void; | ||||
| } | ||||
| 
 | ||||
| export const ManageTagsDialog = ({ | ||||
|   open, | ||||
|   onOpenChange, | ||||
|   tags, | ||||
|   onTagCreated, | ||||
|   onTagDeleted | ||||
| }: ManageTagsDialogProps) => { | ||||
|   const [newTagName, setNewTagName] = useState(''); | ||||
|   const [newTagColor, setNewTagColor] = useState('#000000'); | ||||
|   const [isLoading, setIsLoading] = useState(false); | ||||
|   const [tagToDelete, setTagToDelete] = useState<Tag | null>(null); | ||||
|   const [isDeletingTag, setIsDeletingTag] = useState(false); | ||||
| 
 | ||||
|   const { toast } = useToast(); | ||||
| 
 | ||||
|   const handleCreateTag = async (e: React.FormEvent) => { | ||||
|     e.preventDefault(); | ||||
| 
 | ||||
|     if (!newTagName.trim()) { | ||||
|       toast({ | ||||
|         title: "Errore", | ||||
|         description: "Il nome del tag è obbligatorio", | ||||
|         variant: "destructive", | ||||
|       }); | ||||
|       return; | ||||
|     } | ||||
| 
 | ||||
|     if (tags.some(tag => tag.name.toLowerCase() === newTagName.trim().toLowerCase())) { | ||||
|       toast({ | ||||
|         title: "Errore", | ||||
|         description: "Un tag con questo nome esiste già", | ||||
|         variant: "destructive", | ||||
|       }); | ||||
|       return; | ||||
|     } | ||||
| 
 | ||||
|     setIsLoading(true); | ||||
| 
 | ||||
|     try { | ||||
|       const newTag = await inventoryService.createTag(newTagName.trim(), newTagColor.trim()); | ||||
|       onTagCreated(newTag); | ||||
|       setNewTagName(''); | ||||
|       setNewTagColor('#000000'); | ||||
| 
 | ||||
|       toast({ | ||||
|         title: "Tag creato", | ||||
|         description: `Il tag "${newTag.name}" è stato aggiunto`, | ||||
|       }); | ||||
|     } catch (error) { | ||||
|       toast({ | ||||
|         title: "Errore", | ||||
|         description: "Impossibile creare il tag", | ||||
|         variant: "destructive", | ||||
|       }); | ||||
|     } finally { | ||||
|       setIsLoading(false); | ||||
|     } | ||||
|   }; | ||||
| 
 | ||||
|   const handleDeleteTag = async () => { | ||||
|     if (!tagToDelete) return; | ||||
| 
 | ||||
|     setIsDeletingTag(true); | ||||
| 
 | ||||
|     try { | ||||
|       await inventoryService.deleteTag(tagToDelete._id); | ||||
|       onTagDeleted(tagToDelete._id); | ||||
|       setTagToDelete(null); | ||||
| 
 | ||||
|       toast({ | ||||
|         title: "Tag eliminato", | ||||
|         description: `Il tag "${tagToDelete.name}" è stato eliminato`, | ||||
|       }); | ||||
|     } catch (error) { | ||||
|       toast({ | ||||
|         title: "Errore", | ||||
|         description: "Impossibile eliminare il tag", | ||||
|         variant: "destructive", | ||||
|       }); | ||||
|     } finally { | ||||
|       setIsDeletingTag(false); | ||||
|     } | ||||
|   }; | ||||
| 
 | ||||
|   return ( | ||||
|     <> | ||||
|       <Dialog open={open} onOpenChange={onOpenChange}> | ||||
|         <DialogContent className="sm:max-w-md"> | ||||
|           <DialogHeader> | ||||
|             <DialogTitle className="flex items-center gap-2"> | ||||
|               <TagIcon className="w-5 h-5" /> | ||||
|               Gestisci Tag | ||||
|             </DialogTitle> | ||||
|             <DialogDescription> | ||||
|               Visualizza, crea ed elimina tag per organizzare l'inventario | ||||
|             </DialogDescription> | ||||
|           </DialogHeader> | ||||
| 
 | ||||
|           <div className="space-y-6"> | ||||
|             {/* Create New Tag */} | ||||
|             <div className="space-y-3"> | ||||
|               <h4 className="font-medium text-gray-900">Crea Nuovo Tag</h4> | ||||
|               <form onSubmit={handleCreateTag} className="space-y-3"> | ||||
|                 <div className="flex gap-2"> | ||||
|                   <Input | ||||
|                     value={newTagName} | ||||
|                     onChange={(e) => setNewTagName(e.target.value)} | ||||
|                     placeholder="Nome del tag..." | ||||
|                     className="flex-1" | ||||
|                   /> | ||||
|                   <input | ||||
|                     type="color" | ||||
|                     value={newTagColor} | ||||
|                     onChange={(e) => setNewTagColor(e.target.value)} | ||||
|                     className="w-10 h-10 p-0 border rounded" | ||||
|                     title="Seleziona colore" | ||||
|                   /> | ||||
|                   <Button | ||||
|                     type="submit" | ||||
|                     disabled={isLoading} | ||||
|                     className="bg-green-600 hover:bg-green-700" | ||||
|                   > | ||||
|                     <Plus className="w-4 h-4" /> | ||||
|                   </Button> | ||||
|                 </div> | ||||
|               </form> | ||||
|             </div> | ||||
| 
 | ||||
|             {/* Existing Tags */} | ||||
|             <div className="space-y-3"> | ||||
|               <h4 className="font-medium text-gray-900">Tag Esistenti ({tags.length})</h4> | ||||
|               <div className="p-3 border rounded-md bg-gray-50 min-h-[100px] max-h-[200px] overflow-y-auto"> | ||||
|                 {tags.map(tag => ( | ||||
|                   <div key={tag._id} className="flex items-center justify-between mb-2 p-2 bg-white rounded border"> | ||||
|                     <Badge | ||||
|                       key={tag._id} | ||||
|                       variant="outline" | ||||
|                     > | ||||
|                       <div | ||||
|                         className="w-3 h-3 rounded-full mr-2" | ||||
|                         style={{ backgroundColor: tag.color }} | ||||
|                       /> | ||||
|                       {tag.name} | ||||
|                     </Badge> | ||||
|                     <Button | ||||
|                       variant="ghost" | ||||
|                       size="sm" | ||||
|                       onClick={() => setTagToDelete(tag)} | ||||
|                       className="text-red-600 hover:text-red-700 hover:bg-red-50" | ||||
|                     > | ||||
|                       <Trash2 className="w-4 h-4" /> | ||||
|                     </Button> | ||||
|                   </div> | ||||
|                 ))} | ||||
|                 {tags.length === 0 && ( | ||||
|                   <p className="text-gray-500 text-sm">Nessun tag disponibile</p> | ||||
|                 )} | ||||
|               </div> | ||||
|             </div> | ||||
|           </div> | ||||
| 
 | ||||
|           <div className="flex justify-end pt-4"> | ||||
|             <Button variant="outline" onClick={() => onOpenChange(false)}> | ||||
|               Chiudi | ||||
|             </Button> | ||||
|           </div> | ||||
|         </DialogContent> | ||||
|       </Dialog> | ||||
| 
 | ||||
|       {/* Delete Confirmation Dialog */} | ||||
|       <AlertDialog open={!!tagToDelete} onOpenChange={() => setTagToDelete(null)}> | ||||
|         <AlertDialogContent> | ||||
|           <AlertDialogHeader> | ||||
|             <AlertDialogTitle>Conferma eliminazione</AlertDialogTitle> | ||||
|             <AlertDialogDescription> | ||||
|               Sei sicuro di voler eliminare il tag <strong>"{tagToDelete?.name}"</strong>? | ||||
|               <br /> | ||||
|               Questa azione non può essere annullata e rimuoverà il tag da tutti gli oggetti associati. | ||||
|             </AlertDialogDescription> | ||||
|           </AlertDialogHeader> | ||||
|           <AlertDialogFooter> | ||||
|             <AlertDialogCancel disabled={isDeletingTag}> | ||||
|               Annulla | ||||
|             </AlertDialogCancel> | ||||
|             <AlertDialogAction | ||||
|               onClick={handleDeleteTag} | ||||
|               disabled={isDeletingTag} | ||||
|               className="bg-red-600 hover:bg-red-700" | ||||
|             > | ||||
|               {isDeletingTag ? "Eliminazione..." : "Elimina"} | ||||
|             </AlertDialogAction> | ||||
|           </AlertDialogFooter> | ||||
|         </AlertDialogContent> | ||||
|       </AlertDialog> | ||||
|     </> | ||||
|   ); | ||||
| }; | ||||
							
								
								
									
										65
									
								
								frontend/src/components/QuantityControls.tsx
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										65
									
								
								frontend/src/components/QuantityControls.tsx
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,65 @@ | |||
| 
 | ||||
| import { useState } from 'react'; | ||||
| import { Button } from '@/components/ui/button'; | ||||
| import { Plus, Minus } from 'lucide-react'; | ||||
| import { InventoryItem } from '@/types/inventory'; | ||||
| import { inventoryService } from '@/services/inventoryService'; | ||||
| import { useToast } from '@/hooks/use-toast'; | ||||
| 
 | ||||
| interface QuantityControlsProps { | ||||
|   item: InventoryItem; | ||||
|   onQuantityUpdate: (updatedItem: InventoryItem) => void; | ||||
| } | ||||
| 
 | ||||
| export const QuantityControls = ({ item, onQuantityUpdate }: QuantityControlsProps) => { | ||||
|   const [isUpdating, setIsUpdating] = useState(false); | ||||
|   const { toast } = useToast(); | ||||
| 
 | ||||
|   const updateQuantity = async (newQuantity: number) => { | ||||
|     if (newQuantity < 0) return; | ||||
|      | ||||
|     setIsUpdating(true); | ||||
|     try { | ||||
|       const updatedItem = await inventoryService.updateItemQuantity(item._id, newQuantity); | ||||
|       onQuantityUpdate(updatedItem); | ||||
|       toast({ | ||||
|         title: "Quantità aggiornata", | ||||
|         description: `${item.name}: ${newQuantity} pezzi`, | ||||
|       }); | ||||
|     } catch (error) { | ||||
|       toast({ | ||||
|         title: "Errore", | ||||
|         description: "Impossibile aggiornare la quantità", | ||||
|         variant: "destructive", | ||||
|       }); | ||||
|     } finally { | ||||
|       setIsUpdating(false); | ||||
|     } | ||||
|   }; | ||||
| 
 | ||||
|   return ( | ||||
|     <div className="flex items-center space-x-2"> | ||||
|       <Button | ||||
|         variant="outline" | ||||
|         size="sm" | ||||
|         onClick={() => updateQuantity(item.quantity - 1)} | ||||
|         disabled={isUpdating || item.quantity <= 0} | ||||
|         className="h-8 w-8 p-0" | ||||
|       > | ||||
|         <Minus className="w-3 h-3" /> | ||||
|       </Button> | ||||
|       <span className="text-sm font-medium min-w-[2rem] text-center"> | ||||
|         {item.quantity} | ||||
|       </span> | ||||
|       <Button | ||||
|         variant="outline" | ||||
|         size="sm" | ||||
|         onClick={() => updateQuantity(item.quantity + 1)} | ||||
|         disabled={isUpdating} | ||||
|         className="h-8 w-8 p-0" | ||||
|       > | ||||
|         <Plus className="w-3 h-3" /> | ||||
|       </Button> | ||||
|     </div> | ||||
|   ); | ||||
| }; | ||||
							
								
								
									
										56
									
								
								frontend/src/components/ui/accordion.tsx
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										56
									
								
								frontend/src/components/ui/accordion.tsx
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,56 @@ | |||
| import * as React from "react" | ||||
| import * as AccordionPrimitive from "@radix-ui/react-accordion" | ||||
| import { ChevronDown } from "lucide-react" | ||||
| 
 | ||||
| import { cn } from "@/lib/utils" | ||||
| 
 | ||||
| const Accordion = AccordionPrimitive.Root | ||||
| 
 | ||||
| const AccordionItem = React.forwardRef< | ||||
|   React.ElementRef<typeof AccordionPrimitive.Item>, | ||||
|   React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Item> | ||||
| >(({ className, ...props }, ref) => ( | ||||
|   <AccordionPrimitive.Item | ||||
|     ref={ref} | ||||
|     className={cn("border-b", className)} | ||||
|     {...props} | ||||
|   /> | ||||
| )) | ||||
| AccordionItem.displayName = "AccordionItem" | ||||
| 
 | ||||
| const AccordionTrigger = React.forwardRef< | ||||
|   React.ElementRef<typeof AccordionPrimitive.Trigger>, | ||||
|   React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Trigger> | ||||
| >(({ className, children, ...props }, ref) => ( | ||||
|   <AccordionPrimitive.Header className="flex"> | ||||
|     <AccordionPrimitive.Trigger | ||||
|       ref={ref} | ||||
|       className={cn( | ||||
|         "flex flex-1 items-center justify-between py-4 font-medium transition-all hover:underline [&[data-state=open]>svg]:rotate-180", | ||||
|         className | ||||
|       )} | ||||
|       {...props} | ||||
|     > | ||||
|       {children} | ||||
|       <ChevronDown className="h-4 w-4 shrink-0 transition-transform duration-200" /> | ||||
|     </AccordionPrimitive.Trigger> | ||||
|   </AccordionPrimitive.Header> | ||||
| )) | ||||
| AccordionTrigger.displayName = AccordionPrimitive.Trigger.displayName | ||||
| 
 | ||||
| const AccordionContent = React.forwardRef< | ||||
|   React.ElementRef<typeof AccordionPrimitive.Content>, | ||||
|   React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Content> | ||||
| >(({ className, children, ...props }, ref) => ( | ||||
|   <AccordionPrimitive.Content | ||||
|     ref={ref} | ||||
|     className="overflow-hidden text-sm transition-all data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down" | ||||
|     {...props} | ||||
|   > | ||||
|     <div className={cn("pb-4 pt-0", className)}>{children}</div> | ||||
|   </AccordionPrimitive.Content> | ||||
| )) | ||||
| 
 | ||||
| AccordionContent.displayName = AccordionPrimitive.Content.displayName | ||||
| 
 | ||||
| export { Accordion, AccordionItem, AccordionTrigger, AccordionContent } | ||||
							
								
								
									
										139
									
								
								frontend/src/components/ui/alert-dialog.tsx
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										139
									
								
								frontend/src/components/ui/alert-dialog.tsx
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,139 @@ | |||
| import * as React from "react" | ||||
| import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog" | ||||
| 
 | ||||
| import { cn } from "@/lib/utils" | ||||
| import { buttonVariants } from "@/components/ui/button" | ||||
| 
 | ||||
| const AlertDialog = AlertDialogPrimitive.Root | ||||
| 
 | ||||
| const AlertDialogTrigger = AlertDialogPrimitive.Trigger | ||||
| 
 | ||||
| const AlertDialogPortal = AlertDialogPrimitive.Portal | ||||
| 
 | ||||
| const AlertDialogOverlay = React.forwardRef< | ||||
|   React.ElementRef<typeof AlertDialogPrimitive.Overlay>, | ||||
|   React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Overlay> | ||||
| >(({ className, ...props }, ref) => ( | ||||
|   <AlertDialogPrimitive.Overlay | ||||
|     className={cn( | ||||
|       "fixed inset-0 z-50 bg-black/80  data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0", | ||||
|       className | ||||
|     )} | ||||
|     {...props} | ||||
|     ref={ref} | ||||
|   /> | ||||
| )) | ||||
| AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName | ||||
| 
 | ||||
| const AlertDialogContent = React.forwardRef< | ||||
|   React.ElementRef<typeof AlertDialogPrimitive.Content>, | ||||
|   React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Content> | ||||
| >(({ className, ...props }, ref) => ( | ||||
|   <AlertDialogPortal> | ||||
|     <AlertDialogOverlay /> | ||||
|     <AlertDialogPrimitive.Content | ||||
|       ref={ref} | ||||
|       className={cn( | ||||
|         "fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg", | ||||
|         className | ||||
|       )} | ||||
|       {...props} | ||||
|     /> | ||||
|   </AlertDialogPortal> | ||||
| )) | ||||
| AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName | ||||
| 
 | ||||
| const AlertDialogHeader = ({ | ||||
|   className, | ||||
|   ...props | ||||
| }: React.HTMLAttributes<HTMLDivElement>) => ( | ||||
|   <div | ||||
|     className={cn( | ||||
|       "flex flex-col space-y-2 text-center sm:text-left", | ||||
|       className | ||||
|     )} | ||||
|     {...props} | ||||
|   /> | ||||
| ) | ||||
| AlertDialogHeader.displayName = "AlertDialogHeader" | ||||
| 
 | ||||
| const AlertDialogFooter = ({ | ||||
|   className, | ||||
|   ...props | ||||
| }: React.HTMLAttributes<HTMLDivElement>) => ( | ||||
|   <div | ||||
|     className={cn( | ||||
|       "flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2", | ||||
|       className | ||||
|     )} | ||||
|     {...props} | ||||
|   /> | ||||
| ) | ||||
| AlertDialogFooter.displayName = "AlertDialogFooter" | ||||
| 
 | ||||
| const AlertDialogTitle = React.forwardRef< | ||||
|   React.ElementRef<typeof AlertDialogPrimitive.Title>, | ||||
|   React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Title> | ||||
| >(({ className, ...props }, ref) => ( | ||||
|   <AlertDialogPrimitive.Title | ||||
|     ref={ref} | ||||
|     className={cn("text-lg font-semibold", className)} | ||||
|     {...props} | ||||
|   /> | ||||
| )) | ||||
| AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName | ||||
| 
 | ||||
| const AlertDialogDescription = React.forwardRef< | ||||
|   React.ElementRef<typeof AlertDialogPrimitive.Description>, | ||||
|   React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Description> | ||||
| >(({ className, ...props }, ref) => ( | ||||
|   <AlertDialogPrimitive.Description | ||||
|     ref={ref} | ||||
|     className={cn("text-sm text-muted-foreground", className)} | ||||
|     {...props} | ||||
|   /> | ||||
| )) | ||||
| AlertDialogDescription.displayName = | ||||
|   AlertDialogPrimitive.Description.displayName | ||||
| 
 | ||||
| const AlertDialogAction = React.forwardRef< | ||||
|   React.ElementRef<typeof AlertDialogPrimitive.Action>, | ||||
|   React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Action> | ||||
| >(({ className, ...props }, ref) => ( | ||||
|   <AlertDialogPrimitive.Action | ||||
|     ref={ref} | ||||
|     className={cn(buttonVariants(), className)} | ||||
|     {...props} | ||||
|   /> | ||||
| )) | ||||
| AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName | ||||
| 
 | ||||
| const AlertDialogCancel = React.forwardRef< | ||||
|   React.ElementRef<typeof AlertDialogPrimitive.Cancel>, | ||||
|   React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Cancel> | ||||
| >(({ className, ...props }, ref) => ( | ||||
|   <AlertDialogPrimitive.Cancel | ||||
|     ref={ref} | ||||
|     className={cn( | ||||
|       buttonVariants({ variant: "outline" }), | ||||
|       "mt-2 sm:mt-0", | ||||
|       className | ||||
|     )} | ||||
|     {...props} | ||||
|   /> | ||||
| )) | ||||
| AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName | ||||
| 
 | ||||
| export { | ||||
|   AlertDialog, | ||||
|   AlertDialogPortal, | ||||
|   AlertDialogOverlay, | ||||
|   AlertDialogTrigger, | ||||
|   AlertDialogContent, | ||||
|   AlertDialogHeader, | ||||
|   AlertDialogFooter, | ||||
|   AlertDialogTitle, | ||||
|   AlertDialogDescription, | ||||
|   AlertDialogAction, | ||||
|   AlertDialogCancel, | ||||
| } | ||||
							
								
								
									
										59
									
								
								frontend/src/components/ui/alert.tsx
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										59
									
								
								frontend/src/components/ui/alert.tsx
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,59 @@ | |||
| import * as React from "react" | ||||
| import { cva, type VariantProps } from "class-variance-authority" | ||||
| 
 | ||||
| import { cn } from "@/lib/utils" | ||||
| 
 | ||||
| const alertVariants = cva( | ||||
|   "relative w-full rounded-lg border p-4 [&>svg~*]:pl-7 [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground", | ||||
|   { | ||||
|     variants: { | ||||
|       variant: { | ||||
|         default: "bg-background text-foreground", | ||||
|         destructive: | ||||
|           "border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive", | ||||
|       }, | ||||
|     }, | ||||
|     defaultVariants: { | ||||
|       variant: "default", | ||||
|     }, | ||||
|   } | ||||
| ) | ||||
| 
 | ||||
| const Alert = React.forwardRef< | ||||
|   HTMLDivElement, | ||||
|   React.HTMLAttributes<HTMLDivElement> & VariantProps<typeof alertVariants> | ||||
| >(({ className, variant, ...props }, ref) => ( | ||||
|   <div | ||||
|     ref={ref} | ||||
|     role="alert" | ||||
|     className={cn(alertVariants({ variant }), className)} | ||||
|     {...props} | ||||
|   /> | ||||
| )) | ||||
| Alert.displayName = "Alert" | ||||
| 
 | ||||
| const AlertTitle = React.forwardRef< | ||||
|   HTMLParagraphElement, | ||||
|   React.HTMLAttributes<HTMLHeadingElement> | ||||
| >(({ className, ...props }, ref) => ( | ||||
|   <h5 | ||||
|     ref={ref} | ||||
|     className={cn("mb-1 font-medium leading-none tracking-tight", className)} | ||||
|     {...props} | ||||
|   /> | ||||
| )) | ||||
| AlertTitle.displayName = "AlertTitle" | ||||
| 
 | ||||
| const AlertDescription = React.forwardRef< | ||||
|   HTMLParagraphElement, | ||||
|   React.HTMLAttributes<HTMLParagraphElement> | ||||
| >(({ className, ...props }, ref) => ( | ||||
|   <div | ||||
|     ref={ref} | ||||
|     className={cn("text-sm [&_p]:leading-relaxed", className)} | ||||
|     {...props} | ||||
|   /> | ||||
| )) | ||||
| AlertDescription.displayName = "AlertDescription" | ||||
| 
 | ||||
| export { Alert, AlertTitle, AlertDescription } | ||||
							
								
								
									
										5
									
								
								frontend/src/components/ui/aspect-ratio.tsx
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										5
									
								
								frontend/src/components/ui/aspect-ratio.tsx
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,5 @@ | |||
| import * as AspectRatioPrimitive from "@radix-ui/react-aspect-ratio" | ||||
| 
 | ||||
| const AspectRatio = AspectRatioPrimitive.Root | ||||
| 
 | ||||
| export { AspectRatio } | ||||
							
								
								
									
										48
									
								
								frontend/src/components/ui/avatar.tsx
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										48
									
								
								frontend/src/components/ui/avatar.tsx
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,48 @@ | |||
| import * as React from "react" | ||||
| import * as AvatarPrimitive from "@radix-ui/react-avatar" | ||||
| 
 | ||||
| import { cn } from "@/lib/utils" | ||||
| 
 | ||||
| const Avatar = React.forwardRef< | ||||
|   React.ElementRef<typeof AvatarPrimitive.Root>, | ||||
|   React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Root> | ||||
| >(({ className, ...props }, ref) => ( | ||||
|   <AvatarPrimitive.Root | ||||
|     ref={ref} | ||||
|     className={cn( | ||||
|       "relative flex h-10 w-10 shrink-0 overflow-hidden rounded-full", | ||||
|       className | ||||
|     )} | ||||
|     {...props} | ||||
|   /> | ||||
| )) | ||||
| Avatar.displayName = AvatarPrimitive.Root.displayName | ||||
| 
 | ||||
| const AvatarImage = React.forwardRef< | ||||
|   React.ElementRef<typeof AvatarPrimitive.Image>, | ||||
|   React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Image> | ||||
| >(({ className, ...props }, ref) => ( | ||||
|   <AvatarPrimitive.Image | ||||
|     ref={ref} | ||||
|     className={cn("aspect-square h-full w-full", className)} | ||||
|     {...props} | ||||
|   /> | ||||
| )) | ||||
| AvatarImage.displayName = AvatarPrimitive.Image.displayName | ||||
| 
 | ||||
| const AvatarFallback = React.forwardRef< | ||||
|   React.ElementRef<typeof AvatarPrimitive.Fallback>, | ||||
|   React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Fallback> | ||||
| >(({ className, ...props }, ref) => ( | ||||
|   <AvatarPrimitive.Fallback | ||||
|     ref={ref} | ||||
|     className={cn( | ||||
|       "flex h-full w-full items-center justify-center rounded-full bg-muted", | ||||
|       className | ||||
|     )} | ||||
|     {...props} | ||||
|   /> | ||||
| )) | ||||
| AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName | ||||
| 
 | ||||
| export { Avatar, AvatarImage, AvatarFallback } | ||||
							
								
								
									
										36
									
								
								frontend/src/components/ui/badge.tsx
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										36
									
								
								frontend/src/components/ui/badge.tsx
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,36 @@ | |||
| import * as React from "react" | ||||
| import { cva, type VariantProps } from "class-variance-authority" | ||||
| 
 | ||||
| import { cn } from "@/lib/utils" | ||||
| 
 | ||||
| const badgeVariants = cva( | ||||
|   "inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2", | ||||
|   { | ||||
|     variants: { | ||||
|       variant: { | ||||
|         default: | ||||
|           "border-transparent bg-primary text-primary-foreground hover:bg-primary/80", | ||||
|         secondary: | ||||
|           "border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80", | ||||
|         destructive: | ||||
|           "border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80", | ||||
|         outline: "text-foreground", | ||||
|       }, | ||||
|     }, | ||||
|     defaultVariants: { | ||||
|       variant: "default", | ||||
|     }, | ||||
|   } | ||||
| ) | ||||
| 
 | ||||
| export interface BadgeProps | ||||
|   extends React.HTMLAttributes<HTMLDivElement>, | ||||
|     VariantProps<typeof badgeVariants> {} | ||||
| 
 | ||||
| function Badge({ className, variant, ...props }: BadgeProps) { | ||||
|   return ( | ||||
|     <div className={cn(badgeVariants({ variant }), className)} {...props} /> | ||||
|   ) | ||||
| } | ||||
| 
 | ||||
| export { Badge, badgeVariants } | ||||
							
								
								
									
										115
									
								
								frontend/src/components/ui/breadcrumb.tsx
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										115
									
								
								frontend/src/components/ui/breadcrumb.tsx
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,115 @@ | |||
| import * as React from "react" | ||||
| import { Slot } from "@radix-ui/react-slot" | ||||
| import { ChevronRight, MoreHorizontal } from "lucide-react" | ||||
| 
 | ||||
| import { cn } from "@/lib/utils" | ||||
| 
 | ||||
| const Breadcrumb = React.forwardRef< | ||||
|   HTMLElement, | ||||
|   React.ComponentPropsWithoutRef<"nav"> & { | ||||
|     separator?: React.ReactNode | ||||
|   } | ||||
| >(({ ...props }, ref) => <nav ref={ref} aria-label="breadcrumb" {...props} />) | ||||
| Breadcrumb.displayName = "Breadcrumb" | ||||
| 
 | ||||
| const BreadcrumbList = React.forwardRef< | ||||
|   HTMLOListElement, | ||||
|   React.ComponentPropsWithoutRef<"ol"> | ||||
| >(({ className, ...props }, ref) => ( | ||||
|   <ol | ||||
|     ref={ref} | ||||
|     className={cn( | ||||
|       "flex flex-wrap items-center gap-1.5 break-words text-sm text-muted-foreground sm:gap-2.5", | ||||
|       className | ||||
|     )} | ||||
|     {...props} | ||||
|   /> | ||||
| )) | ||||
| BreadcrumbList.displayName = "BreadcrumbList" | ||||
| 
 | ||||
| const BreadcrumbItem = React.forwardRef< | ||||
|   HTMLLIElement, | ||||
|   React.ComponentPropsWithoutRef<"li"> | ||||
| >(({ className, ...props }, ref) => ( | ||||
|   <li | ||||
|     ref={ref} | ||||
|     className={cn("inline-flex items-center gap-1.5", className)} | ||||
|     {...props} | ||||
|   /> | ||||
| )) | ||||
| BreadcrumbItem.displayName = "BreadcrumbItem" | ||||
| 
 | ||||
| const BreadcrumbLink = React.forwardRef< | ||||
|   HTMLAnchorElement, | ||||
|   React.ComponentPropsWithoutRef<"a"> & { | ||||
|     asChild?: boolean | ||||
|   } | ||||
| >(({ asChild, className, ...props }, ref) => { | ||||
|   const Comp = asChild ? Slot : "a" | ||||
| 
 | ||||
|   return ( | ||||
|     <Comp | ||||
|       ref={ref} | ||||
|       className={cn("transition-colors hover:text-foreground", className)} | ||||
|       {...props} | ||||
|     /> | ||||
|   ) | ||||
| }) | ||||
| BreadcrumbLink.displayName = "BreadcrumbLink" | ||||
| 
 | ||||
| const BreadcrumbPage = React.forwardRef< | ||||
|   HTMLSpanElement, | ||||
|   React.ComponentPropsWithoutRef<"span"> | ||||
| >(({ className, ...props }, ref) => ( | ||||
|   <span | ||||
|     ref={ref} | ||||
|     role="link" | ||||
|     aria-disabled="true" | ||||
|     aria-current="page" | ||||
|     className={cn("font-normal text-foreground", className)} | ||||
|     {...props} | ||||
|   /> | ||||
| )) | ||||
| BreadcrumbPage.displayName = "BreadcrumbPage" | ||||
| 
 | ||||
| const BreadcrumbSeparator = ({ | ||||
|   children, | ||||
|   className, | ||||
|   ...props | ||||
| }: React.ComponentProps<"li">) => ( | ||||
|   <li | ||||
|     role="presentation" | ||||
|     aria-hidden="true" | ||||
|     className={cn("[&>svg]:size-3.5", className)} | ||||
|     {...props} | ||||
|   > | ||||
|     {children ?? <ChevronRight />} | ||||
|   </li> | ||||
| ) | ||||
| BreadcrumbSeparator.displayName = "BreadcrumbSeparator" | ||||
| 
 | ||||
| const BreadcrumbEllipsis = ({ | ||||
|   className, | ||||
|   ...props | ||||
| }: React.ComponentProps<"span">) => ( | ||||
|   <span | ||||
|     role="presentation" | ||||
|     aria-hidden="true" | ||||
|     className={cn("flex h-9 w-9 items-center justify-center", className)} | ||||
|     {...props} | ||||
|   > | ||||
|     <MoreHorizontal className="h-4 w-4" /> | ||||
|     <span className="sr-only">More</span> | ||||
|   </span> | ||||
| ) | ||||
| BreadcrumbEllipsis.displayName = "BreadcrumbElipssis" | ||||
| 
 | ||||
| export { | ||||
|   Breadcrumb, | ||||
|   BreadcrumbList, | ||||
|   BreadcrumbItem, | ||||
|   BreadcrumbLink, | ||||
|   BreadcrumbPage, | ||||
|   BreadcrumbSeparator, | ||||
|   BreadcrumbEllipsis, | ||||
| } | ||||
							
								
								
									
										56
									
								
								frontend/src/components/ui/button.tsx
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										56
									
								
								frontend/src/components/ui/button.tsx
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,56 @@ | |||
| import * as React from "react" | ||||
| import { Slot } from "@radix-ui/react-slot" | ||||
| import { cva, type VariantProps } from "class-variance-authority" | ||||
| 
 | ||||
| import { cn } from "@/lib/utils" | ||||
| 
 | ||||
| const buttonVariants = cva( | ||||
|   "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0", | ||||
|   { | ||||
|     variants: { | ||||
|       variant: { | ||||
|         default: "bg-primary text-primary-foreground hover:bg-primary/90", | ||||
|         destructive: | ||||
|           "bg-destructive text-destructive-foreground hover:bg-destructive/90", | ||||
|         outline: | ||||
|           "border border-input bg-background hover:bg-accent hover:text-accent-foreground", | ||||
|         secondary: | ||||
|           "bg-secondary text-secondary-foreground hover:bg-secondary/80", | ||||
|         ghost: "hover:bg-accent hover:text-accent-foreground", | ||||
|         link: "text-primary underline-offset-4 hover:underline", | ||||
|       }, | ||||
|       size: { | ||||
|         default: "h-10 px-4 py-2", | ||||
|         sm: "h-9 rounded-md px-3", | ||||
|         lg: "h-11 rounded-md px-8", | ||||
|         icon: "h-10 w-10", | ||||
|       }, | ||||
|     }, | ||||
|     defaultVariants: { | ||||
|       variant: "default", | ||||
|       size: "default", | ||||
|     }, | ||||
|   } | ||||
| ) | ||||
| 
 | ||||
| export interface ButtonProps | ||||
|   extends React.ButtonHTMLAttributes<HTMLButtonElement>, | ||||
|     VariantProps<typeof buttonVariants> { | ||||
|   asChild?: boolean | ||||
| } | ||||
| 
 | ||||
| const Button = React.forwardRef<HTMLButtonElement, ButtonProps>( | ||||
|   ({ className, variant, size, asChild = false, ...props }, ref) => { | ||||
|     const Comp = asChild ? Slot : "button" | ||||
|     return ( | ||||
|       <Comp | ||||
|         className={cn(buttonVariants({ variant, size, className }))} | ||||
|         ref={ref} | ||||
|         {...props} | ||||
|       /> | ||||
|     ) | ||||
|   } | ||||
| ) | ||||
| Button.displayName = "Button" | ||||
| 
 | ||||
| export { Button, buttonVariants } | ||||
							
								
								
									
										64
									
								
								frontend/src/components/ui/calendar.tsx
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										64
									
								
								frontend/src/components/ui/calendar.tsx
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,64 @@ | |||
| import * as React from "react"; | ||||
| import { ChevronLeft, ChevronRight } from "lucide-react"; | ||||
| import { DayPicker } from "react-day-picker"; | ||||
| 
 | ||||
| import { cn } from "@/lib/utils"; | ||||
| import { buttonVariants } from "@/components/ui/button"; | ||||
| 
 | ||||
| export type CalendarProps = React.ComponentProps<typeof DayPicker>; | ||||
| 
 | ||||
| function Calendar({ | ||||
|   className, | ||||
|   classNames, | ||||
|   showOutsideDays = true, | ||||
|   ...props | ||||
| }: CalendarProps) { | ||||
|   return ( | ||||
|     <DayPicker | ||||
|       showOutsideDays={showOutsideDays} | ||||
|       className={cn("p-3", className)} | ||||
|       classNames={{ | ||||
|         months: "flex flex-col sm:flex-row space-y-4 sm:space-x-4 sm:space-y-0", | ||||
|         month: "space-y-4", | ||||
|         caption: "flex justify-center pt-1 relative items-center", | ||||
|         caption_label: "text-sm font-medium", | ||||
|         nav: "space-x-1 flex items-center", | ||||
|         nav_button: cn( | ||||
|           buttonVariants({ variant: "outline" }), | ||||
|           "h-7 w-7 bg-transparent p-0 opacity-50 hover:opacity-100" | ||||
|         ), | ||||
|         nav_button_previous: "absolute left-1", | ||||
|         nav_button_next: "absolute right-1", | ||||
|         table: "w-full border-collapse space-y-1", | ||||
|         head_row: "flex", | ||||
|         head_cell: | ||||
|           "text-muted-foreground rounded-md w-9 font-normal text-[0.8rem]", | ||||
|         row: "flex w-full mt-2", | ||||
|         cell: "h-9 w-9 text-center text-sm p-0 relative [&:has([aria-selected].day-range-end)]:rounded-r-md [&:has([aria-selected].day-outside)]:bg-accent/50 [&:has([aria-selected])]:bg-accent first:[&:has([aria-selected])]:rounded-l-md last:[&:has([aria-selected])]:rounded-r-md focus-within:relative focus-within:z-20", | ||||
|         day: cn( | ||||
|           buttonVariants({ variant: "ghost" }), | ||||
|           "h-9 w-9 p-0 font-normal aria-selected:opacity-100" | ||||
|         ), | ||||
|         day_range_end: "day-range-end", | ||||
|         day_selected: | ||||
|           "bg-primary text-primary-foreground hover:bg-primary hover:text-primary-foreground focus:bg-primary focus:text-primary-foreground", | ||||
|         day_today: "bg-accent text-accent-foreground", | ||||
|         day_outside: | ||||
|           "day-outside text-muted-foreground opacity-50 aria-selected:bg-accent/50 aria-selected:text-muted-foreground aria-selected:opacity-30", | ||||
|         day_disabled: "text-muted-foreground opacity-50", | ||||
|         day_range_middle: | ||||
|           "aria-selected:bg-accent aria-selected:text-accent-foreground", | ||||
|         day_hidden: "invisible", | ||||
|         ...classNames, | ||||
|       }} | ||||
|       components={{ | ||||
|         IconLeft: ({ ..._props }) => <ChevronLeft className="h-4 w-4" />, | ||||
|         IconRight: ({ ..._props }) => <ChevronRight className="h-4 w-4" />, | ||||
|       }} | ||||
|       {...props} | ||||
|     /> | ||||
|   ); | ||||
| } | ||||
| Calendar.displayName = "Calendar"; | ||||
| 
 | ||||
| export { Calendar }; | ||||
							
								
								
									
										79
									
								
								frontend/src/components/ui/card.tsx
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										79
									
								
								frontend/src/components/ui/card.tsx
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,79 @@ | |||
| import * as React from "react" | ||||
| 
 | ||||
| import { cn } from "@/lib/utils" | ||||
| 
 | ||||
| const Card = React.forwardRef< | ||||
|   HTMLDivElement, | ||||
|   React.HTMLAttributes<HTMLDivElement> | ||||
| >(({ className, ...props }, ref) => ( | ||||
|   <div | ||||
|     ref={ref} | ||||
|     className={cn( | ||||
|       "rounded-lg border bg-card text-card-foreground shadow-sm", | ||||
|       className | ||||
|     )} | ||||
|     {...props} | ||||
|   /> | ||||
| )) | ||||
| Card.displayName = "Card" | ||||
| 
 | ||||
| const CardHeader = React.forwardRef< | ||||
|   HTMLDivElement, | ||||
|   React.HTMLAttributes<HTMLDivElement> | ||||
| >(({ className, ...props }, ref) => ( | ||||
|   <div | ||||
|     ref={ref} | ||||
|     className={cn("flex flex-col space-y-1.5 p-6", className)} | ||||
|     {...props} | ||||
|   /> | ||||
| )) | ||||
| CardHeader.displayName = "CardHeader" | ||||
| 
 | ||||
| const CardTitle = React.forwardRef< | ||||
|   HTMLParagraphElement, | ||||
|   React.HTMLAttributes<HTMLHeadingElement> | ||||
| >(({ className, ...props }, ref) => ( | ||||
|   <h3 | ||||
|     ref={ref} | ||||
|     className={cn( | ||||
|       "text-2xl font-semibold leading-none tracking-tight", | ||||
|       className | ||||
|     )} | ||||
|     {...props} | ||||
|   /> | ||||
| )) | ||||
| CardTitle.displayName = "CardTitle" | ||||
| 
 | ||||
| const CardDescription = React.forwardRef< | ||||
|   HTMLParagraphElement, | ||||
|   React.HTMLAttributes<HTMLParagraphElement> | ||||
| >(({ className, ...props }, ref) => ( | ||||
|   <p | ||||
|     ref={ref} | ||||
|     className={cn("text-sm text-muted-foreground", className)} | ||||
|     {...props} | ||||
|   /> | ||||
| )) | ||||
| CardDescription.displayName = "CardDescription" | ||||
| 
 | ||||
| const CardContent = React.forwardRef< | ||||
|   HTMLDivElement, | ||||
|   React.HTMLAttributes<HTMLDivElement> | ||||
| >(({ className, ...props }, ref) => ( | ||||
|   <div ref={ref} className={cn("p-6 pt-0", className)} {...props} /> | ||||
| )) | ||||
| CardContent.displayName = "CardContent" | ||||
| 
 | ||||
| const CardFooter = React.forwardRef< | ||||
|   HTMLDivElement, | ||||
|   React.HTMLAttributes<HTMLDivElement> | ||||
| >(({ className, ...props }, ref) => ( | ||||
|   <div | ||||
|     ref={ref} | ||||
|     className={cn("flex items-center p-6 pt-0", className)} | ||||
|     {...props} | ||||
|   /> | ||||
| )) | ||||
| CardFooter.displayName = "CardFooter" | ||||
| 
 | ||||
| export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent } | ||||
							
								
								
									
										260
									
								
								frontend/src/components/ui/carousel.tsx
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										260
									
								
								frontend/src/components/ui/carousel.tsx
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,260 @@ | |||
| import * as React from "react" | ||||
| import useEmblaCarousel, { | ||||
|   type UseEmblaCarouselType, | ||||
| } from "embla-carousel-react" | ||||
| import { ArrowLeft, ArrowRight } from "lucide-react" | ||||
| 
 | ||||
| import { cn } from "@/lib/utils" | ||||
| import { Button } from "@/components/ui/button" | ||||
| 
 | ||||
| type CarouselApi = UseEmblaCarouselType[1] | ||||
| type UseCarouselParameters = Parameters<typeof useEmblaCarousel> | ||||
| type CarouselOptions = UseCarouselParameters[0] | ||||
| type CarouselPlugin = UseCarouselParameters[1] | ||||
| 
 | ||||
| type CarouselProps = { | ||||
|   opts?: CarouselOptions | ||||
|   plugins?: CarouselPlugin | ||||
|   orientation?: "horizontal" | "vertical" | ||||
|   setApi?: (api: CarouselApi) => void | ||||
| } | ||||
| 
 | ||||
| type CarouselContextProps = { | ||||
|   carouselRef: ReturnType<typeof useEmblaCarousel>[0] | ||||
|   api: ReturnType<typeof useEmblaCarousel>[1] | ||||
|   scrollPrev: () => void | ||||
|   scrollNext: () => void | ||||
|   canScrollPrev: boolean | ||||
|   canScrollNext: boolean | ||||
| } & CarouselProps | ||||
| 
 | ||||
| const CarouselContext = React.createContext<CarouselContextProps | null>(null) | ||||
| 
 | ||||
| function useCarousel() { | ||||
|   const context = React.useContext(CarouselContext) | ||||
| 
 | ||||
|   if (!context) { | ||||
|     throw new Error("useCarousel must be used within a <Carousel />") | ||||
|   } | ||||
| 
 | ||||
|   return context | ||||
| } | ||||
| 
 | ||||
| const Carousel = React.forwardRef< | ||||
|   HTMLDivElement, | ||||
|   React.HTMLAttributes<HTMLDivElement> & CarouselProps | ||||
| >( | ||||
|   ( | ||||
|     { | ||||
|       orientation = "horizontal", | ||||
|       opts, | ||||
|       setApi, | ||||
|       plugins, | ||||
|       className, | ||||
|       children, | ||||
|       ...props | ||||
|     }, | ||||
|     ref | ||||
|   ) => { | ||||
|     const [carouselRef, api] = useEmblaCarousel( | ||||
|       { | ||||
|         ...opts, | ||||
|         axis: orientation === "horizontal" ? "x" : "y", | ||||
|       }, | ||||
|       plugins | ||||
|     ) | ||||
|     const [canScrollPrev, setCanScrollPrev] = React.useState(false) | ||||
|     const [canScrollNext, setCanScrollNext] = React.useState(false) | ||||
| 
 | ||||
|     const onSelect = React.useCallback((api: CarouselApi) => { | ||||
|       if (!api) { | ||||
|         return | ||||
|       } | ||||
| 
 | ||||
|       setCanScrollPrev(api.canScrollPrev()) | ||||
|       setCanScrollNext(api.canScrollNext()) | ||||
|     }, []) | ||||
| 
 | ||||
|     const scrollPrev = React.useCallback(() => { | ||||
|       api?.scrollPrev() | ||||
|     }, [api]) | ||||
| 
 | ||||
|     const scrollNext = React.useCallback(() => { | ||||
|       api?.scrollNext() | ||||
|     }, [api]) | ||||
| 
 | ||||
|     const handleKeyDown = React.useCallback( | ||||
|       (event: React.KeyboardEvent<HTMLDivElement>) => { | ||||
|         if (event.key === "ArrowLeft") { | ||||
|           event.preventDefault() | ||||
|           scrollPrev() | ||||
|         } else if (event.key === "ArrowRight") { | ||||
|           event.preventDefault() | ||||
|           scrollNext() | ||||
|         } | ||||
|       }, | ||||
|       [scrollPrev, scrollNext] | ||||
|     ) | ||||
| 
 | ||||
|     React.useEffect(() => { | ||||
|       if (!api || !setApi) { | ||||
|         return | ||||
|       } | ||||
| 
 | ||||
|       setApi(api) | ||||
|     }, [api, setApi]) | ||||
| 
 | ||||
|     React.useEffect(() => { | ||||
|       if (!api) { | ||||
|         return | ||||
|       } | ||||
| 
 | ||||
|       onSelect(api) | ||||
|       api.on("reInit", onSelect) | ||||
|       api.on("select", onSelect) | ||||
| 
 | ||||
|       return () => { | ||||
|         api?.off("select", onSelect) | ||||
|       } | ||||
|     }, [api, onSelect]) | ||||
| 
 | ||||
|     return ( | ||||
|       <CarouselContext.Provider | ||||
|         value={{ | ||||
|           carouselRef, | ||||
|           api: api, | ||||
|           opts, | ||||
|           orientation: | ||||
|             orientation || (opts?.axis === "y" ? "vertical" : "horizontal"), | ||||
|           scrollPrev, | ||||
|           scrollNext, | ||||
|           canScrollPrev, | ||||
|           canScrollNext, | ||||
|         }} | ||||
|       > | ||||
|         <div | ||||
|           ref={ref} | ||||
|           onKeyDownCapture={handleKeyDown} | ||||
|           className={cn("relative", className)} | ||||
|           role="region" | ||||
|           aria-roledescription="carousel" | ||||
|           {...props} | ||||
|         > | ||||
|           {children} | ||||
|         </div> | ||||
|       </CarouselContext.Provider> | ||||
|     ) | ||||
|   } | ||||
| ) | ||||
| Carousel.displayName = "Carousel" | ||||
| 
 | ||||
| const CarouselContent = React.forwardRef< | ||||
|   HTMLDivElement, | ||||
|   React.HTMLAttributes<HTMLDivElement> | ||||
| >(({ className, ...props }, ref) => { | ||||
|   const { carouselRef, orientation } = useCarousel() | ||||
| 
 | ||||
|   return ( | ||||
|     <div ref={carouselRef} className="overflow-hidden"> | ||||
|       <div | ||||
|         ref={ref} | ||||
|         className={cn( | ||||
|           "flex", | ||||
|           orientation === "horizontal" ? "-ml-4" : "-mt-4 flex-col", | ||||
|           className | ||||
|         )} | ||||
|         {...props} | ||||
|       /> | ||||
|     </div> | ||||
|   ) | ||||
| }) | ||||
| CarouselContent.displayName = "CarouselContent" | ||||
| 
 | ||||
| const CarouselItem = React.forwardRef< | ||||
|   HTMLDivElement, | ||||
|   React.HTMLAttributes<HTMLDivElement> | ||||
| >(({ className, ...props }, ref) => { | ||||
|   const { orientation } = useCarousel() | ||||
| 
 | ||||
|   return ( | ||||
|     <div | ||||
|       ref={ref} | ||||
|       role="group" | ||||
|       aria-roledescription="slide" | ||||
|       className={cn( | ||||
|         "min-w-0 shrink-0 grow-0 basis-full", | ||||
|         orientation === "horizontal" ? "pl-4" : "pt-4", | ||||
|         className | ||||
|       )} | ||||
|       {...props} | ||||
|     /> | ||||
|   ) | ||||
| }) | ||||
| CarouselItem.displayName = "CarouselItem" | ||||
| 
 | ||||
| const CarouselPrevious = React.forwardRef< | ||||
|   HTMLButtonElement, | ||||
|   React.ComponentProps<typeof Button> | ||||
| >(({ className, variant = "outline", size = "icon", ...props }, ref) => { | ||||
|   const { orientation, scrollPrev, canScrollPrev } = useCarousel() | ||||
| 
 | ||||
|   return ( | ||||
|     <Button | ||||
|       ref={ref} | ||||
|       variant={variant} | ||||
|       size={size} | ||||
|       className={cn( | ||||
|         "absolute  h-8 w-8 rounded-full", | ||||
|         orientation === "horizontal" | ||||
|           ? "-left-12 top-1/2 -translate-y-1/2" | ||||
|           : "-top-12 left-1/2 -translate-x-1/2 rotate-90", | ||||
|         className | ||||
|       )} | ||||
|       disabled={!canScrollPrev} | ||||
|       onClick={scrollPrev} | ||||
|       {...props} | ||||
|     > | ||||
|       <ArrowLeft className="h-4 w-4" /> | ||||
|       <span className="sr-only">Previous slide</span> | ||||
|     </Button> | ||||
|   ) | ||||
| }) | ||||
| CarouselPrevious.displayName = "CarouselPrevious" | ||||
| 
 | ||||
| const CarouselNext = React.forwardRef< | ||||
|   HTMLButtonElement, | ||||
|   React.ComponentProps<typeof Button> | ||||
| >(({ className, variant = "outline", size = "icon", ...props }, ref) => { | ||||
|   const { orientation, scrollNext, canScrollNext } = useCarousel() | ||||
| 
 | ||||
|   return ( | ||||
|     <Button | ||||
|       ref={ref} | ||||
|       variant={variant} | ||||
|       size={size} | ||||
|       className={cn( | ||||
|         "absolute h-8 w-8 rounded-full", | ||||
|         orientation === "horizontal" | ||||
|           ? "-right-12 top-1/2 -translate-y-1/2" | ||||
|           : "-bottom-12 left-1/2 -translate-x-1/2 rotate-90", | ||||
|         className | ||||
|       )} | ||||
|       disabled={!canScrollNext} | ||||
|       onClick={scrollNext} | ||||
|       {...props} | ||||
|     > | ||||
|       <ArrowRight className="h-4 w-4" /> | ||||
|       <span className="sr-only">Next slide</span> | ||||
|     </Button> | ||||
|   ) | ||||
| }) | ||||
| CarouselNext.displayName = "CarouselNext" | ||||
| 
 | ||||
| export { | ||||
|   type CarouselApi, | ||||
|   Carousel, | ||||
|   CarouselContent, | ||||
|   CarouselItem, | ||||
|   CarouselPrevious, | ||||
|   CarouselNext, | ||||
| } | ||||
							
								
								
									
										363
									
								
								frontend/src/components/ui/chart.tsx
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										363
									
								
								frontend/src/components/ui/chart.tsx
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,363 @@ | |||
| import * as React from "react" | ||||
| import * as RechartsPrimitive from "recharts" | ||||
| 
 | ||||
| import { cn } from "@/lib/utils" | ||||
| 
 | ||||
| // Format: { THEME_NAME: CSS_SELECTOR }
 | ||||
| const THEMES = { light: "", dark: ".dark" } as const | ||||
| 
 | ||||
| export type ChartConfig = { | ||||
|   [k in string]: { | ||||
|     label?: React.ReactNode | ||||
|     icon?: React.ComponentType | ||||
|   } & ( | ||||
|     | { color?: string; theme?: never } | ||||
|     | { color?: never; theme: Record<keyof typeof THEMES, string> } | ||||
|   ) | ||||
| } | ||||
| 
 | ||||
| type ChartContextProps = { | ||||
|   config: ChartConfig | ||||
| } | ||||
| 
 | ||||
| const ChartContext = React.createContext<ChartContextProps | null>(null) | ||||
| 
 | ||||
| function useChart() { | ||||
|   const context = React.useContext(ChartContext) | ||||
| 
 | ||||
|   if (!context) { | ||||
|     throw new Error("useChart must be used within a <ChartContainer />") | ||||
|   } | ||||
| 
 | ||||
|   return context | ||||
| } | ||||
| 
 | ||||
| const ChartContainer = React.forwardRef< | ||||
|   HTMLDivElement, | ||||
|   React.ComponentProps<"div"> & { | ||||
|     config: ChartConfig | ||||
|     children: React.ComponentProps< | ||||
|       typeof RechartsPrimitive.ResponsiveContainer | ||||
|     >["children"] | ||||
|   } | ||||
| >(({ id, className, children, config, ...props }, ref) => { | ||||
|   const uniqueId = React.useId() | ||||
|   const chartId = `chart-${id || uniqueId.replace(/:/g, "")}` | ||||
| 
 | ||||
|   return ( | ||||
|     <ChartContext.Provider value={{ config }}> | ||||
|       <div | ||||
|         data-chart={chartId} | ||||
|         ref={ref} | ||||
|         className={cn( | ||||
|           "flex aspect-video justify-center text-xs [&_.recharts-cartesian-axis-tick_text]:fill-muted-foreground [&_.recharts-cartesian-grid_line[stroke='#ccc']]:stroke-border/50 [&_.recharts-curve.recharts-tooltip-cursor]:stroke-border [&_.recharts-dot[stroke='#fff']]:stroke-transparent [&_.recharts-layer]:outline-none [&_.recharts-polar-grid_[stroke='#ccc']]:stroke-border [&_.recharts-radial-bar-background-sector]:fill-muted [&_.recharts-rectangle.recharts-tooltip-cursor]:fill-muted [&_.recharts-reference-line_[stroke='#ccc']]:stroke-border [&_.recharts-sector[stroke='#fff']]:stroke-transparent [&_.recharts-sector]:outline-none [&_.recharts-surface]:outline-none", | ||||
|           className | ||||
|         )} | ||||
|         {...props} | ||||
|       > | ||||
|         <ChartStyle id={chartId} config={config} /> | ||||
|         <RechartsPrimitive.ResponsiveContainer> | ||||
|           {children} | ||||
|         </RechartsPrimitive.ResponsiveContainer> | ||||
|       </div> | ||||
|     </ChartContext.Provider> | ||||
|   ) | ||||
| }) | ||||
| ChartContainer.displayName = "Chart" | ||||
| 
 | ||||
| const ChartStyle = ({ id, config }: { id: string; config: ChartConfig }) => { | ||||
|   const colorConfig = Object.entries(config).filter( | ||||
|     ([_, config]) => config.theme || config.color | ||||
|   ) | ||||
| 
 | ||||
|   if (!colorConfig.length) { | ||||
|     return null | ||||
|   } | ||||
| 
 | ||||
|   return ( | ||||
|     <style | ||||
|       dangerouslySetInnerHTML={{ | ||||
|         __html: Object.entries(THEMES) | ||||
|           .map( | ||||
|             ([theme, prefix]) => ` | ||||
| ${prefix} [data-chart=${id}] { | ||||
| ${colorConfig | ||||
|   .map(([key, itemConfig]) => { | ||||
|     const color = | ||||
|       itemConfig.theme?.[theme as keyof typeof itemConfig.theme] || | ||||
|       itemConfig.color | ||||
|     return color ? `  --color-${key}: ${color};` : null | ||||
|   }) | ||||
|   .join("\n")} | ||||
| } | ||||
| ` | ||||
|           ) | ||||
|           .join("\n"), | ||||
|       }} | ||||
|     /> | ||||
|   ) | ||||
| } | ||||
| 
 | ||||
| const ChartTooltip = RechartsPrimitive.Tooltip | ||||
| 
 | ||||
| const ChartTooltipContent = React.forwardRef< | ||||
|   HTMLDivElement, | ||||
|   React.ComponentProps<typeof RechartsPrimitive.Tooltip> & | ||||
|     React.ComponentProps<"div"> & { | ||||
|       hideLabel?: boolean | ||||
|       hideIndicator?: boolean | ||||
|       indicator?: "line" | "dot" | "dashed" | ||||
|       nameKey?: string | ||||
|       labelKey?: string | ||||
|     } | ||||
| >( | ||||
|   ( | ||||
|     { | ||||
|       active, | ||||
|       payload, | ||||
|       className, | ||||
|       indicator = "dot", | ||||
|       hideLabel = false, | ||||
|       hideIndicator = false, | ||||
|       label, | ||||
|       labelFormatter, | ||||
|       labelClassName, | ||||
|       formatter, | ||||
|       color, | ||||
|       nameKey, | ||||
|       labelKey, | ||||
|     }, | ||||
|     ref | ||||
|   ) => { | ||||
|     const { config } = useChart() | ||||
| 
 | ||||
|     const tooltipLabel = React.useMemo(() => { | ||||
|       if (hideLabel || !payload?.length) { | ||||
|         return null | ||||
|       } | ||||
| 
 | ||||
|       const [item] = payload | ||||
|       const key = `${labelKey || item.dataKey || item.name || "value"}` | ||||
|       const itemConfig = getPayloadConfigFromPayload(config, item, key) | ||||
|       const value = | ||||
|         !labelKey && typeof label === "string" | ||||
|           ? config[label as keyof typeof config]?.label || label | ||||
|           : itemConfig?.label | ||||
| 
 | ||||
|       if (labelFormatter) { | ||||
|         return ( | ||||
|           <div className={cn("font-medium", labelClassName)}> | ||||
|             {labelFormatter(value, payload)} | ||||
|           </div> | ||||
|         ) | ||||
|       } | ||||
| 
 | ||||
|       if (!value) { | ||||
|         return null | ||||
|       } | ||||
| 
 | ||||
|       return <div className={cn("font-medium", labelClassName)}>{value}</div> | ||||
|     }, [ | ||||
|       label, | ||||
|       labelFormatter, | ||||
|       payload, | ||||
|       hideLabel, | ||||
|       labelClassName, | ||||
|       config, | ||||
|       labelKey, | ||||
|     ]) | ||||
| 
 | ||||
|     if (!active || !payload?.length) { | ||||
|       return null | ||||
|     } | ||||
| 
 | ||||
|     const nestLabel = payload.length === 1 && indicator !== "dot" | ||||
| 
 | ||||
|     return ( | ||||
|       <div | ||||
|         ref={ref} | ||||
|         className={cn( | ||||
|           "grid min-w-[8rem] items-start gap-1.5 rounded-lg border border-border/50 bg-background px-2.5 py-1.5 text-xs shadow-xl", | ||||
|           className | ||||
|         )} | ||||
|       > | ||||
|         {!nestLabel ? tooltipLabel : null} | ||||
|         <div className="grid gap-1.5"> | ||||
|           {payload.map((item, index) => { | ||||
|             const key = `${nameKey || item.name || item.dataKey || "value"}` | ||||
|             const itemConfig = getPayloadConfigFromPayload(config, item, key) | ||||
|             const indicatorColor = color || item.payload.fill || item.color | ||||
| 
 | ||||
|             return ( | ||||
|               <div | ||||
|                 key={item.dataKey} | ||||
|                 className={cn( | ||||
|                   "flex w-full flex-wrap items-stretch gap-2 [&>svg]:h-2.5 [&>svg]:w-2.5 [&>svg]:text-muted-foreground", | ||||
|                   indicator === "dot" && "items-center" | ||||
|                 )} | ||||
|               > | ||||
|                 {formatter && item?.value !== undefined && item.name ? ( | ||||
|                   formatter(item.value, item.name, item, index, item.payload) | ||||
|                 ) : ( | ||||
|                   <> | ||||
|                     {itemConfig?.icon ? ( | ||||
|                       <itemConfig.icon /> | ||||
|                     ) : ( | ||||
|                       !hideIndicator && ( | ||||
|                         <div | ||||
|                           className={cn( | ||||
|                             "shrink-0 rounded-[2px] border-[--color-border] bg-[--color-bg]", | ||||
|                             { | ||||
|                               "h-2.5 w-2.5": indicator === "dot", | ||||
|                               "w-1": indicator === "line", | ||||
|                               "w-0 border-[1.5px] border-dashed bg-transparent": | ||||
|                                 indicator === "dashed", | ||||
|                               "my-0.5": nestLabel && indicator === "dashed", | ||||
|                             } | ||||
|                           )} | ||||
|                           style={ | ||||
|                             { | ||||
|                               "--color-bg": indicatorColor, | ||||
|                               "--color-border": indicatorColor, | ||||
|                             } as React.CSSProperties | ||||
|                           } | ||||
|                         /> | ||||
|                       ) | ||||
|                     )} | ||||
|                     <div | ||||
|                       className={cn( | ||||
|                         "flex flex-1 justify-between leading-none", | ||||
|                         nestLabel ? "items-end" : "items-center" | ||||
|                       )} | ||||
|                     > | ||||
|                       <div className="grid gap-1.5"> | ||||
|                         {nestLabel ? tooltipLabel : null} | ||||
|                         <span className="text-muted-foreground"> | ||||
|                           {itemConfig?.label || item.name} | ||||
|                         </span> | ||||
|                       </div> | ||||
|                       {item.value && ( | ||||
|                         <span className="font-mono font-medium tabular-nums text-foreground"> | ||||
|                           {item.value.toLocaleString()} | ||||
|                         </span> | ||||
|                       )} | ||||
|                     </div> | ||||
|                   </> | ||||
|                 )} | ||||
|               </div> | ||||
|             ) | ||||
|           })} | ||||
|         </div> | ||||
|       </div> | ||||
|     ) | ||||
|   } | ||||
| ) | ||||
| ChartTooltipContent.displayName = "ChartTooltip" | ||||
| 
 | ||||
| const ChartLegend = RechartsPrimitive.Legend | ||||
| 
 | ||||
| const ChartLegendContent = React.forwardRef< | ||||
|   HTMLDivElement, | ||||
|   React.ComponentProps<"div"> & | ||||
|     Pick<RechartsPrimitive.LegendProps, "payload" | "verticalAlign"> & { | ||||
|       hideIcon?: boolean | ||||
|       nameKey?: string | ||||
|     } | ||||
| >( | ||||
|   ( | ||||
|     { className, hideIcon = false, payload, verticalAlign = "bottom", nameKey }, | ||||
|     ref | ||||
|   ) => { | ||||
|     const { config } = useChart() | ||||
| 
 | ||||
|     if (!payload?.length) { | ||||
|       return null | ||||
|     } | ||||
| 
 | ||||
|     return ( | ||||
|       <div | ||||
|         ref={ref} | ||||
|         className={cn( | ||||
|           "flex items-center justify-center gap-4", | ||||
|           verticalAlign === "top" ? "pb-3" : "pt-3", | ||||
|           className | ||||
|         )} | ||||
|       > | ||||
|         {payload.map((item) => { | ||||
|           const key = `${nameKey || item.dataKey || "value"}` | ||||
|           const itemConfig = getPayloadConfigFromPayload(config, item, key) | ||||
| 
 | ||||
|           return ( | ||||
|             <div | ||||
|               key={item.value} | ||||
|               className={cn( | ||||
|                 "flex items-center gap-1.5 [&>svg]:h-3 [&>svg]:w-3 [&>svg]:text-muted-foreground" | ||||
|               )} | ||||
|             > | ||||
|               {itemConfig?.icon && !hideIcon ? ( | ||||
|                 <itemConfig.icon /> | ||||
|               ) : ( | ||||
|                 <div | ||||
|                   className="h-2 w-2 shrink-0 rounded-[2px]" | ||||
|                   style={{ | ||||
|                     backgroundColor: item.color, | ||||
|                   }} | ||||
|                 /> | ||||
|               )} | ||||
|               {itemConfig?.label} | ||||
|             </div> | ||||
|           ) | ||||
|         })} | ||||
|       </div> | ||||
|     ) | ||||
|   } | ||||
| ) | ||||
| ChartLegendContent.displayName = "ChartLegend" | ||||
| 
 | ||||
| // Helper to extract item config from a payload.
 | ||||
| function getPayloadConfigFromPayload( | ||||
|   config: ChartConfig, | ||||
|   payload: unknown, | ||||
|   key: string | ||||
| ) { | ||||
|   if (typeof payload !== "object" || payload === null) { | ||||
|     return undefined | ||||
|   } | ||||
| 
 | ||||
|   const payloadPayload = | ||||
|     "payload" in payload && | ||||
|     typeof payload.payload === "object" && | ||||
|     payload.payload !== null | ||||
|       ? payload.payload | ||||
|       : undefined | ||||
| 
 | ||||
|   let configLabelKey: string = key | ||||
| 
 | ||||
|   if ( | ||||
|     key in payload && | ||||
|     typeof payload[key as keyof typeof payload] === "string" | ||||
|   ) { | ||||
|     configLabelKey = payload[key as keyof typeof payload] as string | ||||
|   } else if ( | ||||
|     payloadPayload && | ||||
|     key in payloadPayload && | ||||
|     typeof payloadPayload[key as keyof typeof payloadPayload] === "string" | ||||
|   ) { | ||||
|     configLabelKey = payloadPayload[ | ||||
|       key as keyof typeof payloadPayload | ||||
|     ] as string | ||||
|   } | ||||
| 
 | ||||
|   return configLabelKey in config | ||||
|     ? config[configLabelKey] | ||||
|     : config[key as keyof typeof config] | ||||
| } | ||||
| 
 | ||||
| export { | ||||
|   ChartContainer, | ||||
|   ChartTooltip, | ||||
|   ChartTooltipContent, | ||||
|   ChartLegend, | ||||
|   ChartLegendContent, | ||||
|   ChartStyle, | ||||
| } | ||||
							
								
								
									
										28
									
								
								frontend/src/components/ui/checkbox.tsx
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										28
									
								
								frontend/src/components/ui/checkbox.tsx
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,28 @@ | |||
| import * as React from "react" | ||||
| import * as CheckboxPrimitive from "@radix-ui/react-checkbox" | ||||
| import { Check } from "lucide-react" | ||||
| 
 | ||||
| import { cn } from "@/lib/utils" | ||||
| 
 | ||||
| const Checkbox = React.forwardRef< | ||||
|   React.ElementRef<typeof CheckboxPrimitive.Root>, | ||||
|   React.ComponentPropsWithoutRef<typeof CheckboxPrimitive.Root> | ||||
| >(({ className, ...props }, ref) => ( | ||||
|   <CheckboxPrimitive.Root | ||||
|     ref={ref} | ||||
|     className={cn( | ||||
|       "peer h-4 w-4 shrink-0 rounded-sm border border-primary ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground", | ||||
|       className | ||||
|     )} | ||||
|     {...props} | ||||
|   > | ||||
|     <CheckboxPrimitive.Indicator | ||||
|       className={cn("flex items-center justify-center text-current")} | ||||
|     > | ||||
|       <Check className="h-4 w-4" /> | ||||
|     </CheckboxPrimitive.Indicator> | ||||
|   </CheckboxPrimitive.Root> | ||||
| )) | ||||
| Checkbox.displayName = CheckboxPrimitive.Root.displayName | ||||
| 
 | ||||
| export { Checkbox } | ||||
							
								
								
									
										9
									
								
								frontend/src/components/ui/collapsible.tsx
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										9
									
								
								frontend/src/components/ui/collapsible.tsx
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,9 @@ | |||
| import * as CollapsiblePrimitive from "@radix-ui/react-collapsible" | ||||
| 
 | ||||
| const Collapsible = CollapsiblePrimitive.Root | ||||
| 
 | ||||
| const CollapsibleTrigger = CollapsiblePrimitive.CollapsibleTrigger | ||||
| 
 | ||||
| const CollapsibleContent = CollapsiblePrimitive.CollapsibleContent | ||||
| 
 | ||||
| export { Collapsible, CollapsibleTrigger, CollapsibleContent } | ||||
							
								
								
									
										153
									
								
								frontend/src/components/ui/command.tsx
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										153
									
								
								frontend/src/components/ui/command.tsx
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,153 @@ | |||
| import * as React from "react" | ||||
| import { type DialogProps } from "@radix-ui/react-dialog" | ||||
| import { Command as CommandPrimitive } from "cmdk" | ||||
| import { Search } from "lucide-react" | ||||
| 
 | ||||
| import { cn } from "@/lib/utils" | ||||
| import { Dialog, DialogContent } from "@/components/ui/dialog" | ||||
| 
 | ||||
| const Command = React.forwardRef< | ||||
|   React.ElementRef<typeof CommandPrimitive>, | ||||
|   React.ComponentPropsWithoutRef<typeof CommandPrimitive> | ||||
| >(({ className, ...props }, ref) => ( | ||||
|   <CommandPrimitive | ||||
|     ref={ref} | ||||
|     className={cn( | ||||
|       "flex h-full w-full flex-col overflow-hidden rounded-md bg-popover text-popover-foreground", | ||||
|       className | ||||
|     )} | ||||
|     {...props} | ||||
|   /> | ||||
| )) | ||||
| Command.displayName = CommandPrimitive.displayName | ||||
| 
 | ||||
| interface CommandDialogProps extends DialogProps {} | ||||
| 
 | ||||
| const CommandDialog = ({ children, ...props }: CommandDialogProps) => { | ||||
|   return ( | ||||
|     <Dialog {...props}> | ||||
|       <DialogContent className="overflow-hidden p-0 shadow-lg"> | ||||
|         <Command className="[&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-group]]:px-2 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5"> | ||||
|           {children} | ||||
|         </Command> | ||||
|       </DialogContent> | ||||
|     </Dialog> | ||||
|   ) | ||||
| } | ||||
| 
 | ||||
| const CommandInput = React.forwardRef< | ||||
|   React.ElementRef<typeof CommandPrimitive.Input>, | ||||
|   React.ComponentPropsWithoutRef<typeof CommandPrimitive.Input> | ||||
| >(({ className, ...props }, ref) => ( | ||||
|   <div className="flex items-center border-b px-3" cmdk-input-wrapper=""> | ||||
|     <Search className="mr-2 h-4 w-4 shrink-0 opacity-50" /> | ||||
|     <CommandPrimitive.Input | ||||
|       ref={ref} | ||||
|       className={cn( | ||||
|         "flex h-11 w-full rounded-md bg-transparent py-3 text-sm outline-none placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50", | ||||
|         className | ||||
|       )} | ||||
|       {...props} | ||||
|     /> | ||||
|   </div> | ||||
| )) | ||||
| 
 | ||||
| CommandInput.displayName = CommandPrimitive.Input.displayName | ||||
| 
 | ||||
| const CommandList = React.forwardRef< | ||||
|   React.ElementRef<typeof CommandPrimitive.List>, | ||||
|   React.ComponentPropsWithoutRef<typeof CommandPrimitive.List> | ||||
| >(({ className, ...props }, ref) => ( | ||||
|   <CommandPrimitive.List | ||||
|     ref={ref} | ||||
|     className={cn("max-h-[300px] overflow-y-auto overflow-x-hidden", className)} | ||||
|     {...props} | ||||
|   /> | ||||
| )) | ||||
| 
 | ||||
| CommandList.displayName = CommandPrimitive.List.displayName | ||||
| 
 | ||||
| const CommandEmpty = React.forwardRef< | ||||
|   React.ElementRef<typeof CommandPrimitive.Empty>, | ||||
|   React.ComponentPropsWithoutRef<typeof CommandPrimitive.Empty> | ||||
| >((props, ref) => ( | ||||
|   <CommandPrimitive.Empty | ||||
|     ref={ref} | ||||
|     className="py-6 text-center text-sm" | ||||
|     {...props} | ||||
|   /> | ||||
| )) | ||||
| 
 | ||||
| CommandEmpty.displayName = CommandPrimitive.Empty.displayName | ||||
| 
 | ||||
| const CommandGroup = React.forwardRef< | ||||
|   React.ElementRef<typeof CommandPrimitive.Group>, | ||||
|   React.ComponentPropsWithoutRef<typeof CommandPrimitive.Group> | ||||
| >(({ className, ...props }, ref) => ( | ||||
|   <CommandPrimitive.Group | ||||
|     ref={ref} | ||||
|     className={cn( | ||||
|       "overflow-hidden p-1 text-foreground [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground", | ||||
|       className | ||||
|     )} | ||||
|     {...props} | ||||
|   /> | ||||
| )) | ||||
| 
 | ||||
| CommandGroup.displayName = CommandPrimitive.Group.displayName | ||||
| 
 | ||||
| const CommandSeparator = React.forwardRef< | ||||
|   React.ElementRef<typeof CommandPrimitive.Separator>, | ||||
|   React.ComponentPropsWithoutRef<typeof CommandPrimitive.Separator> | ||||
| >(({ className, ...props }, ref) => ( | ||||
|   <CommandPrimitive.Separator | ||||
|     ref={ref} | ||||
|     className={cn("-mx-1 h-px bg-border", className)} | ||||
|     {...props} | ||||
|   /> | ||||
| )) | ||||
| CommandSeparator.displayName = CommandPrimitive.Separator.displayName | ||||
| 
 | ||||
| const CommandItem = React.forwardRef< | ||||
|   React.ElementRef<typeof CommandPrimitive.Item>, | ||||
|   React.ComponentPropsWithoutRef<typeof CommandPrimitive.Item> | ||||
| >(({ className, ...props }, ref) => ( | ||||
|   <CommandPrimitive.Item | ||||
|     ref={ref} | ||||
|     className={cn( | ||||
|       "relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none data-[disabled=true]:pointer-events-none data-[selected='true']:bg-accent data-[selected=true]:text-accent-foreground data-[disabled=true]:opacity-50", | ||||
|       className | ||||
|     )} | ||||
|     {...props} | ||||
|   /> | ||||
| )) | ||||
| 
 | ||||
| CommandItem.displayName = CommandPrimitive.Item.displayName | ||||
| 
 | ||||
| const CommandShortcut = ({ | ||||
|   className, | ||||
|   ...props | ||||
| }: React.HTMLAttributes<HTMLSpanElement>) => { | ||||
|   return ( | ||||
|     <span | ||||
|       className={cn( | ||||
|         "ml-auto text-xs tracking-widest text-muted-foreground", | ||||
|         className | ||||
|       )} | ||||
|       {...props} | ||||
|     /> | ||||
|   ) | ||||
| } | ||||
| CommandShortcut.displayName = "CommandShortcut" | ||||
| 
 | ||||
| export { | ||||
|   Command, | ||||
|   CommandDialog, | ||||
|   CommandInput, | ||||
|   CommandList, | ||||
|   CommandEmpty, | ||||
|   CommandGroup, | ||||
|   CommandItem, | ||||
|   CommandShortcut, | ||||
|   CommandSeparator, | ||||
| } | ||||
							
								
								
									
										198
									
								
								frontend/src/components/ui/context-menu.tsx
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										198
									
								
								frontend/src/components/ui/context-menu.tsx
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,198 @@ | |||
| import * as React from "react" | ||||
| import * as ContextMenuPrimitive from "@radix-ui/react-context-menu" | ||||
| import { Check, ChevronRight, Circle } from "lucide-react" | ||||
| 
 | ||||
| import { cn } from "@/lib/utils" | ||||
| 
 | ||||
| const ContextMenu = ContextMenuPrimitive.Root | ||||
| 
 | ||||
| const ContextMenuTrigger = ContextMenuPrimitive.Trigger | ||||
| 
 | ||||
| const ContextMenuGroup = ContextMenuPrimitive.Group | ||||
| 
 | ||||
| const ContextMenuPortal = ContextMenuPrimitive.Portal | ||||
| 
 | ||||
| const ContextMenuSub = ContextMenuPrimitive.Sub | ||||
| 
 | ||||
| const ContextMenuRadioGroup = ContextMenuPrimitive.RadioGroup | ||||
| 
 | ||||
| const ContextMenuSubTrigger = React.forwardRef< | ||||
|   React.ElementRef<typeof ContextMenuPrimitive.SubTrigger>, | ||||
|   React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.SubTrigger> & { | ||||
|     inset?: boolean | ||||
|   } | ||||
| >(({ className, inset, children, ...props }, ref) => ( | ||||
|   <ContextMenuPrimitive.SubTrigger | ||||
|     ref={ref} | ||||
|     className={cn( | ||||
|       "flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground", | ||||
|       inset && "pl-8", | ||||
|       className | ||||
|     )} | ||||
|     {...props} | ||||
|   > | ||||
|     {children} | ||||
|     <ChevronRight className="ml-auto h-4 w-4" /> | ||||
|   </ContextMenuPrimitive.SubTrigger> | ||||
| )) | ||||
| ContextMenuSubTrigger.displayName = ContextMenuPrimitive.SubTrigger.displayName | ||||
| 
 | ||||
| const ContextMenuSubContent = React.forwardRef< | ||||
|   React.ElementRef<typeof ContextMenuPrimitive.SubContent>, | ||||
|   React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.SubContent> | ||||
| >(({ className, ...props }, ref) => ( | ||||
|   <ContextMenuPrimitive.SubContent | ||||
|     ref={ref} | ||||
|     className={cn( | ||||
|       "z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2", | ||||
|       className | ||||
|     )} | ||||
|     {...props} | ||||
|   /> | ||||
| )) | ||||
| ContextMenuSubContent.displayName = ContextMenuPrimitive.SubContent.displayName | ||||
| 
 | ||||
| const ContextMenuContent = React.forwardRef< | ||||
|   React.ElementRef<typeof ContextMenuPrimitive.Content>, | ||||
|   React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Content> | ||||
| >(({ className, ...props }, ref) => ( | ||||
|   <ContextMenuPrimitive.Portal> | ||||
|     <ContextMenuPrimitive.Content | ||||
|       ref={ref} | ||||
|       className={cn( | ||||
|         "z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md animate-in fade-in-80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2", | ||||
|         className | ||||
|       )} | ||||
|       {...props} | ||||
|     /> | ||||
|   </ContextMenuPrimitive.Portal> | ||||
| )) | ||||
| ContextMenuContent.displayName = ContextMenuPrimitive.Content.displayName | ||||
| 
 | ||||
| const ContextMenuItem = React.forwardRef< | ||||
|   React.ElementRef<typeof ContextMenuPrimitive.Item>, | ||||
|   React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Item> & { | ||||
|     inset?: boolean | ||||
|   } | ||||
| >(({ className, inset, ...props }, ref) => ( | ||||
|   <ContextMenuPrimitive.Item | ||||
|     ref={ref} | ||||
|     className={cn( | ||||
|       "relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50", | ||||
|       inset && "pl-8", | ||||
|       className | ||||
|     )} | ||||
|     {...props} | ||||
|   /> | ||||
| )) | ||||
| ContextMenuItem.displayName = ContextMenuPrimitive.Item.displayName | ||||
| 
 | ||||
| const ContextMenuCheckboxItem = React.forwardRef< | ||||
|   React.ElementRef<typeof ContextMenuPrimitive.CheckboxItem>, | ||||
|   React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.CheckboxItem> | ||||
| >(({ className, children, checked, ...props }, ref) => ( | ||||
|   <ContextMenuPrimitive.CheckboxItem | ||||
|     ref={ref} | ||||
|     className={cn( | ||||
|       "relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50", | ||||
|       className | ||||
|     )} | ||||
|     checked={checked} | ||||
|     {...props} | ||||
|   > | ||||
|     <span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center"> | ||||
|       <ContextMenuPrimitive.ItemIndicator> | ||||
|         <Check className="h-4 w-4" /> | ||||
|       </ContextMenuPrimitive.ItemIndicator> | ||||
|     </span> | ||||
|     {children} | ||||
|   </ContextMenuPrimitive.CheckboxItem> | ||||
| )) | ||||
| ContextMenuCheckboxItem.displayName = | ||||
|   ContextMenuPrimitive.CheckboxItem.displayName | ||||
| 
 | ||||
| const ContextMenuRadioItem = React.forwardRef< | ||||
|   React.ElementRef<typeof ContextMenuPrimitive.RadioItem>, | ||||
|   React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.RadioItem> | ||||
| >(({ className, children, ...props }, ref) => ( | ||||
|   <ContextMenuPrimitive.RadioItem | ||||
|     ref={ref} | ||||
|     className={cn( | ||||
|       "relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50", | ||||
|       className | ||||
|     )} | ||||
|     {...props} | ||||
|   > | ||||
|     <span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center"> | ||||
|       <ContextMenuPrimitive.ItemIndicator> | ||||
|         <Circle className="h-2 w-2 fill-current" /> | ||||
|       </ContextMenuPrimitive.ItemIndicator> | ||||
|     </span> | ||||
|     {children} | ||||
|   </ContextMenuPrimitive.RadioItem> | ||||
| )) | ||||
| ContextMenuRadioItem.displayName = ContextMenuPrimitive.RadioItem.displayName | ||||
| 
 | ||||
| const ContextMenuLabel = React.forwardRef< | ||||
|   React.ElementRef<typeof ContextMenuPrimitive.Label>, | ||||
|   React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Label> & { | ||||
|     inset?: boolean | ||||
|   } | ||||
| >(({ className, inset, ...props }, ref) => ( | ||||
|   <ContextMenuPrimitive.Label | ||||
|     ref={ref} | ||||
|     className={cn( | ||||
|       "px-2 py-1.5 text-sm font-semibold text-foreground", | ||||
|       inset && "pl-8", | ||||
|       className | ||||
|     )} | ||||
|     {...props} | ||||
|   /> | ||||
| )) | ||||
| ContextMenuLabel.displayName = ContextMenuPrimitive.Label.displayName | ||||
| 
 | ||||
| const ContextMenuSeparator = React.forwardRef< | ||||
|   React.ElementRef<typeof ContextMenuPrimitive.Separator>, | ||||
|   React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Separator> | ||||
| >(({ className, ...props }, ref) => ( | ||||
|   <ContextMenuPrimitive.Separator | ||||
|     ref={ref} | ||||
|     className={cn("-mx-1 my-1 h-px bg-border", className)} | ||||
|     {...props} | ||||
|   /> | ||||
| )) | ||||
| ContextMenuSeparator.displayName = ContextMenuPrimitive.Separator.displayName | ||||
| 
 | ||||
| const ContextMenuShortcut = ({ | ||||
|   className, | ||||
|   ...props | ||||
| }: React.HTMLAttributes<HTMLSpanElement>) => { | ||||
|   return ( | ||||
|     <span | ||||
|       className={cn( | ||||
|         "ml-auto text-xs tracking-widest text-muted-foreground", | ||||
|         className | ||||
|       )} | ||||
|       {...props} | ||||
|     /> | ||||
|   ) | ||||
| } | ||||
| ContextMenuShortcut.displayName = "ContextMenuShortcut" | ||||
| 
 | ||||
| export { | ||||
|   ContextMenu, | ||||
|   ContextMenuTrigger, | ||||
|   ContextMenuContent, | ||||
|   ContextMenuItem, | ||||
|   ContextMenuCheckboxItem, | ||||
|   ContextMenuRadioItem, | ||||
|   ContextMenuLabel, | ||||
|   ContextMenuSeparator, | ||||
|   ContextMenuShortcut, | ||||
|   ContextMenuGroup, | ||||
|   ContextMenuPortal, | ||||
|   ContextMenuSub, | ||||
|   ContextMenuSubContent, | ||||
|   ContextMenuSubTrigger, | ||||
|   ContextMenuRadioGroup, | ||||
| } | ||||
							
								
								
									
										120
									
								
								frontend/src/components/ui/dialog.tsx
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										120
									
								
								frontend/src/components/ui/dialog.tsx
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,120 @@ | |||
| import * as React from "react" | ||||
| import * as DialogPrimitive from "@radix-ui/react-dialog" | ||||
| import { X } from "lucide-react" | ||||
| 
 | ||||
| import { cn } from "@/lib/utils" | ||||
| 
 | ||||
| const Dialog = DialogPrimitive.Root | ||||
| 
 | ||||
| const DialogTrigger = DialogPrimitive.Trigger | ||||
| 
 | ||||
| const DialogPortal = DialogPrimitive.Portal | ||||
| 
 | ||||
| const DialogClose = DialogPrimitive.Close | ||||
| 
 | ||||
| const DialogOverlay = React.forwardRef< | ||||
|   React.ElementRef<typeof DialogPrimitive.Overlay>, | ||||
|   React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay> | ||||
| >(({ className, ...props }, ref) => ( | ||||
|   <DialogPrimitive.Overlay | ||||
|     ref={ref} | ||||
|     className={cn( | ||||
|       "fixed inset-0 z-50 bg-black/80  data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0", | ||||
|       className | ||||
|     )} | ||||
|     {...props} | ||||
|   /> | ||||
| )) | ||||
| DialogOverlay.displayName = DialogPrimitive.Overlay.displayName | ||||
| 
 | ||||
| const DialogContent = React.forwardRef< | ||||
|   React.ElementRef<typeof DialogPrimitive.Content>, | ||||
|   React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content> | ||||
| >(({ className, children, ...props }, ref) => ( | ||||
|   <DialogPortal> | ||||
|     <DialogOverlay /> | ||||
|     <DialogPrimitive.Content | ||||
|       ref={ref} | ||||
|       className={cn( | ||||
|         "fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg", | ||||
|         className | ||||
|       )} | ||||
|       {...props} | ||||
|     > | ||||
|       {children} | ||||
|       <DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground"> | ||||
|         <X className="h-4 w-4" /> | ||||
|         <span className="sr-only">Close</span> | ||||
|       </DialogPrimitive.Close> | ||||
|     </DialogPrimitive.Content> | ||||
|   </DialogPortal> | ||||
| )) | ||||
| DialogContent.displayName = DialogPrimitive.Content.displayName | ||||
| 
 | ||||
| const DialogHeader = ({ | ||||
|   className, | ||||
|   ...props | ||||
| }: React.HTMLAttributes<HTMLDivElement>) => ( | ||||
|   <div | ||||
|     className={cn( | ||||
|       "flex flex-col space-y-1.5 text-center sm:text-left", | ||||
|       className | ||||
|     )} | ||||
|     {...props} | ||||
|   /> | ||||
| ) | ||||
| DialogHeader.displayName = "DialogHeader" | ||||
| 
 | ||||
| const DialogFooter = ({ | ||||
|   className, | ||||
|   ...props | ||||
| }: React.HTMLAttributes<HTMLDivElement>) => ( | ||||
|   <div | ||||
|     className={cn( | ||||
|       "flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2", | ||||
|       className | ||||
|     )} | ||||
|     {...props} | ||||
|   /> | ||||
| ) | ||||
| DialogFooter.displayName = "DialogFooter" | ||||
| 
 | ||||
| const DialogTitle = React.forwardRef< | ||||
|   React.ElementRef<typeof DialogPrimitive.Title>, | ||||
|   React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title> | ||||
| >(({ className, ...props }, ref) => ( | ||||
|   <DialogPrimitive.Title | ||||
|     ref={ref} | ||||
|     className={cn( | ||||
|       "text-lg font-semibold leading-none tracking-tight", | ||||
|       className | ||||
|     )} | ||||
|     {...props} | ||||
|   /> | ||||
| )) | ||||
| DialogTitle.displayName = DialogPrimitive.Title.displayName | ||||
| 
 | ||||
| const DialogDescription = React.forwardRef< | ||||
|   React.ElementRef<typeof DialogPrimitive.Description>, | ||||
|   React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description> | ||||
| >(({ className, ...props }, ref) => ( | ||||
|   <DialogPrimitive.Description | ||||
|     ref={ref} | ||||
|     className={cn("text-sm text-muted-foreground", className)} | ||||
|     {...props} | ||||
|   /> | ||||
| )) | ||||
| DialogDescription.displayName = DialogPrimitive.Description.displayName | ||||
| 
 | ||||
| export { | ||||
|   Dialog, | ||||
|   DialogPortal, | ||||
|   DialogOverlay, | ||||
|   DialogClose, | ||||
|   DialogTrigger, | ||||
|   DialogContent, | ||||
|   DialogHeader, | ||||
|   DialogFooter, | ||||
|   DialogTitle, | ||||
|   DialogDescription, | ||||
| } | ||||
							
								
								
									
										116
									
								
								frontend/src/components/ui/drawer.tsx
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										116
									
								
								frontend/src/components/ui/drawer.tsx
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,116 @@ | |||
| import * as React from "react" | ||||
| import { Drawer as DrawerPrimitive } from "vaul" | ||||
| 
 | ||||
| import { cn } from "@/lib/utils" | ||||
| 
 | ||||
| const Drawer = ({ | ||||
|   shouldScaleBackground = true, | ||||
|   ...props | ||||
| }: React.ComponentProps<typeof DrawerPrimitive.Root>) => ( | ||||
|   <DrawerPrimitive.Root | ||||
|     shouldScaleBackground={shouldScaleBackground} | ||||
|     {...props} | ||||
|   /> | ||||
| ) | ||||
| Drawer.displayName = "Drawer" | ||||
| 
 | ||||
| const DrawerTrigger = DrawerPrimitive.Trigger | ||||
| 
 | ||||
| const DrawerPortal = DrawerPrimitive.Portal | ||||
| 
 | ||||
| const DrawerClose = DrawerPrimitive.Close | ||||
| 
 | ||||
| const DrawerOverlay = React.forwardRef< | ||||
|   React.ElementRef<typeof DrawerPrimitive.Overlay>, | ||||
|   React.ComponentPropsWithoutRef<typeof DrawerPrimitive.Overlay> | ||||
| >(({ className, ...props }, ref) => ( | ||||
|   <DrawerPrimitive.Overlay | ||||
|     ref={ref} | ||||
|     className={cn("fixed inset-0 z-50 bg-black/80", className)} | ||||
|     {...props} | ||||
|   /> | ||||
| )) | ||||
| DrawerOverlay.displayName = DrawerPrimitive.Overlay.displayName | ||||
| 
 | ||||
| const DrawerContent = React.forwardRef< | ||||
|   React.ElementRef<typeof DrawerPrimitive.Content>, | ||||
|   React.ComponentPropsWithoutRef<typeof DrawerPrimitive.Content> | ||||
| >(({ className, children, ...props }, ref) => ( | ||||
|   <DrawerPortal> | ||||
|     <DrawerOverlay /> | ||||
|     <DrawerPrimitive.Content | ||||
|       ref={ref} | ||||
|       className={cn( | ||||
|         "fixed inset-x-0 bottom-0 z-50 mt-24 flex h-auto flex-col rounded-t-[10px] border bg-background", | ||||
|         className | ||||
|       )} | ||||
|       {...props} | ||||
|     > | ||||
|       <div className="mx-auto mt-4 h-2 w-[100px] rounded-full bg-muted" /> | ||||
|       {children} | ||||
|     </DrawerPrimitive.Content> | ||||
|   </DrawerPortal> | ||||
| )) | ||||
| DrawerContent.displayName = "DrawerContent" | ||||
| 
 | ||||
| const DrawerHeader = ({ | ||||
|   className, | ||||
|   ...props | ||||
| }: React.HTMLAttributes<HTMLDivElement>) => ( | ||||
|   <div | ||||
|     className={cn("grid gap-1.5 p-4 text-center sm:text-left", className)} | ||||
|     {...props} | ||||
|   /> | ||||
| ) | ||||
| DrawerHeader.displayName = "DrawerHeader" | ||||
| 
 | ||||
| const DrawerFooter = ({ | ||||
|   className, | ||||
|   ...props | ||||
| }: React.HTMLAttributes<HTMLDivElement>) => ( | ||||
|   <div | ||||
|     className={cn("mt-auto flex flex-col gap-2 p-4", className)} | ||||
|     {...props} | ||||
|   /> | ||||
| ) | ||||
| DrawerFooter.displayName = "DrawerFooter" | ||||
| 
 | ||||
| const DrawerTitle = React.forwardRef< | ||||
|   React.ElementRef<typeof DrawerPrimitive.Title>, | ||||
|   React.ComponentPropsWithoutRef<typeof DrawerPrimitive.Title> | ||||
| >(({ className, ...props }, ref) => ( | ||||
|   <DrawerPrimitive.Title | ||||
|     ref={ref} | ||||
|     className={cn( | ||||
|       "text-lg font-semibold leading-none tracking-tight", | ||||
|       className | ||||
|     )} | ||||
|     {...props} | ||||
|   /> | ||||
| )) | ||||
| DrawerTitle.displayName = DrawerPrimitive.Title.displayName | ||||
| 
 | ||||
| const DrawerDescription = React.forwardRef< | ||||
|   React.ElementRef<typeof DrawerPrimitive.Description>, | ||||
|   React.ComponentPropsWithoutRef<typeof DrawerPrimitive.Description> | ||||
| >(({ className, ...props }, ref) => ( | ||||
|   <DrawerPrimitive.Description | ||||
|     ref={ref} | ||||
|     className={cn("text-sm text-muted-foreground", className)} | ||||
|     {...props} | ||||
|   /> | ||||
| )) | ||||
| DrawerDescription.displayName = DrawerPrimitive.Description.displayName | ||||
| 
 | ||||
| export { | ||||
|   Drawer, | ||||
|   DrawerPortal, | ||||
|   DrawerOverlay, | ||||
|   DrawerTrigger, | ||||
|   DrawerClose, | ||||
|   DrawerContent, | ||||
|   DrawerHeader, | ||||
|   DrawerFooter, | ||||
|   DrawerTitle, | ||||
|   DrawerDescription, | ||||
| } | ||||
							
								
								
									
										198
									
								
								frontend/src/components/ui/dropdown-menu.tsx
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										198
									
								
								frontend/src/components/ui/dropdown-menu.tsx
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,198 @@ | |||
| import * as React from "react" | ||||
| import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu" | ||||
| import { Check, ChevronRight, Circle } from "lucide-react" | ||||
| 
 | ||||
| import { cn } from "@/lib/utils" | ||||
| 
 | ||||
| const DropdownMenu = DropdownMenuPrimitive.Root | ||||
| 
 | ||||
| const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger | ||||
| 
 | ||||
| const DropdownMenuGroup = DropdownMenuPrimitive.Group | ||||
| 
 | ||||
| const DropdownMenuPortal = DropdownMenuPrimitive.Portal | ||||
| 
 | ||||
| const DropdownMenuSub = DropdownMenuPrimitive.Sub | ||||
| 
 | ||||
| const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup | ||||
| 
 | ||||
| const DropdownMenuSubTrigger = React.forwardRef< | ||||
|   React.ElementRef<typeof DropdownMenuPrimitive.SubTrigger>, | ||||
|   React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> & { | ||||
|     inset?: boolean | ||||
|   } | ||||
| >(({ className, inset, children, ...props }, ref) => ( | ||||
|   <DropdownMenuPrimitive.SubTrigger | ||||
|     ref={ref} | ||||
|     className={cn( | ||||
|       "flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent data-[state=open]:bg-accent", | ||||
|       inset && "pl-8", | ||||
|       className | ||||
|     )} | ||||
|     {...props} | ||||
|   > | ||||
|     {children} | ||||
|     <ChevronRight className="ml-auto h-4 w-4" /> | ||||
|   </DropdownMenuPrimitive.SubTrigger> | ||||
| )) | ||||
| DropdownMenuSubTrigger.displayName = | ||||
|   DropdownMenuPrimitive.SubTrigger.displayName | ||||
| 
 | ||||
| const DropdownMenuSubContent = React.forwardRef< | ||||
|   React.ElementRef<typeof DropdownMenuPrimitive.SubContent>, | ||||
|   React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubContent> | ||||
| >(({ className, ...props }, ref) => ( | ||||
|   <DropdownMenuPrimitive.SubContent | ||||
|     ref={ref} | ||||
|     className={cn( | ||||
|       "z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2", | ||||
|       className | ||||
|     )} | ||||
|     {...props} | ||||
|   /> | ||||
| )) | ||||
| DropdownMenuSubContent.displayName = | ||||
|   DropdownMenuPrimitive.SubContent.displayName | ||||
| 
 | ||||
| const DropdownMenuContent = React.forwardRef< | ||||
|   React.ElementRef<typeof DropdownMenuPrimitive.Content>, | ||||
|   React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content> | ||||
| >(({ className, sideOffset = 4, ...props }, ref) => ( | ||||
|   <DropdownMenuPrimitive.Portal> | ||||
|     <DropdownMenuPrimitive.Content | ||||
|       ref={ref} | ||||
|       sideOffset={sideOffset} | ||||
|       className={cn( | ||||
|         "z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2", | ||||
|         className | ||||
|       )} | ||||
|       {...props} | ||||
|     /> | ||||
|   </DropdownMenuPrimitive.Portal> | ||||
| )) | ||||
| DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName | ||||
| 
 | ||||
| const DropdownMenuItem = React.forwardRef< | ||||
|   React.ElementRef<typeof DropdownMenuPrimitive.Item>, | ||||
|   React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & { | ||||
|     inset?: boolean | ||||
|   } | ||||
| >(({ className, inset, ...props }, ref) => ( | ||||
|   <DropdownMenuPrimitive.Item | ||||
|     ref={ref} | ||||
|     className={cn( | ||||
|       "relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50", | ||||
|       inset && "pl-8", | ||||
|       className | ||||
|     )} | ||||
|     {...props} | ||||
|   /> | ||||
| )) | ||||
| DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName | ||||
| 
 | ||||
| const DropdownMenuCheckboxItem = React.forwardRef< | ||||
|   React.ElementRef<typeof DropdownMenuPrimitive.CheckboxItem>, | ||||
|   React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.CheckboxItem> | ||||
| >(({ className, children, checked, ...props }, ref) => ( | ||||
|   <DropdownMenuPrimitive.CheckboxItem | ||||
|     ref={ref} | ||||
|     className={cn( | ||||
|       "relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50", | ||||
|       className | ||||
|     )} | ||||
|     checked={checked} | ||||
|     {...props} | ||||
|   > | ||||
|     <span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center"> | ||||
|       <DropdownMenuPrimitive.ItemIndicator> | ||||
|         <Check className="h-4 w-4" /> | ||||
|       </DropdownMenuPrimitive.ItemIndicator> | ||||
|     </span> | ||||
|     {children} | ||||
|   </DropdownMenuPrimitive.CheckboxItem> | ||||
| )) | ||||
| DropdownMenuCheckboxItem.displayName = | ||||
|   DropdownMenuPrimitive.CheckboxItem.displayName | ||||
| 
 | ||||
| const DropdownMenuRadioItem = React.forwardRef< | ||||
|   React.ElementRef<typeof DropdownMenuPrimitive.RadioItem>, | ||||
|   React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.RadioItem> | ||||
| >(({ className, children, ...props }, ref) => ( | ||||
|   <DropdownMenuPrimitive.RadioItem | ||||
|     ref={ref} | ||||
|     className={cn( | ||||
|       "relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50", | ||||
|       className | ||||
|     )} | ||||
|     {...props} | ||||
|   > | ||||
|     <span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center"> | ||||
|       <DropdownMenuPrimitive.ItemIndicator> | ||||
|         <Circle className="h-2 w-2 fill-current" /> | ||||
|       </DropdownMenuPrimitive.ItemIndicator> | ||||
|     </span> | ||||
|     {children} | ||||
|   </DropdownMenuPrimitive.RadioItem> | ||||
| )) | ||||
| DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName | ||||
| 
 | ||||
| const DropdownMenuLabel = React.forwardRef< | ||||
|   React.ElementRef<typeof DropdownMenuPrimitive.Label>, | ||||
|   React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Label> & { | ||||
|     inset?: boolean | ||||
|   } | ||||
| >(({ className, inset, ...props }, ref) => ( | ||||
|   <DropdownMenuPrimitive.Label | ||||
|     ref={ref} | ||||
|     className={cn( | ||||
|       "px-2 py-1.5 text-sm font-semibold", | ||||
|       inset && "pl-8", | ||||
|       className | ||||
|     )} | ||||
|     {...props} | ||||
|   /> | ||||
| )) | ||||
| DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName | ||||
| 
 | ||||
| const DropdownMenuSeparator = React.forwardRef< | ||||
|   React.ElementRef<typeof DropdownMenuPrimitive.Separator>, | ||||
|   React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Separator> | ||||
| >(({ className, ...props }, ref) => ( | ||||
|   <DropdownMenuPrimitive.Separator | ||||
|     ref={ref} | ||||
|     className={cn("-mx-1 my-1 h-px bg-muted", className)} | ||||
|     {...props} | ||||
|   /> | ||||
| )) | ||||
| DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName | ||||
| 
 | ||||
| const DropdownMenuShortcut = ({ | ||||
|   className, | ||||
|   ...props | ||||
| }: React.HTMLAttributes<HTMLSpanElement>) => { | ||||
|   return ( | ||||
|     <span | ||||
|       className={cn("ml-auto text-xs tracking-widest opacity-60", className)} | ||||
|       {...props} | ||||
|     /> | ||||
|   ) | ||||
| } | ||||
| DropdownMenuShortcut.displayName = "DropdownMenuShortcut" | ||||
| 
 | ||||
| export { | ||||
|   DropdownMenu, | ||||
|   DropdownMenuTrigger, | ||||
|   DropdownMenuContent, | ||||
|   DropdownMenuItem, | ||||
|   DropdownMenuCheckboxItem, | ||||
|   DropdownMenuRadioItem, | ||||
|   DropdownMenuLabel, | ||||
|   DropdownMenuSeparator, | ||||
|   DropdownMenuShortcut, | ||||
|   DropdownMenuGroup, | ||||
|   DropdownMenuPortal, | ||||
|   DropdownMenuSub, | ||||
|   DropdownMenuSubContent, | ||||
|   DropdownMenuSubTrigger, | ||||
|   DropdownMenuRadioGroup, | ||||
| } | ||||
							
								
								
									
										176
									
								
								frontend/src/components/ui/form.tsx
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										176
									
								
								frontend/src/components/ui/form.tsx
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,176 @@ | |||
| import * as React from "react" | ||||
| import * as LabelPrimitive from "@radix-ui/react-label" | ||||
| import { Slot } from "@radix-ui/react-slot" | ||||
| import { | ||||
|   Controller, | ||||
|   ControllerProps, | ||||
|   FieldPath, | ||||
|   FieldValues, | ||||
|   FormProvider, | ||||
|   useFormContext, | ||||
| } from "react-hook-form" | ||||
| 
 | ||||
| import { cn } from "@/lib/utils" | ||||
| import { Label } from "@/components/ui/label" | ||||
| 
 | ||||
| const Form = FormProvider | ||||
| 
 | ||||
| type FormFieldContextValue< | ||||
|   TFieldValues extends FieldValues = FieldValues, | ||||
|   TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues> | ||||
| > = { | ||||
|   name: TName | ||||
| } | ||||
| 
 | ||||
| const FormFieldContext = React.createContext<FormFieldContextValue>( | ||||
|   {} as FormFieldContextValue | ||||
| ) | ||||
| 
 | ||||
| const FormField = < | ||||
|   TFieldValues extends FieldValues = FieldValues, | ||||
|   TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues> | ||||
| >({ | ||||
|   ...props | ||||
| }: ControllerProps<TFieldValues, TName>) => { | ||||
|   return ( | ||||
|     <FormFieldContext.Provider value={{ name: props.name }}> | ||||
|       <Controller {...props} /> | ||||
|     </FormFieldContext.Provider> | ||||
|   ) | ||||
| } | ||||
| 
 | ||||
| const useFormField = () => { | ||||
|   const fieldContext = React.useContext(FormFieldContext) | ||||
|   const itemContext = React.useContext(FormItemContext) | ||||
|   const { getFieldState, formState } = useFormContext() | ||||
| 
 | ||||
|   const fieldState = getFieldState(fieldContext.name, formState) | ||||
| 
 | ||||
|   if (!fieldContext) { | ||||
|     throw new Error("useFormField should be used within <FormField>") | ||||
|   } | ||||
| 
 | ||||
|   const { id } = itemContext | ||||
| 
 | ||||
|   return { | ||||
|     id, | ||||
|     name: fieldContext.name, | ||||
|     formItemId: `${id}-form-item`, | ||||
|     formDescriptionId: `${id}-form-item-description`, | ||||
|     formMessageId: `${id}-form-item-message`, | ||||
|     ...fieldState, | ||||
|   } | ||||
| } | ||||
| 
 | ||||
| type FormItemContextValue = { | ||||
|   id: string | ||||
| } | ||||
| 
 | ||||
| const FormItemContext = React.createContext<FormItemContextValue>( | ||||
|   {} as FormItemContextValue | ||||
| ) | ||||
| 
 | ||||
| const FormItem = React.forwardRef< | ||||
|   HTMLDivElement, | ||||
|   React.HTMLAttributes<HTMLDivElement> | ||||
| >(({ className, ...props }, ref) => { | ||||
|   const id = React.useId() | ||||
| 
 | ||||
|   return ( | ||||
|     <FormItemContext.Provider value={{ id }}> | ||||
|       <div ref={ref} className={cn("space-y-2", className)} {...props} /> | ||||
|     </FormItemContext.Provider> | ||||
|   ) | ||||
| }) | ||||
| FormItem.displayName = "FormItem" | ||||
| 
 | ||||
| const FormLabel = React.forwardRef< | ||||
|   React.ElementRef<typeof LabelPrimitive.Root>, | ||||
|   React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> | ||||
| >(({ className, ...props }, ref) => { | ||||
|   const { error, formItemId } = useFormField() | ||||
| 
 | ||||
|   return ( | ||||
|     <Label | ||||
|       ref={ref} | ||||
|       className={cn(error && "text-destructive", className)} | ||||
|       htmlFor={formItemId} | ||||
|       {...props} | ||||
|     /> | ||||
|   ) | ||||
| }) | ||||
| FormLabel.displayName = "FormLabel" | ||||
| 
 | ||||
| const FormControl = React.forwardRef< | ||||
|   React.ElementRef<typeof Slot>, | ||||
|   React.ComponentPropsWithoutRef<typeof Slot> | ||||
| >(({ ...props }, ref) => { | ||||
|   const { error, formItemId, formDescriptionId, formMessageId } = useFormField() | ||||
| 
 | ||||
|   return ( | ||||
|     <Slot | ||||
|       ref={ref} | ||||
|       id={formItemId} | ||||
|       aria-describedby={ | ||||
|         !error | ||||
|           ? `${formDescriptionId}` | ||||
|           : `${formDescriptionId} ${formMessageId}` | ||||
|       } | ||||
|       aria-invalid={!!error} | ||||
|       {...props} | ||||
|     /> | ||||
|   ) | ||||
| }) | ||||
| FormControl.displayName = "FormControl" | ||||
| 
 | ||||
| const FormDescription = React.forwardRef< | ||||
|   HTMLParagraphElement, | ||||
|   React.HTMLAttributes<HTMLParagraphElement> | ||||
| >(({ className, ...props }, ref) => { | ||||
|   const { formDescriptionId } = useFormField() | ||||
| 
 | ||||
|   return ( | ||||
|     <p | ||||
|       ref={ref} | ||||
|       id={formDescriptionId} | ||||
|       className={cn("text-sm text-muted-foreground", className)} | ||||
|       {...props} | ||||
|     /> | ||||
|   ) | ||||
| }) | ||||
| FormDescription.displayName = "FormDescription" | ||||
| 
 | ||||
| const FormMessage = React.forwardRef< | ||||
|   HTMLParagraphElement, | ||||
|   React.HTMLAttributes<HTMLParagraphElement> | ||||
| >(({ className, children, ...props }, ref) => { | ||||
|   const { error, formMessageId } = useFormField() | ||||
|   const body = error ? String(error?.message) : children | ||||
| 
 | ||||
|   if (!body) { | ||||
|     return null | ||||
|   } | ||||
| 
 | ||||
|   return ( | ||||
|     <p | ||||
|       ref={ref} | ||||
|       id={formMessageId} | ||||
|       className={cn("text-sm font-medium text-destructive", className)} | ||||
|       {...props} | ||||
|     > | ||||
|       {body} | ||||
|     </p> | ||||
|   ) | ||||
| }) | ||||
| FormMessage.displayName = "FormMessage" | ||||
| 
 | ||||
| export { | ||||
|   useFormField, | ||||
|   Form, | ||||
|   FormItem, | ||||
|   FormLabel, | ||||
|   FormControl, | ||||
|   FormDescription, | ||||
|   FormMessage, | ||||
|   FormField, | ||||
| } | ||||
							
								
								
									
										27
									
								
								frontend/src/components/ui/hover-card.tsx
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										27
									
								
								frontend/src/components/ui/hover-card.tsx
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,27 @@ | |||
| import * as React from "react" | ||||
| import * as HoverCardPrimitive from "@radix-ui/react-hover-card" | ||||
| 
 | ||||
| import { cn } from "@/lib/utils" | ||||
| 
 | ||||
| const HoverCard = HoverCardPrimitive.Root | ||||
| 
 | ||||
| const HoverCardTrigger = HoverCardPrimitive.Trigger | ||||
| 
 | ||||
| const HoverCardContent = React.forwardRef< | ||||
|   React.ElementRef<typeof HoverCardPrimitive.Content>, | ||||
|   React.ComponentPropsWithoutRef<typeof HoverCardPrimitive.Content> | ||||
| >(({ className, align = "center", sideOffset = 4, ...props }, ref) => ( | ||||
|   <HoverCardPrimitive.Content | ||||
|     ref={ref} | ||||
|     align={align} | ||||
|     sideOffset={sideOffset} | ||||
|     className={cn( | ||||
|       "z-50 w-64 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2", | ||||
|       className | ||||
|     )} | ||||
|     {...props} | ||||
|   /> | ||||
| )) | ||||
| HoverCardContent.displayName = HoverCardPrimitive.Content.displayName | ||||
| 
 | ||||
| export { HoverCard, HoverCardTrigger, HoverCardContent } | ||||
							
								
								
									
										69
									
								
								frontend/src/components/ui/input-otp.tsx
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										69
									
								
								frontend/src/components/ui/input-otp.tsx
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,69 @@ | |||
| import * as React from "react" | ||||
| import { OTPInput, OTPInputContext } from "input-otp" | ||||
| import { Dot } from "lucide-react" | ||||
| 
 | ||||
| import { cn } from "@/lib/utils" | ||||
| 
 | ||||
| const InputOTP = React.forwardRef< | ||||
|   React.ElementRef<typeof OTPInput>, | ||||
|   React.ComponentPropsWithoutRef<typeof OTPInput> | ||||
| >(({ className, containerClassName, ...props }, ref) => ( | ||||
|   <OTPInput | ||||
|     ref={ref} | ||||
|     containerClassName={cn( | ||||
|       "flex items-center gap-2 has-[:disabled]:opacity-50", | ||||
|       containerClassName | ||||
|     )} | ||||
|     className={cn("disabled:cursor-not-allowed", className)} | ||||
|     {...props} | ||||
|   /> | ||||
| )) | ||||
| InputOTP.displayName = "InputOTP" | ||||
| 
 | ||||
| const InputOTPGroup = React.forwardRef< | ||||
|   React.ElementRef<"div">, | ||||
|   React.ComponentPropsWithoutRef<"div"> | ||||
| >(({ className, ...props }, ref) => ( | ||||
|   <div ref={ref} className={cn("flex items-center", className)} {...props} /> | ||||
| )) | ||||
| InputOTPGroup.displayName = "InputOTPGroup" | ||||
| 
 | ||||
| const InputOTPSlot = React.forwardRef< | ||||
|   React.ElementRef<"div">, | ||||
|   React.ComponentPropsWithoutRef<"div"> & { index: number } | ||||
| >(({ index, className, ...props }, ref) => { | ||||
|   const inputOTPContext = React.useContext(OTPInputContext) | ||||
|   const { char, hasFakeCaret, isActive } = inputOTPContext.slots[index] | ||||
| 
 | ||||
|   return ( | ||||
|     <div | ||||
|       ref={ref} | ||||
|       className={cn( | ||||
|         "relative flex h-10 w-10 items-center justify-center border-y border-r border-input text-sm transition-all first:rounded-l-md first:border-l last:rounded-r-md", | ||||
|         isActive && "z-10 ring-2 ring-ring ring-offset-background", | ||||
|         className | ||||
|       )} | ||||
|       {...props} | ||||
|     > | ||||
|       {char} | ||||
|       {hasFakeCaret && ( | ||||
|         <div className="pointer-events-none absolute inset-0 flex items-center justify-center"> | ||||
|           <div className="h-4 w-px animate-caret-blink bg-foreground duration-1000" /> | ||||
|         </div> | ||||
|       )} | ||||
|     </div> | ||||
|   ) | ||||
| }) | ||||
| InputOTPSlot.displayName = "InputOTPSlot" | ||||
| 
 | ||||
| const InputOTPSeparator = React.forwardRef< | ||||
|   React.ElementRef<"div">, | ||||
|   React.ComponentPropsWithoutRef<"div"> | ||||
| >(({ ...props }, ref) => ( | ||||
|   <div ref={ref} role="separator" {...props}> | ||||
|     <Dot /> | ||||
|   </div> | ||||
| )) | ||||
| InputOTPSeparator.displayName = "InputOTPSeparator" | ||||
| 
 | ||||
| export { InputOTP, InputOTPGroup, InputOTPSlot, InputOTPSeparator } | ||||
							
								
								
									
										22
									
								
								frontend/src/components/ui/input.tsx
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										22
									
								
								frontend/src/components/ui/input.tsx
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,22 @@ | |||
| import * as React from "react" | ||||
| 
 | ||||
| import { cn } from "@/lib/utils" | ||||
| 
 | ||||
| const Input = React.forwardRef<HTMLInputElement, React.ComponentProps<"input">>( | ||||
|   ({ className, type, ...props }, ref) => { | ||||
|     return ( | ||||
|       <input | ||||
|         type={type} | ||||
|         className={cn( | ||||
|           "flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-base ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 md:text-sm", | ||||
|           className | ||||
|         )} | ||||
|         ref={ref} | ||||
|         {...props} | ||||
|       /> | ||||
|     ) | ||||
|   } | ||||
| ) | ||||
| Input.displayName = "Input" | ||||
| 
 | ||||
| export { Input } | ||||
							
								
								
									
										24
									
								
								frontend/src/components/ui/label.tsx
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										24
									
								
								frontend/src/components/ui/label.tsx
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,24 @@ | |||
| import * as React from "react" | ||||
| import * as LabelPrimitive from "@radix-ui/react-label" | ||||
| import { cva, type VariantProps } from "class-variance-authority" | ||||
| 
 | ||||
| import { cn } from "@/lib/utils" | ||||
| 
 | ||||
| const labelVariants = cva( | ||||
|   "text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70" | ||||
| ) | ||||
| 
 | ||||
| const Label = React.forwardRef< | ||||
|   React.ElementRef<typeof LabelPrimitive.Root>, | ||||
|   React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> & | ||||
|     VariantProps<typeof labelVariants> | ||||
| >(({ className, ...props }, ref) => ( | ||||
|   <LabelPrimitive.Root | ||||
|     ref={ref} | ||||
|     className={cn(labelVariants(), className)} | ||||
|     {...props} | ||||
|   /> | ||||
| )) | ||||
| Label.displayName = LabelPrimitive.Root.displayName | ||||
| 
 | ||||
| export { Label } | ||||
							
								
								
									
										234
									
								
								frontend/src/components/ui/menubar.tsx
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										234
									
								
								frontend/src/components/ui/menubar.tsx
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,234 @@ | |||
| import * as React from "react" | ||||
| import * as MenubarPrimitive from "@radix-ui/react-menubar" | ||||
| import { Check, ChevronRight, Circle } from "lucide-react" | ||||
| 
 | ||||
| import { cn } from "@/lib/utils" | ||||
| 
 | ||||
| const MenubarMenu = MenubarPrimitive.Menu | ||||
| 
 | ||||
| const MenubarGroup = MenubarPrimitive.Group | ||||
| 
 | ||||
| const MenubarPortal = MenubarPrimitive.Portal | ||||
| 
 | ||||
| const MenubarSub = MenubarPrimitive.Sub | ||||
| 
 | ||||
| const MenubarRadioGroup = MenubarPrimitive.RadioGroup | ||||
| 
 | ||||
| const Menubar = React.forwardRef< | ||||
|   React.ElementRef<typeof MenubarPrimitive.Root>, | ||||
|   React.ComponentPropsWithoutRef<typeof MenubarPrimitive.Root> | ||||
| >(({ className, ...props }, ref) => ( | ||||
|   <MenubarPrimitive.Root | ||||
|     ref={ref} | ||||
|     className={cn( | ||||
|       "flex h-10 items-center space-x-1 rounded-md border bg-background p-1", | ||||
|       className | ||||
|     )} | ||||
|     {...props} | ||||
|   /> | ||||
| )) | ||||
| Menubar.displayName = MenubarPrimitive.Root.displayName | ||||
| 
 | ||||
| const MenubarTrigger = React.forwardRef< | ||||
|   React.ElementRef<typeof MenubarPrimitive.Trigger>, | ||||
|   React.ComponentPropsWithoutRef<typeof MenubarPrimitive.Trigger> | ||||
| >(({ className, ...props }, ref) => ( | ||||
|   <MenubarPrimitive.Trigger | ||||
|     ref={ref} | ||||
|     className={cn( | ||||
|       "flex cursor-default select-none items-center rounded-sm px-3 py-1.5 text-sm font-medium outline-none focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground", | ||||
|       className | ||||
|     )} | ||||
|     {...props} | ||||
|   /> | ||||
| )) | ||||
| MenubarTrigger.displayName = MenubarPrimitive.Trigger.displayName | ||||
| 
 | ||||
| const MenubarSubTrigger = React.forwardRef< | ||||
|   React.ElementRef<typeof MenubarPrimitive.SubTrigger>, | ||||
|   React.ComponentPropsWithoutRef<typeof MenubarPrimitive.SubTrigger> & { | ||||
|     inset?: boolean | ||||
|   } | ||||
| >(({ className, inset, children, ...props }, ref) => ( | ||||
|   <MenubarPrimitive.SubTrigger | ||||
|     ref={ref} | ||||
|     className={cn( | ||||
|       "flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground", | ||||
|       inset && "pl-8", | ||||
|       className | ||||
|     )} | ||||
|     {...props} | ||||
|   > | ||||
|     {children} | ||||
|     <ChevronRight className="ml-auto h-4 w-4" /> | ||||
|   </MenubarPrimitive.SubTrigger> | ||||
| )) | ||||
| MenubarSubTrigger.displayName = MenubarPrimitive.SubTrigger.displayName | ||||
| 
 | ||||
| const MenubarSubContent = React.forwardRef< | ||||
|   React.ElementRef<typeof MenubarPrimitive.SubContent>, | ||||
|   React.ComponentPropsWithoutRef<typeof MenubarPrimitive.SubContent> | ||||
| >(({ className, ...props }, ref) => ( | ||||
|   <MenubarPrimitive.SubContent | ||||
|     ref={ref} | ||||
|     className={cn( | ||||
|       "z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2", | ||||
|       className | ||||
|     )} | ||||
|     {...props} | ||||
|   /> | ||||
| )) | ||||
| MenubarSubContent.displayName = MenubarPrimitive.SubContent.displayName | ||||
| 
 | ||||
| const MenubarContent = React.forwardRef< | ||||
|   React.ElementRef<typeof MenubarPrimitive.Content>, | ||||
|   React.ComponentPropsWithoutRef<typeof MenubarPrimitive.Content> | ||||
| >( | ||||
|   ( | ||||
|     { className, align = "start", alignOffset = -4, sideOffset = 8, ...props }, | ||||
|     ref | ||||
|   ) => ( | ||||
|     <MenubarPrimitive.Portal> | ||||
|       <MenubarPrimitive.Content | ||||
|         ref={ref} | ||||
|         align={align} | ||||
|         alignOffset={alignOffset} | ||||
|         sideOffset={sideOffset} | ||||
|         className={cn( | ||||
|           "z-50 min-w-[12rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2", | ||||
|           className | ||||
|         )} | ||||
|         {...props} | ||||
|       /> | ||||
|     </MenubarPrimitive.Portal> | ||||
|   ) | ||||
| ) | ||||
| MenubarContent.displayName = MenubarPrimitive.Content.displayName | ||||
| 
 | ||||
| const MenubarItem = React.forwardRef< | ||||
|   React.ElementRef<typeof MenubarPrimitive.Item>, | ||||
|   React.ComponentPropsWithoutRef<typeof MenubarPrimitive.Item> & { | ||||
|     inset?: boolean | ||||
|   } | ||||
| >(({ className, inset, ...props }, ref) => ( | ||||
|   <MenubarPrimitive.Item | ||||
|     ref={ref} | ||||
|     className={cn( | ||||
|       "relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50", | ||||
|       inset && "pl-8", | ||||
|       className | ||||
|     )} | ||||
|     {...props} | ||||
|   /> | ||||
| )) | ||||
| MenubarItem.displayName = MenubarPrimitive.Item.displayName | ||||
| 
 | ||||
| const MenubarCheckboxItem = React.forwardRef< | ||||
|   React.ElementRef<typeof MenubarPrimitive.CheckboxItem>, | ||||
|   React.ComponentPropsWithoutRef<typeof MenubarPrimitive.CheckboxItem> | ||||
| >(({ className, children, checked, ...props }, ref) => ( | ||||
|   <MenubarPrimitive.CheckboxItem | ||||
|     ref={ref} | ||||
|     className={cn( | ||||
|       "relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50", | ||||
|       className | ||||
|     )} | ||||
|     checked={checked} | ||||
|     {...props} | ||||
|   > | ||||
|     <span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center"> | ||||
|       <MenubarPrimitive.ItemIndicator> | ||||
|         <Check className="h-4 w-4" /> | ||||
|       </MenubarPrimitive.ItemIndicator> | ||||
|     </span> | ||||
|     {children} | ||||
|   </MenubarPrimitive.CheckboxItem> | ||||
| )) | ||||
| MenubarCheckboxItem.displayName = MenubarPrimitive.CheckboxItem.displayName | ||||
| 
 | ||||
| const MenubarRadioItem = React.forwardRef< | ||||
|   React.ElementRef<typeof MenubarPrimitive.RadioItem>, | ||||
|   React.ComponentPropsWithoutRef<typeof MenubarPrimitive.RadioItem> | ||||
| >(({ className, children, ...props }, ref) => ( | ||||
|   <MenubarPrimitive.RadioItem | ||||
|     ref={ref} | ||||
|     className={cn( | ||||
|       "relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50", | ||||
|       className | ||||
|     )} | ||||
|     {...props} | ||||
|   > | ||||
|     <span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center"> | ||||
|       <MenubarPrimitive.ItemIndicator> | ||||
|         <Circle className="h-2 w-2 fill-current" /> | ||||
|       </MenubarPrimitive.ItemIndicator> | ||||
|     </span> | ||||
|     {children} | ||||
|   </MenubarPrimitive.RadioItem> | ||||
| )) | ||||
| MenubarRadioItem.displayName = MenubarPrimitive.RadioItem.displayName | ||||
| 
 | ||||
| const MenubarLabel = React.forwardRef< | ||||
|   React.ElementRef<typeof MenubarPrimitive.Label>, | ||||
|   React.ComponentPropsWithoutRef<typeof MenubarPrimitive.Label> & { | ||||
|     inset?: boolean | ||||
|   } | ||||
| >(({ className, inset, ...props }, ref) => ( | ||||
|   <MenubarPrimitive.Label | ||||
|     ref={ref} | ||||
|     className={cn( | ||||
|       "px-2 py-1.5 text-sm font-semibold", | ||||
|       inset && "pl-8", | ||||
|       className | ||||
|     )} | ||||
|     {...props} | ||||
|   /> | ||||
| )) | ||||
| MenubarLabel.displayName = MenubarPrimitive.Label.displayName | ||||
| 
 | ||||
| const MenubarSeparator = React.forwardRef< | ||||
|   React.ElementRef<typeof MenubarPrimitive.Separator>, | ||||
|   React.ComponentPropsWithoutRef<typeof MenubarPrimitive.Separator> | ||||
| >(({ className, ...props }, ref) => ( | ||||
|   <MenubarPrimitive.Separator | ||||
|     ref={ref} | ||||
|     className={cn("-mx-1 my-1 h-px bg-muted", className)} | ||||
|     {...props} | ||||
|   /> | ||||
| )) | ||||
| MenubarSeparator.displayName = MenubarPrimitive.Separator.displayName | ||||
| 
 | ||||
| const MenubarShortcut = ({ | ||||
|   className, | ||||
|   ...props | ||||
| }: React.HTMLAttributes<HTMLSpanElement>) => { | ||||
|   return ( | ||||
|     <span | ||||
|       className={cn( | ||||
|         "ml-auto text-xs tracking-widest text-muted-foreground", | ||||
|         className | ||||
|       )} | ||||
|       {...props} | ||||
|     /> | ||||
|   ) | ||||
| } | ||||
| MenubarShortcut.displayname = "MenubarShortcut" | ||||
| 
 | ||||
| export { | ||||
|   Menubar, | ||||
|   MenubarMenu, | ||||
|   MenubarTrigger, | ||||
|   MenubarContent, | ||||
|   MenubarItem, | ||||
|   MenubarSeparator, | ||||
|   MenubarLabel, | ||||
|   MenubarCheckboxItem, | ||||
|   MenubarRadioGroup, | ||||
|   MenubarRadioItem, | ||||
|   MenubarPortal, | ||||
|   MenubarSubContent, | ||||
|   MenubarSubTrigger, | ||||
|   MenubarGroup, | ||||
|   MenubarSub, | ||||
|   MenubarShortcut, | ||||
| } | ||||
							
								
								
									
										128
									
								
								frontend/src/components/ui/navigation-menu.tsx
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										128
									
								
								frontend/src/components/ui/navigation-menu.tsx
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,128 @@ | |||
| import * as React from "react" | ||||
| import * as NavigationMenuPrimitive from "@radix-ui/react-navigation-menu" | ||||
| import { cva } from "class-variance-authority" | ||||
| import { ChevronDown } from "lucide-react" | ||||
| 
 | ||||
| import { cn } from "@/lib/utils" | ||||
| 
 | ||||
| const NavigationMenu = React.forwardRef< | ||||
|   React.ElementRef<typeof NavigationMenuPrimitive.Root>, | ||||
|   React.ComponentPropsWithoutRef<typeof NavigationMenuPrimitive.Root> | ||||
| >(({ className, children, ...props }, ref) => ( | ||||
|   <NavigationMenuPrimitive.Root | ||||
|     ref={ref} | ||||
|     className={cn( | ||||
|       "relative z-10 flex max-w-max flex-1 items-center justify-center", | ||||
|       className | ||||
|     )} | ||||
|     {...props} | ||||
|   > | ||||
|     {children} | ||||
|     <NavigationMenuViewport /> | ||||
|   </NavigationMenuPrimitive.Root> | ||||
| )) | ||||
| NavigationMenu.displayName = NavigationMenuPrimitive.Root.displayName | ||||
| 
 | ||||
| const NavigationMenuList = React.forwardRef< | ||||
|   React.ElementRef<typeof NavigationMenuPrimitive.List>, | ||||
|   React.ComponentPropsWithoutRef<typeof NavigationMenuPrimitive.List> | ||||
| >(({ className, ...props }, ref) => ( | ||||
|   <NavigationMenuPrimitive.List | ||||
|     ref={ref} | ||||
|     className={cn( | ||||
|       "group flex flex-1 list-none items-center justify-center space-x-1", | ||||
|       className | ||||
|     )} | ||||
|     {...props} | ||||
|   /> | ||||
| )) | ||||
| NavigationMenuList.displayName = NavigationMenuPrimitive.List.displayName | ||||
| 
 | ||||
| const NavigationMenuItem = NavigationMenuPrimitive.Item | ||||
| 
 | ||||
| const navigationMenuTriggerStyle = cva( | ||||
|   "group inline-flex h-10 w-max items-center justify-center rounded-md bg-background px-4 py-2 text-sm font-medium transition-colors hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground focus:outline-none disabled:pointer-events-none disabled:opacity-50 data-[active]:bg-accent/50 data-[state=open]:bg-accent/50" | ||||
| ) | ||||
| 
 | ||||
| const NavigationMenuTrigger = React.forwardRef< | ||||
|   React.ElementRef<typeof NavigationMenuPrimitive.Trigger>, | ||||
|   React.ComponentPropsWithoutRef<typeof NavigationMenuPrimitive.Trigger> | ||||
| >(({ className, children, ...props }, ref) => ( | ||||
|   <NavigationMenuPrimitive.Trigger | ||||
|     ref={ref} | ||||
|     className={cn(navigationMenuTriggerStyle(), "group", className)} | ||||
|     {...props} | ||||
|   > | ||||
|     {children}{" "} | ||||
|     <ChevronDown | ||||
|       className="relative top-[1px] ml-1 h-3 w-3 transition duration-200 group-data-[state=open]:rotate-180" | ||||
|       aria-hidden="true" | ||||
|     /> | ||||
|   </NavigationMenuPrimitive.Trigger> | ||||
| )) | ||||
| NavigationMenuTrigger.displayName = NavigationMenuPrimitive.Trigger.displayName | ||||
| 
 | ||||
| const NavigationMenuContent = React.forwardRef< | ||||
|   React.ElementRef<typeof NavigationMenuPrimitive.Content>, | ||||
|   React.ComponentPropsWithoutRef<typeof NavigationMenuPrimitive.Content> | ||||
| >(({ className, ...props }, ref) => ( | ||||
|   <NavigationMenuPrimitive.Content | ||||
|     ref={ref} | ||||
|     className={cn( | ||||
|       "left-0 top-0 w-full data-[motion^=from-]:animate-in data-[motion^=to-]:animate-out data-[motion^=from-]:fade-in data-[motion^=to-]:fade-out data-[motion=from-end]:slide-in-from-right-52 data-[motion=from-start]:slide-in-from-left-52 data-[motion=to-end]:slide-out-to-right-52 data-[motion=to-start]:slide-out-to-left-52 md:absolute md:w-auto ", | ||||
|       className | ||||
|     )} | ||||
|     {...props} | ||||
|   /> | ||||
| )) | ||||
| NavigationMenuContent.displayName = NavigationMenuPrimitive.Content.displayName | ||||
| 
 | ||||
| const NavigationMenuLink = NavigationMenuPrimitive.Link | ||||
| 
 | ||||
| const NavigationMenuViewport = React.forwardRef< | ||||
|   React.ElementRef<typeof NavigationMenuPrimitive.Viewport>, | ||||
|   React.ComponentPropsWithoutRef<typeof NavigationMenuPrimitive.Viewport> | ||||
| >(({ className, ...props }, ref) => ( | ||||
|   <div className={cn("absolute left-0 top-full flex justify-center")}> | ||||
|     <NavigationMenuPrimitive.Viewport | ||||
|       className={cn( | ||||
|         "origin-top-center relative mt-1.5 h-[var(--radix-navigation-menu-viewport-height)] w-full overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-90 md:w-[var(--radix-navigation-menu-viewport-width)]", | ||||
|         className | ||||
|       )} | ||||
|       ref={ref} | ||||
|       {...props} | ||||
|     /> | ||||
|   </div> | ||||
| )) | ||||
| NavigationMenuViewport.displayName = | ||||
|   NavigationMenuPrimitive.Viewport.displayName | ||||
| 
 | ||||
| const NavigationMenuIndicator = React.forwardRef< | ||||
|   React.ElementRef<typeof NavigationMenuPrimitive.Indicator>, | ||||
|   React.ComponentPropsWithoutRef<typeof NavigationMenuPrimitive.Indicator> | ||||
| >(({ className, ...props }, ref) => ( | ||||
|   <NavigationMenuPrimitive.Indicator | ||||
|     ref={ref} | ||||
|     className={cn( | ||||
|       "top-full z-[1] flex h-1.5 items-end justify-center overflow-hidden data-[state=visible]:animate-in data-[state=hidden]:animate-out data-[state=hidden]:fade-out data-[state=visible]:fade-in", | ||||
|       className | ||||
|     )} | ||||
|     {...props} | ||||
|   > | ||||
|     <div className="relative top-[60%] h-2 w-2 rotate-45 rounded-tl-sm bg-border shadow-md" /> | ||||
|   </NavigationMenuPrimitive.Indicator> | ||||
| )) | ||||
| NavigationMenuIndicator.displayName = | ||||
|   NavigationMenuPrimitive.Indicator.displayName | ||||
| 
 | ||||
| export { | ||||
|   navigationMenuTriggerStyle, | ||||
|   NavigationMenu, | ||||
|   NavigationMenuList, | ||||
|   NavigationMenuItem, | ||||
|   NavigationMenuContent, | ||||
|   NavigationMenuTrigger, | ||||
|   NavigationMenuLink, | ||||
|   NavigationMenuIndicator, | ||||
|   NavigationMenuViewport, | ||||
| } | ||||
							
								
								
									
										117
									
								
								frontend/src/components/ui/pagination.tsx
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										117
									
								
								frontend/src/components/ui/pagination.tsx
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,117 @@ | |||
| import * as React from "react" | ||||
| import { ChevronLeft, ChevronRight, MoreHorizontal } from "lucide-react" | ||||
| 
 | ||||
| import { cn } from "@/lib/utils" | ||||
| import { ButtonProps, buttonVariants } from "@/components/ui/button" | ||||
| 
 | ||||
| const Pagination = ({ className, ...props }: React.ComponentProps<"nav">) => ( | ||||
|   <nav | ||||
|     role="navigation" | ||||
|     aria-label="pagination" | ||||
|     className={cn("mx-auto flex w-full justify-center", className)} | ||||
|     {...props} | ||||
|   /> | ||||
| ) | ||||
| Pagination.displayName = "Pagination" | ||||
| 
 | ||||
| const PaginationContent = React.forwardRef< | ||||
|   HTMLUListElement, | ||||
|   React.ComponentProps<"ul"> | ||||
| >(({ className, ...props }, ref) => ( | ||||
|   <ul | ||||
|     ref={ref} | ||||
|     className={cn("flex flex-row items-center gap-1", className)} | ||||
|     {...props} | ||||
|   /> | ||||
| )) | ||||
| PaginationContent.displayName = "PaginationContent" | ||||
| 
 | ||||
| const PaginationItem = React.forwardRef< | ||||
|   HTMLLIElement, | ||||
|   React.ComponentProps<"li"> | ||||
| >(({ className, ...props }, ref) => ( | ||||
|   <li ref={ref} className={cn("", className)} {...props} /> | ||||
| )) | ||||
| PaginationItem.displayName = "PaginationItem" | ||||
| 
 | ||||
| type PaginationLinkProps = { | ||||
|   isActive?: boolean | ||||
| } & Pick<ButtonProps, "size"> & | ||||
|   React.ComponentProps<"a"> | ||||
| 
 | ||||
| const PaginationLink = ({ | ||||
|   className, | ||||
|   isActive, | ||||
|   size = "icon", | ||||
|   ...props | ||||
| }: PaginationLinkProps) => ( | ||||
|   <a | ||||
|     aria-current={isActive ? "page" : undefined} | ||||
|     className={cn( | ||||
|       buttonVariants({ | ||||
|         variant: isActive ? "outline" : "ghost", | ||||
|         size, | ||||
|       }), | ||||
|       className | ||||
|     )} | ||||
|     {...props} | ||||
|   /> | ||||
| ) | ||||
| PaginationLink.displayName = "PaginationLink" | ||||
| 
 | ||||
| const PaginationPrevious = ({ | ||||
|   className, | ||||
|   ...props | ||||
| }: React.ComponentProps<typeof PaginationLink>) => ( | ||||
|   <PaginationLink | ||||
|     aria-label="Go to previous page" | ||||
|     size="default" | ||||
|     className={cn("gap-1 pl-2.5", className)} | ||||
|     {...props} | ||||
|   > | ||||
|     <ChevronLeft className="h-4 w-4" /> | ||||
|     <span>Previous</span> | ||||
|   </PaginationLink> | ||||
| ) | ||||
| PaginationPrevious.displayName = "PaginationPrevious" | ||||
| 
 | ||||
| const PaginationNext = ({ | ||||
|   className, | ||||
|   ...props | ||||
| }: React.ComponentProps<typeof PaginationLink>) => ( | ||||
|   <PaginationLink | ||||
|     aria-label="Go to next page" | ||||
|     size="default" | ||||
|     className={cn("gap-1 pr-2.5", className)} | ||||
|     {...props} | ||||
|   > | ||||
|     <span>Next</span> | ||||
|     <ChevronRight className="h-4 w-4" /> | ||||
|   </PaginationLink> | ||||
| ) | ||||
| PaginationNext.displayName = "PaginationNext" | ||||
| 
 | ||||
| const PaginationEllipsis = ({ | ||||
|   className, | ||||
|   ...props | ||||
| }: React.ComponentProps<"span">) => ( | ||||
|   <span | ||||
|     aria-hidden | ||||
|     className={cn("flex h-9 w-9 items-center justify-center", className)} | ||||
|     {...props} | ||||
|   > | ||||
|     <MoreHorizontal className="h-4 w-4" /> | ||||
|     <span className="sr-only">More pages</span> | ||||
|   </span> | ||||
| ) | ||||
| PaginationEllipsis.displayName = "PaginationEllipsis" | ||||
| 
 | ||||
| export { | ||||
|   Pagination, | ||||
|   PaginationContent, | ||||
|   PaginationEllipsis, | ||||
|   PaginationItem, | ||||
|   PaginationLink, | ||||
|   PaginationNext, | ||||
|   PaginationPrevious, | ||||
| } | ||||
							
								
								
									
										29
									
								
								frontend/src/components/ui/popover.tsx
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										29
									
								
								frontend/src/components/ui/popover.tsx
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,29 @@ | |||
| import * as React from "react" | ||||
| import * as PopoverPrimitive from "@radix-ui/react-popover" | ||||
| 
 | ||||
| import { cn } from "@/lib/utils" | ||||
| 
 | ||||
| const Popover = PopoverPrimitive.Root | ||||
| 
 | ||||
| const PopoverTrigger = PopoverPrimitive.Trigger | ||||
| 
 | ||||
| const PopoverContent = React.forwardRef< | ||||
|   React.ElementRef<typeof PopoverPrimitive.Content>, | ||||
|   React.ComponentPropsWithoutRef<typeof PopoverPrimitive.Content> | ||||
| >(({ className, align = "center", sideOffset = 4, ...props }, ref) => ( | ||||
|   <PopoverPrimitive.Portal> | ||||
|     <PopoverPrimitive.Content | ||||
|       ref={ref} | ||||
|       align={align} | ||||
|       sideOffset={sideOffset} | ||||
|       className={cn( | ||||
|         "z-50 w-72 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2", | ||||
|         className | ||||
|       )} | ||||
|       {...props} | ||||
|     /> | ||||
|   </PopoverPrimitive.Portal> | ||||
| )) | ||||
| PopoverContent.displayName = PopoverPrimitive.Content.displayName | ||||
| 
 | ||||
| export { Popover, PopoverTrigger, PopoverContent } | ||||
							
								
								
									
										26
									
								
								frontend/src/components/ui/progress.tsx
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										26
									
								
								frontend/src/components/ui/progress.tsx
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,26 @@ | |||
| import * as React from "react" | ||||
| import * as ProgressPrimitive from "@radix-ui/react-progress" | ||||
| 
 | ||||
| import { cn } from "@/lib/utils" | ||||
| 
 | ||||
| const Progress = React.forwardRef< | ||||
|   React.ElementRef<typeof ProgressPrimitive.Root>, | ||||
|   React.ComponentPropsWithoutRef<typeof ProgressPrimitive.Root> | ||||
| >(({ className, value, ...props }, ref) => ( | ||||
|   <ProgressPrimitive.Root | ||||
|     ref={ref} | ||||
|     className={cn( | ||||
|       "relative h-4 w-full overflow-hidden rounded-full bg-secondary", | ||||
|       className | ||||
|     )} | ||||
|     {...props} | ||||
|   > | ||||
|     <ProgressPrimitive.Indicator | ||||
|       className="h-full w-full flex-1 bg-primary transition-all" | ||||
|       style={{ transform: `translateX(-${100 - (value || 0)}%)` }} | ||||
|     /> | ||||
|   </ProgressPrimitive.Root> | ||||
| )) | ||||
| Progress.displayName = ProgressPrimitive.Root.displayName | ||||
| 
 | ||||
| export { Progress } | ||||
							
								
								
									
										42
									
								
								frontend/src/components/ui/radio-group.tsx
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										42
									
								
								frontend/src/components/ui/radio-group.tsx
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,42 @@ | |||
| import * as React from "react" | ||||
| import * as RadioGroupPrimitive from "@radix-ui/react-radio-group" | ||||
| import { Circle } from "lucide-react" | ||||
| 
 | ||||
| import { cn } from "@/lib/utils" | ||||
| 
 | ||||
| const RadioGroup = React.forwardRef< | ||||
|   React.ElementRef<typeof RadioGroupPrimitive.Root>, | ||||
|   React.ComponentPropsWithoutRef<typeof RadioGroupPrimitive.Root> | ||||
| >(({ className, ...props }, ref) => { | ||||
|   return ( | ||||
|     <RadioGroupPrimitive.Root | ||||
|       className={cn("grid gap-2", className)} | ||||
|       {...props} | ||||
|       ref={ref} | ||||
|     /> | ||||
|   ) | ||||
| }) | ||||
| RadioGroup.displayName = RadioGroupPrimitive.Root.displayName | ||||
| 
 | ||||
| const RadioGroupItem = React.forwardRef< | ||||
|   React.ElementRef<typeof RadioGroupPrimitive.Item>, | ||||
|   React.ComponentPropsWithoutRef<typeof RadioGroupPrimitive.Item> | ||||
| >(({ className, ...props }, ref) => { | ||||
|   return ( | ||||
|     <RadioGroupPrimitive.Item | ||||
|       ref={ref} | ||||
|       className={cn( | ||||
|         "aspect-square h-4 w-4 rounded-full border border-primary text-primary ring-offset-background focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50", | ||||
|         className | ||||
|       )} | ||||
|       {...props} | ||||
|     > | ||||
|       <RadioGroupPrimitive.Indicator className="flex items-center justify-center"> | ||||
|         <Circle className="h-2.5 w-2.5 fill-current text-current" /> | ||||
|       </RadioGroupPrimitive.Indicator> | ||||
|     </RadioGroupPrimitive.Item> | ||||
|   ) | ||||
| }) | ||||
| RadioGroupItem.displayName = RadioGroupPrimitive.Item.displayName | ||||
| 
 | ||||
| export { RadioGroup, RadioGroupItem } | ||||
							
								
								
									
										43
									
								
								frontend/src/components/ui/resizable.tsx
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										43
									
								
								frontend/src/components/ui/resizable.tsx
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,43 @@ | |||
| import { GripVertical } from "lucide-react" | ||||
| import * as ResizablePrimitive from "react-resizable-panels" | ||||
| 
 | ||||
| import { cn } from "@/lib/utils" | ||||
| 
 | ||||
| const ResizablePanelGroup = ({ | ||||
|   className, | ||||
|   ...props | ||||
| }: React.ComponentProps<typeof ResizablePrimitive.PanelGroup>) => ( | ||||
|   <ResizablePrimitive.PanelGroup | ||||
|     className={cn( | ||||
|       "flex h-full w-full data-[panel-group-direction=vertical]:flex-col", | ||||
|       className | ||||
|     )} | ||||
|     {...props} | ||||
|   /> | ||||
| ) | ||||
| 
 | ||||
| const ResizablePanel = ResizablePrimitive.Panel | ||||
| 
 | ||||
| const ResizableHandle = ({ | ||||
|   withHandle, | ||||
|   className, | ||||
|   ...props | ||||
| }: React.ComponentProps<typeof ResizablePrimitive.PanelResizeHandle> & { | ||||
|   withHandle?: boolean | ||||
| }) => ( | ||||
|   <ResizablePrimitive.PanelResizeHandle | ||||
|     className={cn( | ||||
|       "relative flex w-px items-center justify-center bg-border after:absolute after:inset-y-0 after:left-1/2 after:w-1 after:-translate-x-1/2 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring focus-visible:ring-offset-1 data-[panel-group-direction=vertical]:h-px data-[panel-group-direction=vertical]:w-full data-[panel-group-direction=vertical]:after:left-0 data-[panel-group-direction=vertical]:after:h-1 data-[panel-group-direction=vertical]:after:w-full data-[panel-group-direction=vertical]:after:-translate-y-1/2 data-[panel-group-direction=vertical]:after:translate-x-0 [&[data-panel-group-direction=vertical]>div]:rotate-90", | ||||
|       className | ||||
|     )} | ||||
|     {...props} | ||||
|   > | ||||
|     {withHandle && ( | ||||
|       <div className="z-10 flex h-4 w-3 items-center justify-center rounded-sm border bg-border"> | ||||
|         <GripVertical className="h-2.5 w-2.5" /> | ||||
|       </div> | ||||
|     )} | ||||
|   </ResizablePrimitive.PanelResizeHandle> | ||||
| ) | ||||
| 
 | ||||
| export { ResizablePanelGroup, ResizablePanel, ResizableHandle } | ||||
							
								
								
									
										46
									
								
								frontend/src/components/ui/scroll-area.tsx
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										46
									
								
								frontend/src/components/ui/scroll-area.tsx
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,46 @@ | |||
| import * as React from "react" | ||||
| import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area" | ||||
| 
 | ||||
| import { cn } from "@/lib/utils" | ||||
| 
 | ||||
| const ScrollArea = React.forwardRef< | ||||
|   React.ElementRef<typeof ScrollAreaPrimitive.Root>, | ||||
|   React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.Root> | ||||
| >(({ className, children, ...props }, ref) => ( | ||||
|   <ScrollAreaPrimitive.Root | ||||
|     ref={ref} | ||||
|     className={cn("relative overflow-hidden", className)} | ||||
|     {...props} | ||||
|   > | ||||
|     <ScrollAreaPrimitive.Viewport className="h-full w-full rounded-[inherit]"> | ||||
|       {children} | ||||
|     </ScrollAreaPrimitive.Viewport> | ||||
|     <ScrollBar /> | ||||
|     <ScrollAreaPrimitive.Corner /> | ||||
|   </ScrollAreaPrimitive.Root> | ||||
| )) | ||||
| ScrollArea.displayName = ScrollAreaPrimitive.Root.displayName | ||||
| 
 | ||||
| const ScrollBar = React.forwardRef< | ||||
|   React.ElementRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>, | ||||
|   React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar> | ||||
| >(({ className, orientation = "vertical", ...props }, ref) => ( | ||||
|   <ScrollAreaPrimitive.ScrollAreaScrollbar | ||||
|     ref={ref} | ||||
|     orientation={orientation} | ||||
|     className={cn( | ||||
|       "flex touch-none select-none transition-colors", | ||||
|       orientation === "vertical" && | ||||
|         "h-full w-2.5 border-l border-l-transparent p-[1px]", | ||||
|       orientation === "horizontal" && | ||||
|         "h-2.5 flex-col border-t border-t-transparent p-[1px]", | ||||
|       className | ||||
|     )} | ||||
|     {...props} | ||||
|   > | ||||
|     <ScrollAreaPrimitive.ScrollAreaThumb className="relative flex-1 rounded-full bg-border" /> | ||||
|   </ScrollAreaPrimitive.ScrollAreaScrollbar> | ||||
| )) | ||||
| ScrollBar.displayName = ScrollAreaPrimitive.ScrollAreaScrollbar.displayName | ||||
| 
 | ||||
| export { ScrollArea, ScrollBar } | ||||
							
								
								
									
										158
									
								
								frontend/src/components/ui/select.tsx
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										158
									
								
								frontend/src/components/ui/select.tsx
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,158 @@ | |||
| import * as React from "react" | ||||
| import * as SelectPrimitive from "@radix-ui/react-select" | ||||
| import { Check, ChevronDown, ChevronUp } from "lucide-react" | ||||
| 
 | ||||
| import { cn } from "@/lib/utils" | ||||
| 
 | ||||
| const Select = SelectPrimitive.Root | ||||
| 
 | ||||
| const SelectGroup = SelectPrimitive.Group | ||||
| 
 | ||||
| const SelectValue = SelectPrimitive.Value | ||||
| 
 | ||||
| const SelectTrigger = React.forwardRef< | ||||
|   React.ElementRef<typeof SelectPrimitive.Trigger>, | ||||
|   React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger> | ||||
| >(({ className, children, ...props }, ref) => ( | ||||
|   <SelectPrimitive.Trigger | ||||
|     ref={ref} | ||||
|     className={cn( | ||||
|       "flex h-10 w-full items-center justify-between rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1", | ||||
|       className | ||||
|     )} | ||||
|     {...props} | ||||
|   > | ||||
|     {children} | ||||
|     <SelectPrimitive.Icon asChild> | ||||
|       <ChevronDown className="h-4 w-4 opacity-50" /> | ||||
|     </SelectPrimitive.Icon> | ||||
|   </SelectPrimitive.Trigger> | ||||
| )) | ||||
| SelectTrigger.displayName = SelectPrimitive.Trigger.displayName | ||||
| 
 | ||||
| const SelectScrollUpButton = React.forwardRef< | ||||
|   React.ElementRef<typeof SelectPrimitive.ScrollUpButton>, | ||||
|   React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollUpButton> | ||||
| >(({ className, ...props }, ref) => ( | ||||
|   <SelectPrimitive.ScrollUpButton | ||||
|     ref={ref} | ||||
|     className={cn( | ||||
|       "flex cursor-default items-center justify-center py-1", | ||||
|       className | ||||
|     )} | ||||
|     {...props} | ||||
|   > | ||||
|     <ChevronUp className="h-4 w-4" /> | ||||
|   </SelectPrimitive.ScrollUpButton> | ||||
| )) | ||||
| SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName | ||||
| 
 | ||||
| const SelectScrollDownButton = React.forwardRef< | ||||
|   React.ElementRef<typeof SelectPrimitive.ScrollDownButton>, | ||||
|   React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollDownButton> | ||||
| >(({ className, ...props }, ref) => ( | ||||
|   <SelectPrimitive.ScrollDownButton | ||||
|     ref={ref} | ||||
|     className={cn( | ||||
|       "flex cursor-default items-center justify-center py-1", | ||||
|       className | ||||
|     )} | ||||
|     {...props} | ||||
|   > | ||||
|     <ChevronDown className="h-4 w-4" /> | ||||
|   </SelectPrimitive.ScrollDownButton> | ||||
| )) | ||||
| SelectScrollDownButton.displayName = | ||||
|   SelectPrimitive.ScrollDownButton.displayName | ||||
| 
 | ||||
| const SelectContent = React.forwardRef< | ||||
|   React.ElementRef<typeof SelectPrimitive.Content>, | ||||
|   React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content> | ||||
| >(({ className, children, position = "popper", ...props }, ref) => ( | ||||
|   <SelectPrimitive.Portal> | ||||
|     <SelectPrimitive.Content | ||||
|       ref={ref} | ||||
|       className={cn( | ||||
|         "relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2", | ||||
|         position === "popper" && | ||||
|           "data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1", | ||||
|         className | ||||
|       )} | ||||
|       position={position} | ||||
|       {...props} | ||||
|     > | ||||
|       <SelectScrollUpButton /> | ||||
|       <SelectPrimitive.Viewport | ||||
|         className={cn( | ||||
|           "p-1", | ||||
|           position === "popper" && | ||||
|             "h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]" | ||||
|         )} | ||||
|       > | ||||
|         {children} | ||||
|       </SelectPrimitive.Viewport> | ||||
|       <SelectScrollDownButton /> | ||||
|     </SelectPrimitive.Content> | ||||
|   </SelectPrimitive.Portal> | ||||
| )) | ||||
| SelectContent.displayName = SelectPrimitive.Content.displayName | ||||
| 
 | ||||
| const SelectLabel = React.forwardRef< | ||||
|   React.ElementRef<typeof SelectPrimitive.Label>, | ||||
|   React.ComponentPropsWithoutRef<typeof SelectPrimitive.Label> | ||||
| >(({ className, ...props }, ref) => ( | ||||
|   <SelectPrimitive.Label | ||||
|     ref={ref} | ||||
|     className={cn("py-1.5 pl-8 pr-2 text-sm font-semibold", className)} | ||||
|     {...props} | ||||
|   /> | ||||
| )) | ||||
| SelectLabel.displayName = SelectPrimitive.Label.displayName | ||||
| 
 | ||||
| const SelectItem = React.forwardRef< | ||||
|   React.ElementRef<typeof SelectPrimitive.Item>, | ||||
|   React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item> | ||||
| >(({ className, children, ...props }, ref) => ( | ||||
|   <SelectPrimitive.Item | ||||
|     ref={ref} | ||||
|     className={cn( | ||||
|       "relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50", | ||||
|       className | ||||
|     )} | ||||
|     {...props} | ||||
|   > | ||||
|     <span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center"> | ||||
|       <SelectPrimitive.ItemIndicator> | ||||
|         <Check className="h-4 w-4" /> | ||||
|       </SelectPrimitive.ItemIndicator> | ||||
|     </span> | ||||
| 
 | ||||
|     <SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText> | ||||
|   </SelectPrimitive.Item> | ||||
| )) | ||||
| SelectItem.displayName = SelectPrimitive.Item.displayName | ||||
| 
 | ||||
| const SelectSeparator = React.forwardRef< | ||||
|   React.ElementRef<typeof SelectPrimitive.Separator>, | ||||
|   React.ComponentPropsWithoutRef<typeof SelectPrimitive.Separator> | ||||
| >(({ className, ...props }, ref) => ( | ||||
|   <SelectPrimitive.Separator | ||||
|     ref={ref} | ||||
|     className={cn("-mx-1 my-1 h-px bg-muted", className)} | ||||
|     {...props} | ||||
|   /> | ||||
| )) | ||||
| SelectSeparator.displayName = SelectPrimitive.Separator.displayName | ||||
| 
 | ||||
| export { | ||||
|   Select, | ||||
|   SelectGroup, | ||||
|   SelectValue, | ||||
|   SelectTrigger, | ||||
|   SelectContent, | ||||
|   SelectLabel, | ||||
|   SelectItem, | ||||
|   SelectSeparator, | ||||
|   SelectScrollUpButton, | ||||
|   SelectScrollDownButton, | ||||
| } | ||||
							
								
								
									
										29
									
								
								frontend/src/components/ui/separator.tsx
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										29
									
								
								frontend/src/components/ui/separator.tsx
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,29 @@ | |||
| import * as React from "react" | ||||
| import * as SeparatorPrimitive from "@radix-ui/react-separator" | ||||
| 
 | ||||
| import { cn } from "@/lib/utils" | ||||
| 
 | ||||
| const Separator = React.forwardRef< | ||||
|   React.ElementRef<typeof SeparatorPrimitive.Root>, | ||||
|   React.ComponentPropsWithoutRef<typeof SeparatorPrimitive.Root> | ||||
| >( | ||||
|   ( | ||||
|     { className, orientation = "horizontal", decorative = true, ...props }, | ||||
|     ref | ||||
|   ) => ( | ||||
|     <SeparatorPrimitive.Root | ||||
|       ref={ref} | ||||
|       decorative={decorative} | ||||
|       orientation={orientation} | ||||
|       className={cn( | ||||
|         "shrink-0 bg-border", | ||||
|         orientation === "horizontal" ? "h-[1px] w-full" : "h-full w-[1px]", | ||||
|         className | ||||
|       )} | ||||
|       {...props} | ||||
|     /> | ||||
|   ) | ||||
| ) | ||||
| Separator.displayName = SeparatorPrimitive.Root.displayName | ||||
| 
 | ||||
| export { Separator } | ||||
							
								
								
									
										131
									
								
								frontend/src/components/ui/sheet.tsx
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										131
									
								
								frontend/src/components/ui/sheet.tsx
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,131 @@ | |||
| import * as SheetPrimitive from "@radix-ui/react-dialog" | ||||
| import { cva, type VariantProps } from "class-variance-authority" | ||||
| import { X } from "lucide-react" | ||||
| import * as React from "react" | ||||
| 
 | ||||
| import { cn } from "@/lib/utils" | ||||
| 
 | ||||
| const Sheet = SheetPrimitive.Root | ||||
| 
 | ||||
| const SheetTrigger = SheetPrimitive.Trigger | ||||
| 
 | ||||
| const SheetClose = SheetPrimitive.Close | ||||
| 
 | ||||
| const SheetPortal = SheetPrimitive.Portal | ||||
| 
 | ||||
| const SheetOverlay = React.forwardRef< | ||||
|   React.ElementRef<typeof SheetPrimitive.Overlay>, | ||||
|   React.ComponentPropsWithoutRef<typeof SheetPrimitive.Overlay> | ||||
| >(({ className, ...props }, ref) => ( | ||||
|   <SheetPrimitive.Overlay | ||||
|     className={cn( | ||||
|       "fixed inset-0 z-50 bg-black/80  data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0", | ||||
|       className | ||||
|     )} | ||||
|     {...props} | ||||
|     ref={ref} | ||||
|   /> | ||||
| )) | ||||
| SheetOverlay.displayName = SheetPrimitive.Overlay.displayName | ||||
| 
 | ||||
| const sheetVariants = cva( | ||||
|   "fixed z-50 gap-4 bg-background p-6 shadow-lg transition ease-in-out data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:duration-300 data-[state=open]:duration-500", | ||||
|   { | ||||
|     variants: { | ||||
|       side: { | ||||
|         top: "inset-x-0 top-0 border-b data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top", | ||||
|         bottom: | ||||
|           "inset-x-0 bottom-0 border-t data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom", | ||||
|         left: "inset-y-0 left-0 h-full w-3/4 border-r data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left sm:max-w-sm", | ||||
|         right: | ||||
|           "inset-y-0 right-0 h-full w-3/4  border-l data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right sm:max-w-sm", | ||||
|       }, | ||||
|     }, | ||||
|     defaultVariants: { | ||||
|       side: "right", | ||||
|     }, | ||||
|   } | ||||
| ) | ||||
| 
 | ||||
| interface SheetContentProps | ||||
|   extends React.ComponentPropsWithoutRef<typeof SheetPrimitive.Content>, | ||||
|   VariantProps<typeof sheetVariants> { } | ||||
| 
 | ||||
| const SheetContent = React.forwardRef< | ||||
|   React.ElementRef<typeof SheetPrimitive.Content>, | ||||
|   SheetContentProps | ||||
| >(({ side = "right", className, children, ...props }, ref) => ( | ||||
|   <SheetPortal> | ||||
|     <SheetOverlay /> | ||||
|     <SheetPrimitive.Content | ||||
|       ref={ref} | ||||
|       className={cn(sheetVariants({ side }), className)} | ||||
|       {...props} | ||||
|     > | ||||
|       {children} | ||||
|       <SheetPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-secondary"> | ||||
|         <X className="h-4 w-4" /> | ||||
|         <span className="sr-only">Close</span> | ||||
|       </SheetPrimitive.Close> | ||||
|     </SheetPrimitive.Content> | ||||
|   </SheetPortal> | ||||
| )) | ||||
| SheetContent.displayName = SheetPrimitive.Content.displayName | ||||
| 
 | ||||
| const SheetHeader = ({ | ||||
|   className, | ||||
|   ...props | ||||
| }: React.HTMLAttributes<HTMLDivElement>) => ( | ||||
|   <div | ||||
|     className={cn( | ||||
|       "flex flex-col space-y-2 text-center sm:text-left", | ||||
|       className | ||||
|     )} | ||||
|     {...props} | ||||
|   /> | ||||
| ) | ||||
| SheetHeader.displayName = "SheetHeader" | ||||
| 
 | ||||
| const SheetFooter = ({ | ||||
|   className, | ||||
|   ...props | ||||
| }: React.HTMLAttributes<HTMLDivElement>) => ( | ||||
|   <div | ||||
|     className={cn( | ||||
|       "flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2", | ||||
|       className | ||||
|     )} | ||||
|     {...props} | ||||
|   /> | ||||
| ) | ||||
| SheetFooter.displayName = "SheetFooter" | ||||
| 
 | ||||
| const SheetTitle = React.forwardRef< | ||||
|   React.ElementRef<typeof SheetPrimitive.Title>, | ||||
|   React.ComponentPropsWithoutRef<typeof SheetPrimitive.Title> | ||||
| >(({ className, ...props }, ref) => ( | ||||
|   <SheetPrimitive.Title | ||||
|     ref={ref} | ||||
|     className={cn("text-lg font-semibold text-foreground", className)} | ||||
|     {...props} | ||||
|   /> | ||||
| )) | ||||
| SheetTitle.displayName = SheetPrimitive.Title.displayName | ||||
| 
 | ||||
| const SheetDescription = React.forwardRef< | ||||
|   React.ElementRef<typeof SheetPrimitive.Description>, | ||||
|   React.ComponentPropsWithoutRef<typeof SheetPrimitive.Description> | ||||
| >(({ className, ...props }, ref) => ( | ||||
|   <SheetPrimitive.Description | ||||
|     ref={ref} | ||||
|     className={cn("text-sm text-muted-foreground", className)} | ||||
|     {...props} | ||||
|   /> | ||||
| )) | ||||
| SheetDescription.displayName = SheetPrimitive.Description.displayName | ||||
| 
 | ||||
| export { | ||||
|   Sheet, SheetClose, | ||||
|   SheetContent, SheetDescription, SheetFooter, SheetHeader, SheetOverlay, SheetPortal, SheetTitle, SheetTrigger | ||||
| } | ||||
| 
 | ||||
							
								
								
									
										761
									
								
								frontend/src/components/ui/sidebar.tsx
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										761
									
								
								frontend/src/components/ui/sidebar.tsx
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,761 @@ | |||
| import * as React from "react" | ||||
| import { Slot } from "@radix-ui/react-slot" | ||||
| import { VariantProps, cva } from "class-variance-authority" | ||||
| import { PanelLeft } from "lucide-react" | ||||
| 
 | ||||
| import { useIsMobile } from "@/hooks/use-mobile" | ||||
| import { cn } from "@/lib/utils" | ||||
| import { Button } from "@/components/ui/button" | ||||
| import { Input } from "@/components/ui/input" | ||||
| import { Separator } from "@/components/ui/separator" | ||||
| import { Sheet, SheetContent } from "@/components/ui/sheet" | ||||
| import { Skeleton } from "@/components/ui/skeleton" | ||||
| import { | ||||
|   Tooltip, | ||||
|   TooltipContent, | ||||
|   TooltipProvider, | ||||
|   TooltipTrigger, | ||||
| } from "@/components/ui/tooltip" | ||||
| 
 | ||||
| const SIDEBAR_COOKIE_NAME = "sidebar:state" | ||||
| const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7 | ||||
| const SIDEBAR_WIDTH = "16rem" | ||||
| const SIDEBAR_WIDTH_MOBILE = "18rem" | ||||
| const SIDEBAR_WIDTH_ICON = "3rem" | ||||
| const SIDEBAR_KEYBOARD_SHORTCUT = "b" | ||||
| 
 | ||||
| type SidebarContext = { | ||||
|   state: "expanded" | "collapsed" | ||||
|   open: boolean | ||||
|   setOpen: (open: boolean) => void | ||||
|   openMobile: boolean | ||||
|   setOpenMobile: (open: boolean) => void | ||||
|   isMobile: boolean | ||||
|   toggleSidebar: () => void | ||||
| } | ||||
| 
 | ||||
| const SidebarContext = React.createContext<SidebarContext | null>(null) | ||||
| 
 | ||||
| function useSidebar() { | ||||
|   const context = React.useContext(SidebarContext) | ||||
|   if (!context) { | ||||
|     throw new Error("useSidebar must be used within a SidebarProvider.") | ||||
|   } | ||||
| 
 | ||||
|   return context | ||||
| } | ||||
| 
 | ||||
| const SidebarProvider = React.forwardRef< | ||||
|   HTMLDivElement, | ||||
|   React.ComponentProps<"div"> & { | ||||
|     defaultOpen?: boolean | ||||
|     open?: boolean | ||||
|     onOpenChange?: (open: boolean) => void | ||||
|   } | ||||
| >( | ||||
|   ( | ||||
|     { | ||||
|       defaultOpen = true, | ||||
|       open: openProp, | ||||
|       onOpenChange: setOpenProp, | ||||
|       className, | ||||
|       style, | ||||
|       children, | ||||
|       ...props | ||||
|     }, | ||||
|     ref | ||||
|   ) => { | ||||
|     const isMobile = useIsMobile() | ||||
|     const [openMobile, setOpenMobile] = React.useState(false) | ||||
| 
 | ||||
|     // This is the internal state of the sidebar.
 | ||||
|     // We use openProp and setOpenProp for control from outside the component.
 | ||||
|     const [_open, _setOpen] = React.useState(defaultOpen) | ||||
|     const open = openProp ?? _open | ||||
|     const setOpen = React.useCallback( | ||||
|       (value: boolean | ((value: boolean) => boolean)) => { | ||||
|         const openState = typeof value === "function" ? value(open) : value | ||||
|         if (setOpenProp) { | ||||
|           setOpenProp(openState) | ||||
|         } else { | ||||
|           _setOpen(openState) | ||||
|         } | ||||
| 
 | ||||
|         // This sets the cookie to keep the sidebar state.
 | ||||
|         document.cookie = `${SIDEBAR_COOKIE_NAME}=${openState}; path=/; max-age=${SIDEBAR_COOKIE_MAX_AGE}` | ||||
|       }, | ||||
|       [setOpenProp, open] | ||||
|     ) | ||||
| 
 | ||||
|     // Helper to toggle the sidebar.
 | ||||
|     const toggleSidebar = React.useCallback(() => { | ||||
|       return isMobile | ||||
|         ? setOpenMobile((open) => !open) | ||||
|         : setOpen((open) => !open) | ||||
|     }, [isMobile, setOpen, setOpenMobile]) | ||||
| 
 | ||||
|     // Adds a keyboard shortcut to toggle the sidebar.
 | ||||
|     React.useEffect(() => { | ||||
|       const handleKeyDown = (event: KeyboardEvent) => { | ||||
|         if ( | ||||
|           event.key === SIDEBAR_KEYBOARD_SHORTCUT && | ||||
|           (event.metaKey || event.ctrlKey) | ||||
|         ) { | ||||
|           event.preventDefault() | ||||
|           toggleSidebar() | ||||
|         } | ||||
|       } | ||||
| 
 | ||||
|       window.addEventListener("keydown", handleKeyDown) | ||||
|       return () => window.removeEventListener("keydown", handleKeyDown) | ||||
|     }, [toggleSidebar]) | ||||
| 
 | ||||
|     // We add a state so that we can do data-state="expanded" or "collapsed".
 | ||||
|     // This makes it easier to style the sidebar with Tailwind classes.
 | ||||
|     const state = open ? "expanded" : "collapsed" | ||||
| 
 | ||||
|     const contextValue = React.useMemo<SidebarContext>( | ||||
|       () => ({ | ||||
|         state, | ||||
|         open, | ||||
|         setOpen, | ||||
|         isMobile, | ||||
|         openMobile, | ||||
|         setOpenMobile, | ||||
|         toggleSidebar, | ||||
|       }), | ||||
|       [state, open, setOpen, isMobile, openMobile, setOpenMobile, toggleSidebar] | ||||
|     ) | ||||
| 
 | ||||
|     return ( | ||||
|       <SidebarContext.Provider value={contextValue}> | ||||
|         <TooltipProvider delayDuration={0}> | ||||
|           <div | ||||
|             style={ | ||||
|               { | ||||
|                 "--sidebar-width": SIDEBAR_WIDTH, | ||||
|                 "--sidebar-width-icon": SIDEBAR_WIDTH_ICON, | ||||
|                 ...style, | ||||
|               } as React.CSSProperties | ||||
|             } | ||||
|             className={cn( | ||||
|               "group/sidebar-wrapper flex min-h-svh w-full has-[[data-variant=inset]]:bg-sidebar", | ||||
|               className | ||||
|             )} | ||||
|             ref={ref} | ||||
|             {...props} | ||||
|           > | ||||
|             {children} | ||||
|           </div> | ||||
|         </TooltipProvider> | ||||
|       </SidebarContext.Provider> | ||||
|     ) | ||||
|   } | ||||
| ) | ||||
| SidebarProvider.displayName = "SidebarProvider" | ||||
| 
 | ||||
| const Sidebar = React.forwardRef< | ||||
|   HTMLDivElement, | ||||
|   React.ComponentProps<"div"> & { | ||||
|     side?: "left" | "right" | ||||
|     variant?: "sidebar" | "floating" | "inset" | ||||
|     collapsible?: "offcanvas" | "icon" | "none" | ||||
|   } | ||||
| >( | ||||
|   ( | ||||
|     { | ||||
|       side = "left", | ||||
|       variant = "sidebar", | ||||
|       collapsible = "offcanvas", | ||||
|       className, | ||||
|       children, | ||||
|       ...props | ||||
|     }, | ||||
|     ref | ||||
|   ) => { | ||||
|     const { isMobile, state, openMobile, setOpenMobile } = useSidebar() | ||||
| 
 | ||||
|     if (collapsible === "none") { | ||||
|       return ( | ||||
|         <div | ||||
|           className={cn( | ||||
|             "flex h-full w-[--sidebar-width] flex-col bg-sidebar text-sidebar-foreground", | ||||
|             className | ||||
|           )} | ||||
|           ref={ref} | ||||
|           {...props} | ||||
|         > | ||||
|           {children} | ||||
|         </div> | ||||
|       ) | ||||
|     } | ||||
| 
 | ||||
|     if (isMobile) { | ||||
|       return ( | ||||
|         <Sheet open={openMobile} onOpenChange={setOpenMobile} {...props}> | ||||
|           <SheetContent | ||||
|             data-sidebar="sidebar" | ||||
|             data-mobile="true" | ||||
|             className="w-[--sidebar-width] bg-sidebar p-0 text-sidebar-foreground [&>button]:hidden" | ||||
|             style={ | ||||
|               { | ||||
|                 "--sidebar-width": SIDEBAR_WIDTH_MOBILE, | ||||
|               } as React.CSSProperties | ||||
|             } | ||||
|             side={side} | ||||
|           > | ||||
|             <div className="flex h-full w-full flex-col">{children}</div> | ||||
|           </SheetContent> | ||||
|         </Sheet> | ||||
|       ) | ||||
|     } | ||||
| 
 | ||||
|     return ( | ||||
|       <div | ||||
|         ref={ref} | ||||
|         className="group peer hidden md:block text-sidebar-foreground" | ||||
|         data-state={state} | ||||
|         data-collapsible={state === "collapsed" ? collapsible : ""} | ||||
|         data-variant={variant} | ||||
|         data-side={side} | ||||
|       > | ||||
|         {/* This is what handles the sidebar gap on desktop */} | ||||
|         <div | ||||
|           className={cn( | ||||
|             "duration-200 relative h-svh w-[--sidebar-width] bg-transparent transition-[width] ease-linear", | ||||
|             "group-data-[collapsible=offcanvas]:w-0", | ||||
|             "group-data-[side=right]:rotate-180", | ||||
|             variant === "floating" || variant === "inset" | ||||
|               ? "group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)_+_theme(spacing.4))]" | ||||
|               : "group-data-[collapsible=icon]:w-[--sidebar-width-icon]" | ||||
|           )} | ||||
|         /> | ||||
|         <div | ||||
|           className={cn( | ||||
|             "duration-200 fixed inset-y-0 z-10 hidden h-svh w-[--sidebar-width] transition-[left,right,width] ease-linear md:flex", | ||||
|             side === "left" | ||||
|               ? "left-0 group-data-[collapsible=offcanvas]:left-[calc(var(--sidebar-width)*-1)]" | ||||
|               : "right-0 group-data-[collapsible=offcanvas]:right-[calc(var(--sidebar-width)*-1)]", | ||||
|             // Adjust the padding for floating and inset variants.
 | ||||
|             variant === "floating" || variant === "inset" | ||||
|               ? "p-2 group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)_+_theme(spacing.4)_+2px)]" | ||||
|               : "group-data-[collapsible=icon]:w-[--sidebar-width-icon] group-data-[side=left]:border-r group-data-[side=right]:border-l", | ||||
|             className | ||||
|           )} | ||||
|           {...props} | ||||
|         > | ||||
|           <div | ||||
|             data-sidebar="sidebar" | ||||
|             className="flex h-full w-full flex-col bg-sidebar group-data-[variant=floating]:rounded-lg group-data-[variant=floating]:border group-data-[variant=floating]:border-sidebar-border group-data-[variant=floating]:shadow" | ||||
|           > | ||||
|             {children} | ||||
|           </div> | ||||
|         </div> | ||||
|       </div> | ||||
|     ) | ||||
|   } | ||||
| ) | ||||
| Sidebar.displayName = "Sidebar" | ||||
| 
 | ||||
| const SidebarTrigger = React.forwardRef< | ||||
|   React.ElementRef<typeof Button>, | ||||
|   React.ComponentProps<typeof Button> | ||||
| >(({ className, onClick, ...props }, ref) => { | ||||
|   const { toggleSidebar } = useSidebar() | ||||
| 
 | ||||
|   return ( | ||||
|     <Button | ||||
|       ref={ref} | ||||
|       data-sidebar="trigger" | ||||
|       variant="ghost" | ||||
|       size="icon" | ||||
|       className={cn("h-7 w-7", className)} | ||||
|       onClick={(event) => { | ||||
|         onClick?.(event) | ||||
|         toggleSidebar() | ||||
|       }} | ||||
|       {...props} | ||||
|     > | ||||
|       <PanelLeft /> | ||||
|       <span className="sr-only">Toggle Sidebar</span> | ||||
|     </Button> | ||||
|   ) | ||||
| }) | ||||
| SidebarTrigger.displayName = "SidebarTrigger" | ||||
| 
 | ||||
| const SidebarRail = React.forwardRef< | ||||
|   HTMLButtonElement, | ||||
|   React.ComponentProps<"button"> | ||||
| >(({ className, ...props }, ref) => { | ||||
|   const { toggleSidebar } = useSidebar() | ||||
| 
 | ||||
|   return ( | ||||
|     <button | ||||
|       ref={ref} | ||||
|       data-sidebar="rail" | ||||
|       aria-label="Toggle Sidebar" | ||||
|       tabIndex={-1} | ||||
|       onClick={toggleSidebar} | ||||
|       title="Toggle Sidebar" | ||||
|       className={cn( | ||||
|         "absolute inset-y-0 z-20 hidden w-4 -translate-x-1/2 transition-all ease-linear after:absolute after:inset-y-0 after:left-1/2 after:w-[2px] hover:after:bg-sidebar-border group-data-[side=left]:-right-4 group-data-[side=right]:left-0 sm:flex", | ||||
|         "[[data-side=left]_&]:cursor-w-resize [[data-side=right]_&]:cursor-e-resize", | ||||
|         "[[data-side=left][data-state=collapsed]_&]:cursor-e-resize [[data-side=right][data-state=collapsed]_&]:cursor-w-resize", | ||||
|         "group-data-[collapsible=offcanvas]:translate-x-0 group-data-[collapsible=offcanvas]:after:left-full group-data-[collapsible=offcanvas]:hover:bg-sidebar", | ||||
|         "[[data-side=left][data-collapsible=offcanvas]_&]:-right-2", | ||||
|         "[[data-side=right][data-collapsible=offcanvas]_&]:-left-2", | ||||
|         className | ||||
|       )} | ||||
|       {...props} | ||||
|     /> | ||||
|   ) | ||||
| }) | ||||
| SidebarRail.displayName = "SidebarRail" | ||||
| 
 | ||||
| const SidebarInset = React.forwardRef< | ||||
|   HTMLDivElement, | ||||
|   React.ComponentProps<"main"> | ||||
| >(({ className, ...props }, ref) => { | ||||
|   return ( | ||||
|     <main | ||||
|       ref={ref} | ||||
|       className={cn( | ||||
|         "relative flex min-h-svh flex-1 flex-col bg-background", | ||||
|         "peer-data-[variant=inset]:min-h-[calc(100svh-theme(spacing.4))] md:peer-data-[variant=inset]:m-2 md:peer-data-[state=collapsed]:peer-data-[variant=inset]:ml-2 md:peer-data-[variant=inset]:ml-0 md:peer-data-[variant=inset]:rounded-xl md:peer-data-[variant=inset]:shadow", | ||||
|         className | ||||
|       )} | ||||
|       {...props} | ||||
|     /> | ||||
|   ) | ||||
| }) | ||||
| SidebarInset.displayName = "SidebarInset" | ||||
| 
 | ||||
| const SidebarInput = React.forwardRef< | ||||
|   React.ElementRef<typeof Input>, | ||||
|   React.ComponentProps<typeof Input> | ||||
| >(({ className, ...props }, ref) => { | ||||
|   return ( | ||||
|     <Input | ||||
|       ref={ref} | ||||
|       data-sidebar="input" | ||||
|       className={cn( | ||||
|         "h-8 w-full bg-background shadow-none focus-visible:ring-2 focus-visible:ring-sidebar-ring", | ||||
|         className | ||||
|       )} | ||||
|       {...props} | ||||
|     /> | ||||
|   ) | ||||
| }) | ||||
| SidebarInput.displayName = "SidebarInput" | ||||
| 
 | ||||
| const SidebarHeader = React.forwardRef< | ||||
|   HTMLDivElement, | ||||
|   React.ComponentProps<"div"> | ||||
| >(({ className, ...props }, ref) => { | ||||
|   return ( | ||||
|     <div | ||||
|       ref={ref} | ||||
|       data-sidebar="header" | ||||
|       className={cn("flex flex-col gap-2 p-2", className)} | ||||
|       {...props} | ||||
|     /> | ||||
|   ) | ||||
| }) | ||||
| SidebarHeader.displayName = "SidebarHeader" | ||||
| 
 | ||||
| const SidebarFooter = React.forwardRef< | ||||
|   HTMLDivElement, | ||||
|   React.ComponentProps<"div"> | ||||
| >(({ className, ...props }, ref) => { | ||||
|   return ( | ||||
|     <div | ||||
|       ref={ref} | ||||
|       data-sidebar="footer" | ||||
|       className={cn("flex flex-col gap-2 p-2", className)} | ||||
|       {...props} | ||||
|     /> | ||||
|   ) | ||||
| }) | ||||
| SidebarFooter.displayName = "SidebarFooter" | ||||
| 
 | ||||
| const SidebarSeparator = React.forwardRef< | ||||
|   React.ElementRef<typeof Separator>, | ||||
|   React.ComponentProps<typeof Separator> | ||||
| >(({ className, ...props }, ref) => { | ||||
|   return ( | ||||
|     <Separator | ||||
|       ref={ref} | ||||
|       data-sidebar="separator" | ||||
|       className={cn("mx-2 w-auto bg-sidebar-border", className)} | ||||
|       {...props} | ||||
|     /> | ||||
|   ) | ||||
| }) | ||||
| SidebarSeparator.displayName = "SidebarSeparator" | ||||
| 
 | ||||
| const SidebarContent = React.forwardRef< | ||||
|   HTMLDivElement, | ||||
|   React.ComponentProps<"div"> | ||||
| >(({ className, ...props }, ref) => { | ||||
|   return ( | ||||
|     <div | ||||
|       ref={ref} | ||||
|       data-sidebar="content" | ||||
|       className={cn( | ||||
|         "flex min-h-0 flex-1 flex-col gap-2 overflow-auto group-data-[collapsible=icon]:overflow-hidden", | ||||
|         className | ||||
|       )} | ||||
|       {...props} | ||||
|     /> | ||||
|   ) | ||||
| }) | ||||
| SidebarContent.displayName = "SidebarContent" | ||||
| 
 | ||||
| const SidebarGroup = React.forwardRef< | ||||
|   HTMLDivElement, | ||||
|   React.ComponentProps<"div"> | ||||
| >(({ className, ...props }, ref) => { | ||||
|   return ( | ||||
|     <div | ||||
|       ref={ref} | ||||
|       data-sidebar="group" | ||||
|       className={cn("relative flex w-full min-w-0 flex-col p-2", className)} | ||||
|       {...props} | ||||
|     /> | ||||
|   ) | ||||
| }) | ||||
| SidebarGroup.displayName = "SidebarGroup" | ||||
| 
 | ||||
| const SidebarGroupLabel = React.forwardRef< | ||||
|   HTMLDivElement, | ||||
|   React.ComponentProps<"div"> & { asChild?: boolean } | ||||
| >(({ className, asChild = false, ...props }, ref) => { | ||||
|   const Comp = asChild ? Slot : "div" | ||||
| 
 | ||||
|   return ( | ||||
|     <Comp | ||||
|       ref={ref} | ||||
|       data-sidebar="group-label" | ||||
|       className={cn( | ||||
|         "duration-200 flex h-8 shrink-0 items-center rounded-md px-2 text-xs font-medium text-sidebar-foreground/70 outline-none ring-sidebar-ring transition-[margin,opa] ease-linear focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0", | ||||
|         "group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0", | ||||
|         className | ||||
|       )} | ||||
|       {...props} | ||||
|     /> | ||||
|   ) | ||||
| }) | ||||
| SidebarGroupLabel.displayName = "SidebarGroupLabel" | ||||
| 
 | ||||
| const SidebarGroupAction = React.forwardRef< | ||||
|   HTMLButtonElement, | ||||
|   React.ComponentProps<"button"> & { asChild?: boolean } | ||||
| >(({ className, asChild = false, ...props }, ref) => { | ||||
|   const Comp = asChild ? Slot : "button" | ||||
| 
 | ||||
|   return ( | ||||
|     <Comp | ||||
|       ref={ref} | ||||
|       data-sidebar="group-action" | ||||
|       className={cn( | ||||
|         "absolute right-3 top-3.5 flex aspect-square w-5 items-center justify-center rounded-md p-0 text-sidebar-foreground outline-none ring-sidebar-ring transition-transform hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0", | ||||
|         // Increases the hit area of the button on mobile.
 | ||||
|         "after:absolute after:-inset-2 after:md:hidden", | ||||
|         "group-data-[collapsible=icon]:hidden", | ||||
|         className | ||||
|       )} | ||||
|       {...props} | ||||
|     /> | ||||
|   ) | ||||
| }) | ||||
| SidebarGroupAction.displayName = "SidebarGroupAction" | ||||
| 
 | ||||
| const SidebarGroupContent = React.forwardRef< | ||||
|   HTMLDivElement, | ||||
|   React.ComponentProps<"div"> | ||||
| >(({ className, ...props }, ref) => ( | ||||
|   <div | ||||
|     ref={ref} | ||||
|     data-sidebar="group-content" | ||||
|     className={cn("w-full text-sm", className)} | ||||
|     {...props} | ||||
|   /> | ||||
| )) | ||||
| SidebarGroupContent.displayName = "SidebarGroupContent" | ||||
| 
 | ||||
| const SidebarMenu = React.forwardRef< | ||||
|   HTMLUListElement, | ||||
|   React.ComponentProps<"ul"> | ||||
| >(({ className, ...props }, ref) => ( | ||||
|   <ul | ||||
|     ref={ref} | ||||
|     data-sidebar="menu" | ||||
|     className={cn("flex w-full min-w-0 flex-col gap-1", className)} | ||||
|     {...props} | ||||
|   /> | ||||
| )) | ||||
| SidebarMenu.displayName = "SidebarMenu" | ||||
| 
 | ||||
| const SidebarMenuItem = React.forwardRef< | ||||
|   HTMLLIElement, | ||||
|   React.ComponentProps<"li"> | ||||
| >(({ className, ...props }, ref) => ( | ||||
|   <li | ||||
|     ref={ref} | ||||
|     data-sidebar="menu-item" | ||||
|     className={cn("group/menu-item relative", className)} | ||||
|     {...props} | ||||
|   /> | ||||
| )) | ||||
| SidebarMenuItem.displayName = "SidebarMenuItem" | ||||
| 
 | ||||
| const sidebarMenuButtonVariants = cva( | ||||
|   "peer/menu-button flex w-full items-center gap-2 overflow-hidden rounded-md p-2 text-left text-sm outline-none ring-sidebar-ring transition-[width,height,padding] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 group-has-[[data-sidebar=menu-action]]/menu-item:pr-8 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-[active=true]:bg-sidebar-accent data-[active=true]:font-medium data-[active=true]:text-sidebar-accent-foreground data-[state=open]:hover:bg-sidebar-accent data-[state=open]:hover:text-sidebar-accent-foreground group-data-[collapsible=icon]:!size-8 group-data-[collapsible=icon]:!p-2 [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0", | ||||
|   { | ||||
|     variants: { | ||||
|       variant: { | ||||
|         default: "hover:bg-sidebar-accent hover:text-sidebar-accent-foreground", | ||||
|         outline: | ||||
|           "bg-background shadow-[0_0_0_1px_hsl(var(--sidebar-border))] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground hover:shadow-[0_0_0_1px_hsl(var(--sidebar-accent))]", | ||||
|       }, | ||||
|       size: { | ||||
|         default: "h-8 text-sm", | ||||
|         sm: "h-7 text-xs", | ||||
|         lg: "h-12 text-sm group-data-[collapsible=icon]:!p-0", | ||||
|       }, | ||||
|     }, | ||||
|     defaultVariants: { | ||||
|       variant: "default", | ||||
|       size: "default", | ||||
|     }, | ||||
|   } | ||||
| ) | ||||
| 
 | ||||
| const SidebarMenuButton = React.forwardRef< | ||||
|   HTMLButtonElement, | ||||
|   React.ComponentProps<"button"> & { | ||||
|     asChild?: boolean | ||||
|     isActive?: boolean | ||||
|     tooltip?: string | React.ComponentProps<typeof TooltipContent> | ||||
|   } & VariantProps<typeof sidebarMenuButtonVariants> | ||||
| >( | ||||
|   ( | ||||
|     { | ||||
|       asChild = false, | ||||
|       isActive = false, | ||||
|       variant = "default", | ||||
|       size = "default", | ||||
|       tooltip, | ||||
|       className, | ||||
|       ...props | ||||
|     }, | ||||
|     ref | ||||
|   ) => { | ||||
|     const Comp = asChild ? Slot : "button" | ||||
|     const { isMobile, state } = useSidebar() | ||||
| 
 | ||||
|     const button = ( | ||||
|       <Comp | ||||
|         ref={ref} | ||||
|         data-sidebar="menu-button" | ||||
|         data-size={size} | ||||
|         data-active={isActive} | ||||
|         className={cn(sidebarMenuButtonVariants({ variant, size }), className)} | ||||
|         {...props} | ||||
|       /> | ||||
|     ) | ||||
| 
 | ||||
|     if (!tooltip) { | ||||
|       return button | ||||
|     } | ||||
| 
 | ||||
|     if (typeof tooltip === "string") { | ||||
|       tooltip = { | ||||
|         children: tooltip, | ||||
|       } | ||||
|     } | ||||
| 
 | ||||
|     return ( | ||||
|       <Tooltip> | ||||
|         <TooltipTrigger asChild>{button}</TooltipTrigger> | ||||
|         <TooltipContent | ||||
|           side="right" | ||||
|           align="center" | ||||
|           hidden={state !== "collapsed" || isMobile} | ||||
|           {...tooltip} | ||||
|         /> | ||||
|       </Tooltip> | ||||
|     ) | ||||
|   } | ||||
| ) | ||||
| SidebarMenuButton.displayName = "SidebarMenuButton" | ||||
| 
 | ||||
| const SidebarMenuAction = React.forwardRef< | ||||
|   HTMLButtonElement, | ||||
|   React.ComponentProps<"button"> & { | ||||
|     asChild?: boolean | ||||
|     showOnHover?: boolean | ||||
|   } | ||||
| >(({ className, asChild = false, showOnHover = false, ...props }, ref) => { | ||||
|   const Comp = asChild ? Slot : "button" | ||||
| 
 | ||||
|   return ( | ||||
|     <Comp | ||||
|       ref={ref} | ||||
|       data-sidebar="menu-action" | ||||
|       className={cn( | ||||
|         "absolute right-1 top-1.5 flex aspect-square w-5 items-center justify-center rounded-md p-0 text-sidebar-foreground outline-none ring-sidebar-ring transition-transform hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 peer-hover/menu-button:text-sidebar-accent-foreground [&>svg]:size-4 [&>svg]:shrink-0", | ||||
|         // Increases the hit area of the button on mobile.
 | ||||
|         "after:absolute after:-inset-2 after:md:hidden", | ||||
|         "peer-data-[size=sm]/menu-button:top-1", | ||||
|         "peer-data-[size=default]/menu-button:top-1.5", | ||||
|         "peer-data-[size=lg]/menu-button:top-2.5", | ||||
|         "group-data-[collapsible=icon]:hidden", | ||||
|         showOnHover && | ||||
|           "group-focus-within/menu-item:opacity-100 group-hover/menu-item:opacity-100 data-[state=open]:opacity-100 peer-data-[active=true]/menu-button:text-sidebar-accent-foreground md:opacity-0", | ||||
|         className | ||||
|       )} | ||||
|       {...props} | ||||
|     /> | ||||
|   ) | ||||
| }) | ||||
| SidebarMenuAction.displayName = "SidebarMenuAction" | ||||
| 
 | ||||
| const SidebarMenuBadge = React.forwardRef< | ||||
|   HTMLDivElement, | ||||
|   React.ComponentProps<"div"> | ||||
| >(({ className, ...props }, ref) => ( | ||||
|   <div | ||||
|     ref={ref} | ||||
|     data-sidebar="menu-badge" | ||||
|     className={cn( | ||||
|       "absolute right-1 flex h-5 min-w-5 items-center justify-center rounded-md px-1 text-xs font-medium tabular-nums text-sidebar-foreground select-none pointer-events-none", | ||||
|       "peer-hover/menu-button:text-sidebar-accent-foreground peer-data-[active=true]/menu-button:text-sidebar-accent-foreground", | ||||
|       "peer-data-[size=sm]/menu-button:top-1", | ||||
|       "peer-data-[size=default]/menu-button:top-1.5", | ||||
|       "peer-data-[size=lg]/menu-button:top-2.5", | ||||
|       "group-data-[collapsible=icon]:hidden", | ||||
|       className | ||||
|     )} | ||||
|     {...props} | ||||
|   /> | ||||
| )) | ||||
| SidebarMenuBadge.displayName = "SidebarMenuBadge" | ||||
| 
 | ||||
| const SidebarMenuSkeleton = React.forwardRef< | ||||
|   HTMLDivElement, | ||||
|   React.ComponentProps<"div"> & { | ||||
|     showIcon?: boolean | ||||
|   } | ||||
| >(({ className, showIcon = false, ...props }, ref) => { | ||||
|   // Random width between 50 to 90%.
 | ||||
|   const width = React.useMemo(() => { | ||||
|     return `${Math.floor(Math.random() * 40) + 50}%` | ||||
|   }, []) | ||||
| 
 | ||||
|   return ( | ||||
|     <div | ||||
|       ref={ref} | ||||
|       data-sidebar="menu-skeleton" | ||||
|       className={cn("rounded-md h-8 flex gap-2 px-2 items-center", className)} | ||||
|       {...props} | ||||
|     > | ||||
|       {showIcon && ( | ||||
|         <Skeleton | ||||
|           className="size-4 rounded-md" | ||||
|           data-sidebar="menu-skeleton-icon" | ||||
|         /> | ||||
|       )} | ||||
|       <Skeleton | ||||
|         className="h-4 flex-1 max-w-[--skeleton-width]" | ||||
|         data-sidebar="menu-skeleton-text" | ||||
|         style={ | ||||
|           { | ||||
|             "--skeleton-width": width, | ||||
|           } as React.CSSProperties | ||||
|         } | ||||
|       /> | ||||
|     </div> | ||||
|   ) | ||||
| }) | ||||
| SidebarMenuSkeleton.displayName = "SidebarMenuSkeleton" | ||||
| 
 | ||||
| const SidebarMenuSub = React.forwardRef< | ||||
|   HTMLUListElement, | ||||
|   React.ComponentProps<"ul"> | ||||
| >(({ className, ...props }, ref) => ( | ||||
|   <ul | ||||
|     ref={ref} | ||||
|     data-sidebar="menu-sub" | ||||
|     className={cn( | ||||
|       "mx-3.5 flex min-w-0 translate-x-px flex-col gap-1 border-l border-sidebar-border px-2.5 py-0.5", | ||||
|       "group-data-[collapsible=icon]:hidden", | ||||
|       className | ||||
|     )} | ||||
|     {...props} | ||||
|   /> | ||||
| )) | ||||
| SidebarMenuSub.displayName = "SidebarMenuSub" | ||||
| 
 | ||||
| const SidebarMenuSubItem = React.forwardRef< | ||||
|   HTMLLIElement, | ||||
|   React.ComponentProps<"li"> | ||||
| >(({ ...props }, ref) => <li ref={ref} {...props} />) | ||||
| SidebarMenuSubItem.displayName = "SidebarMenuSubItem" | ||||
| 
 | ||||
| const SidebarMenuSubButton = React.forwardRef< | ||||
|   HTMLAnchorElement, | ||||
|   React.ComponentProps<"a"> & { | ||||
|     asChild?: boolean | ||||
|     size?: "sm" | "md" | ||||
|     isActive?: boolean | ||||
|   } | ||||
| >(({ asChild = false, size = "md", isActive, className, ...props }, ref) => { | ||||
|   const Comp = asChild ? Slot : "a" | ||||
| 
 | ||||
|   return ( | ||||
|     <Comp | ||||
|       ref={ref} | ||||
|       data-sidebar="menu-sub-button" | ||||
|       data-size={size} | ||||
|       data-active={isActive} | ||||
|       className={cn( | ||||
|         "flex h-7 min-w-0 -translate-x-px items-center gap-2 overflow-hidden rounded-md px-2 text-sidebar-foreground outline-none ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0 [&>svg]:text-sidebar-accent-foreground", | ||||
|         "data-[active=true]:bg-sidebar-accent data-[active=true]:text-sidebar-accent-foreground", | ||||
|         size === "sm" && "text-xs", | ||||
|         size === "md" && "text-sm", | ||||
|         "group-data-[collapsible=icon]:hidden", | ||||
|         className | ||||
|       )} | ||||
|       {...props} | ||||
|     /> | ||||
|   ) | ||||
| }) | ||||
| SidebarMenuSubButton.displayName = "SidebarMenuSubButton" | ||||
| 
 | ||||
| export { | ||||
|   Sidebar, | ||||
|   SidebarContent, | ||||
|   SidebarFooter, | ||||
|   SidebarGroup, | ||||
|   SidebarGroupAction, | ||||
|   SidebarGroupContent, | ||||
|   SidebarGroupLabel, | ||||
|   SidebarHeader, | ||||
|   SidebarInput, | ||||
|   SidebarInset, | ||||
|   SidebarMenu, | ||||
|   SidebarMenuAction, | ||||
|   SidebarMenuBadge, | ||||
|   SidebarMenuButton, | ||||
|   SidebarMenuItem, | ||||
|   SidebarMenuSkeleton, | ||||
|   SidebarMenuSub, | ||||
|   SidebarMenuSubButton, | ||||
|   SidebarMenuSubItem, | ||||
|   SidebarProvider, | ||||
|   SidebarRail, | ||||
|   SidebarSeparator, | ||||
|   SidebarTrigger, | ||||
|   useSidebar, | ||||
| } | ||||
							
								
								
									
										15
									
								
								frontend/src/components/ui/skeleton.tsx
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										15
									
								
								frontend/src/components/ui/skeleton.tsx
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,15 @@ | |||
| import { cn } from "@/lib/utils" | ||||
| 
 | ||||
| function Skeleton({ | ||||
|   className, | ||||
|   ...props | ||||
| }: React.HTMLAttributes<HTMLDivElement>) { | ||||
|   return ( | ||||
|     <div | ||||
|       className={cn("animate-pulse rounded-md bg-muted", className)} | ||||
|       {...props} | ||||
|     /> | ||||
|   ) | ||||
| } | ||||
| 
 | ||||
| export { Skeleton } | ||||
							
								
								
									
										26
									
								
								frontend/src/components/ui/slider.tsx
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										26
									
								
								frontend/src/components/ui/slider.tsx
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,26 @@ | |||
| import * as React from "react" | ||||
| import * as SliderPrimitive from "@radix-ui/react-slider" | ||||
| 
 | ||||
| import { cn } from "@/lib/utils" | ||||
| 
 | ||||
| const Slider = React.forwardRef< | ||||
|   React.ElementRef<typeof SliderPrimitive.Root>, | ||||
|   React.ComponentPropsWithoutRef<typeof SliderPrimitive.Root> | ||||
| >(({ className, ...props }, ref) => ( | ||||
|   <SliderPrimitive.Root | ||||
|     ref={ref} | ||||
|     className={cn( | ||||
|       "relative flex w-full touch-none select-none items-center", | ||||
|       className | ||||
|     )} | ||||
|     {...props} | ||||
|   > | ||||
|     <SliderPrimitive.Track className="relative h-2 w-full grow overflow-hidden rounded-full bg-secondary"> | ||||
|       <SliderPrimitive.Range className="absolute h-full bg-primary" /> | ||||
|     </SliderPrimitive.Track> | ||||
|     <SliderPrimitive.Thumb className="block h-5 w-5 rounded-full border-2 border-primary bg-background ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50" /> | ||||
|   </SliderPrimitive.Root> | ||||
| )) | ||||
| Slider.displayName = SliderPrimitive.Root.displayName | ||||
| 
 | ||||
| export { Slider } | ||||
							
								
								
									
										29
									
								
								frontend/src/components/ui/sonner.tsx
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										29
									
								
								frontend/src/components/ui/sonner.tsx
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,29 @@ | |||
| import { useTheme } from "next-themes" | ||||
| import { Toaster as Sonner, toast } from "sonner" | ||||
| 
 | ||||
| type ToasterProps = React.ComponentProps<typeof Sonner> | ||||
| 
 | ||||
| const Toaster = ({ ...props }: ToasterProps) => { | ||||
|   const { theme = "system" } = useTheme() | ||||
| 
 | ||||
|   return ( | ||||
|     <Sonner | ||||
|       theme={theme as ToasterProps["theme"]} | ||||
|       className="toaster group" | ||||
|       toastOptions={{ | ||||
|         classNames: { | ||||
|           toast: | ||||
|             "group toast group-[.toaster]:bg-background group-[.toaster]:text-foreground group-[.toaster]:border-border group-[.toaster]:shadow-lg", | ||||
|           description: "group-[.toast]:text-muted-foreground", | ||||
|           actionButton: | ||||
|             "group-[.toast]:bg-primary group-[.toast]:text-primary-foreground", | ||||
|           cancelButton: | ||||
|             "group-[.toast]:bg-muted group-[.toast]:text-muted-foreground", | ||||
|         }, | ||||
|       }} | ||||
|       {...props} | ||||
|     /> | ||||
|   ) | ||||
| } | ||||
| 
 | ||||
| export { Toaster, toast } | ||||
							
								
								
									
										27
									
								
								frontend/src/components/ui/switch.tsx
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										27
									
								
								frontend/src/components/ui/switch.tsx
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,27 @@ | |||
| import * as React from "react" | ||||
| import * as SwitchPrimitives from "@radix-ui/react-switch" | ||||
| 
 | ||||
| import { cn } from "@/lib/utils" | ||||
| 
 | ||||
| const Switch = React.forwardRef< | ||||
|   React.ElementRef<typeof SwitchPrimitives.Root>, | ||||
|   React.ComponentPropsWithoutRef<typeof SwitchPrimitives.Root> | ||||
| >(({ className, ...props }, ref) => ( | ||||
|   <SwitchPrimitives.Root | ||||
|     className={cn( | ||||
|       "peer inline-flex h-6 w-11 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=unchecked]:bg-input", | ||||
|       className | ||||
|     )} | ||||
|     {...props} | ||||
|     ref={ref} | ||||
|   > | ||||
|     <SwitchPrimitives.Thumb | ||||
|       className={cn( | ||||
|         "pointer-events-none block h-5 w-5 rounded-full bg-background shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-5 data-[state=unchecked]:translate-x-0" | ||||
|       )} | ||||
|     /> | ||||
|   </SwitchPrimitives.Root> | ||||
| )) | ||||
| Switch.displayName = SwitchPrimitives.Root.displayName | ||||
| 
 | ||||
| export { Switch } | ||||
							
								
								
									
										117
									
								
								frontend/src/components/ui/table.tsx
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										117
									
								
								frontend/src/components/ui/table.tsx
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,117 @@ | |||
| import * as React from "react" | ||||
| 
 | ||||
| import { cn } from "@/lib/utils" | ||||
| 
 | ||||
| const Table = React.forwardRef< | ||||
|   HTMLTableElement, | ||||
|   React.HTMLAttributes<HTMLTableElement> | ||||
| >(({ className, ...props }, ref) => ( | ||||
|   <div className="relative w-full overflow-auto"> | ||||
|     <table | ||||
|       ref={ref} | ||||
|       className={cn("w-full caption-bottom text-sm", className)} | ||||
|       {...props} | ||||
|     /> | ||||
|   </div> | ||||
| )) | ||||
| Table.displayName = "Table" | ||||
| 
 | ||||
| const TableHeader = React.forwardRef< | ||||
|   HTMLTableSectionElement, | ||||
|   React.HTMLAttributes<HTMLTableSectionElement> | ||||
| >(({ className, ...props }, ref) => ( | ||||
|   <thead ref={ref} className={cn("[&_tr]:border-b", className)} {...props} /> | ||||
| )) | ||||
| TableHeader.displayName = "TableHeader" | ||||
| 
 | ||||
| const TableBody = React.forwardRef< | ||||
|   HTMLTableSectionElement, | ||||
|   React.HTMLAttributes<HTMLTableSectionElement> | ||||
| >(({ className, ...props }, ref) => ( | ||||
|   <tbody | ||||
|     ref={ref} | ||||
|     className={cn("[&_tr:last-child]:border-0", className)} | ||||
|     {...props} | ||||
|   /> | ||||
| )) | ||||
| TableBody.displayName = "TableBody" | ||||
| 
 | ||||
| const TableFooter = React.forwardRef< | ||||
|   HTMLTableSectionElement, | ||||
|   React.HTMLAttributes<HTMLTableSectionElement> | ||||
| >(({ className, ...props }, ref) => ( | ||||
|   <tfoot | ||||
|     ref={ref} | ||||
|     className={cn( | ||||
|       "border-t bg-muted/50 font-medium [&>tr]:last:border-b-0", | ||||
|       className | ||||
|     )} | ||||
|     {...props} | ||||
|   /> | ||||
| )) | ||||
| TableFooter.displayName = "TableFooter" | ||||
| 
 | ||||
| const TableRow = React.forwardRef< | ||||
|   HTMLTableRowElement, | ||||
|   React.HTMLAttributes<HTMLTableRowElement> | ||||
| >(({ className, ...props }, ref) => ( | ||||
|   <tr | ||||
|     ref={ref} | ||||
|     className={cn( | ||||
|       "border-b transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted", | ||||
|       className | ||||
|     )} | ||||
|     {...props} | ||||
|   /> | ||||
| )) | ||||
| TableRow.displayName = "TableRow" | ||||
| 
 | ||||
| const TableHead = React.forwardRef< | ||||
|   HTMLTableCellElement, | ||||
|   React.ThHTMLAttributes<HTMLTableCellElement> | ||||
| >(({ className, ...props }, ref) => ( | ||||
|   <th | ||||
|     ref={ref} | ||||
|     className={cn( | ||||
|       "h-12 px-4 text-left align-middle font-medium text-muted-foreground [&:has([role=checkbox])]:pr-0", | ||||
|       className | ||||
|     )} | ||||
|     {...props} | ||||
|   /> | ||||
| )) | ||||
| TableHead.displayName = "TableHead" | ||||
| 
 | ||||
| const TableCell = React.forwardRef< | ||||
|   HTMLTableCellElement, | ||||
|   React.TdHTMLAttributes<HTMLTableCellElement> | ||||
| >(({ className, ...props }, ref) => ( | ||||
|   <td | ||||
|     ref={ref} | ||||
|     className={cn("p-4 align-middle [&:has([role=checkbox])]:pr-0", className)} | ||||
|     {...props} | ||||
|   /> | ||||
| )) | ||||
| TableCell.displayName = "TableCell" | ||||
| 
 | ||||
| const TableCaption = React.forwardRef< | ||||
|   HTMLTableCaptionElement, | ||||
|   React.HTMLAttributes<HTMLTableCaptionElement> | ||||
| >(({ className, ...props }, ref) => ( | ||||
|   <caption | ||||
|     ref={ref} | ||||
|     className={cn("mt-4 text-sm text-muted-foreground", className)} | ||||
|     {...props} | ||||
|   /> | ||||
| )) | ||||
| TableCaption.displayName = "TableCaption" | ||||
| 
 | ||||
| export { | ||||
|   Table, | ||||
|   TableHeader, | ||||
|   TableBody, | ||||
|   TableFooter, | ||||
|   TableHead, | ||||
|   TableRow, | ||||
|   TableCell, | ||||
|   TableCaption, | ||||
| } | ||||
							
								
								
									
										53
									
								
								frontend/src/components/ui/tabs.tsx
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										53
									
								
								frontend/src/components/ui/tabs.tsx
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,53 @@ | |||
| import * as React from "react" | ||||
| import * as TabsPrimitive from "@radix-ui/react-tabs" | ||||
| 
 | ||||
| import { cn } from "@/lib/utils" | ||||
| 
 | ||||
| const Tabs = TabsPrimitive.Root | ||||
| 
 | ||||
| const TabsList = React.forwardRef< | ||||
|   React.ElementRef<typeof TabsPrimitive.List>, | ||||
|   React.ComponentPropsWithoutRef<typeof TabsPrimitive.List> | ||||
| >(({ className, ...props }, ref) => ( | ||||
|   <TabsPrimitive.List | ||||
|     ref={ref} | ||||
|     className={cn( | ||||
|       "inline-flex h-10 items-center justify-center rounded-md bg-muted p-1 text-muted-foreground", | ||||
|       className | ||||
|     )} | ||||
|     {...props} | ||||
|   /> | ||||
| )) | ||||
| TabsList.displayName = TabsPrimitive.List.displayName | ||||
| 
 | ||||
| const TabsTrigger = React.forwardRef< | ||||
|   React.ElementRef<typeof TabsPrimitive.Trigger>, | ||||
|   React.ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger> | ||||
| >(({ className, ...props }, ref) => ( | ||||
|   <TabsPrimitive.Trigger | ||||
|     ref={ref} | ||||
|     className={cn( | ||||
|       "inline-flex items-center justify-center whitespace-nowrap rounded-sm px-3 py-1.5 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow-sm", | ||||
|       className | ||||
|     )} | ||||
|     {...props} | ||||
|   /> | ||||
| )) | ||||
| TabsTrigger.displayName = TabsPrimitive.Trigger.displayName | ||||
| 
 | ||||
| const TabsContent = React.forwardRef< | ||||
|   React.ElementRef<typeof TabsPrimitive.Content>, | ||||
|   React.ComponentPropsWithoutRef<typeof TabsPrimitive.Content> | ||||
| >(({ className, ...props }, ref) => ( | ||||
|   <TabsPrimitive.Content | ||||
|     ref={ref} | ||||
|     className={cn( | ||||
|       "mt-2 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2", | ||||
|       className | ||||
|     )} | ||||
|     {...props} | ||||
|   /> | ||||
| )) | ||||
| TabsContent.displayName = TabsPrimitive.Content.displayName | ||||
| 
 | ||||
| export { Tabs, TabsList, TabsTrigger, TabsContent } | ||||
							
								
								
									
										24
									
								
								frontend/src/components/ui/textarea.tsx
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										24
									
								
								frontend/src/components/ui/textarea.tsx
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,24 @@ | |||
| import * as React from "react" | ||||
| 
 | ||||
| import { cn } from "@/lib/utils" | ||||
| 
 | ||||
| export interface TextareaProps | ||||
|   extends React.TextareaHTMLAttributes<HTMLTextAreaElement> {} | ||||
| 
 | ||||
| const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>( | ||||
|   ({ className, ...props }, ref) => { | ||||
|     return ( | ||||
|       <textarea | ||||
|         className={cn( | ||||
|           "flex min-h-[80px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50", | ||||
|           className | ||||
|         )} | ||||
|         ref={ref} | ||||
|         {...props} | ||||
|       /> | ||||
|     ) | ||||
|   } | ||||
| ) | ||||
| Textarea.displayName = "Textarea" | ||||
| 
 | ||||
| export { Textarea } | ||||
							
								
								
									
										127
									
								
								frontend/src/components/ui/toast.tsx
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										127
									
								
								frontend/src/components/ui/toast.tsx
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,127 @@ | |||
| import * as React from "react" | ||||
| import * as ToastPrimitives from "@radix-ui/react-toast" | ||||
| import { cva, type VariantProps } from "class-variance-authority" | ||||
| import { X } from "lucide-react" | ||||
| 
 | ||||
| import { cn } from "@/lib/utils" | ||||
| 
 | ||||
| const ToastProvider = ToastPrimitives.Provider | ||||
| 
 | ||||
| const ToastViewport = React.forwardRef< | ||||
|   React.ElementRef<typeof ToastPrimitives.Viewport>, | ||||
|   React.ComponentPropsWithoutRef<typeof ToastPrimitives.Viewport> | ||||
| >(({ className, ...props }, ref) => ( | ||||
|   <ToastPrimitives.Viewport | ||||
|     ref={ref} | ||||
|     className={cn( | ||||
|       "fixed top-0 z-[100] flex max-h-screen w-full flex-col-reverse p-4 sm:bottom-0 sm:right-0 sm:top-auto sm:flex-col md:max-w-[420px]", | ||||
|       className | ||||
|     )} | ||||
|     {...props} | ||||
|   /> | ||||
| )) | ||||
| ToastViewport.displayName = ToastPrimitives.Viewport.displayName | ||||
| 
 | ||||
| const toastVariants = cva( | ||||
|   "group pointer-events-auto relative flex w-full items-center justify-between space-x-4 overflow-hidden rounded-md border p-6 pr-8 shadow-lg transition-all data-[swipe=cancel]:translate-x-0 data-[swipe=end]:translate-x-[var(--radix-toast-swipe-end-x)] data-[swipe=move]:translate-x-[var(--radix-toast-swipe-move-x)] data-[swipe=move]:transition-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[swipe=end]:animate-out data-[state=closed]:fade-out-80 data-[state=closed]:slide-out-to-right-full data-[state=open]:slide-in-from-top-full data-[state=open]:sm:slide-in-from-bottom-full", | ||||
|   { | ||||
|     variants: { | ||||
|       variant: { | ||||
|         default: "border bg-background text-foreground", | ||||
|         destructive: | ||||
|           "destructive group border-destructive bg-destructive text-destructive-foreground", | ||||
|       }, | ||||
|     }, | ||||
|     defaultVariants: { | ||||
|       variant: "default", | ||||
|     }, | ||||
|   } | ||||
| ) | ||||
| 
 | ||||
| const Toast = React.forwardRef< | ||||
|   React.ElementRef<typeof ToastPrimitives.Root>, | ||||
|   React.ComponentPropsWithoutRef<typeof ToastPrimitives.Root> & | ||||
|     VariantProps<typeof toastVariants> | ||||
| >(({ className, variant, ...props }, ref) => { | ||||
|   return ( | ||||
|     <ToastPrimitives.Root | ||||
|       ref={ref} | ||||
|       className={cn(toastVariants({ variant }), className)} | ||||
|       {...props} | ||||
|     /> | ||||
|   ) | ||||
| }) | ||||
| Toast.displayName = ToastPrimitives.Root.displayName | ||||
| 
 | ||||
| const ToastAction = React.forwardRef< | ||||
|   React.ElementRef<typeof ToastPrimitives.Action>, | ||||
|   React.ComponentPropsWithoutRef<typeof ToastPrimitives.Action> | ||||
| >(({ className, ...props }, ref) => ( | ||||
|   <ToastPrimitives.Action | ||||
|     ref={ref} | ||||
|     className={cn( | ||||
|       "inline-flex h-8 shrink-0 items-center justify-center rounded-md border bg-transparent px-3 text-sm font-medium ring-offset-background transition-colors hover:bg-secondary focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 group-[.destructive]:border-muted/40 group-[.destructive]:hover:border-destructive/30 group-[.destructive]:hover:bg-destructive group-[.destructive]:hover:text-destructive-foreground group-[.destructive]:focus:ring-destructive", | ||||
|       className | ||||
|     )} | ||||
|     {...props} | ||||
|   /> | ||||
| )) | ||||
| ToastAction.displayName = ToastPrimitives.Action.displayName | ||||
| 
 | ||||
| const ToastClose = React.forwardRef< | ||||
|   React.ElementRef<typeof ToastPrimitives.Close>, | ||||
|   React.ComponentPropsWithoutRef<typeof ToastPrimitives.Close> | ||||
| >(({ className, ...props }, ref) => ( | ||||
|   <ToastPrimitives.Close | ||||
|     ref={ref} | ||||
|     className={cn( | ||||
|       "absolute right-2 top-2 rounded-md p-1 text-foreground/50 opacity-0 transition-opacity hover:text-foreground focus:opacity-100 focus:outline-none focus:ring-2 group-hover:opacity-100 group-[.destructive]:text-red-300 group-[.destructive]:hover:text-red-50 group-[.destructive]:focus:ring-red-400 group-[.destructive]:focus:ring-offset-red-600", | ||||
|       className | ||||
|     )} | ||||
|     toast-close="" | ||||
|     {...props} | ||||
|   > | ||||
|     <X className="h-4 w-4" /> | ||||
|   </ToastPrimitives.Close> | ||||
| )) | ||||
| ToastClose.displayName = ToastPrimitives.Close.displayName | ||||
| 
 | ||||
| const ToastTitle = React.forwardRef< | ||||
|   React.ElementRef<typeof ToastPrimitives.Title>, | ||||
|   React.ComponentPropsWithoutRef<typeof ToastPrimitives.Title> | ||||
| >(({ className, ...props }, ref) => ( | ||||
|   <ToastPrimitives.Title | ||||
|     ref={ref} | ||||
|     className={cn("text-sm font-semibold", className)} | ||||
|     {...props} | ||||
|   /> | ||||
| )) | ||||
| ToastTitle.displayName = ToastPrimitives.Title.displayName | ||||
| 
 | ||||
| const ToastDescription = React.forwardRef< | ||||
|   React.ElementRef<typeof ToastPrimitives.Description>, | ||||
|   React.ComponentPropsWithoutRef<typeof ToastPrimitives.Description> | ||||
| >(({ className, ...props }, ref) => ( | ||||
|   <ToastPrimitives.Description | ||||
|     ref={ref} | ||||
|     className={cn("text-sm opacity-90", className)} | ||||
|     {...props} | ||||
|   /> | ||||
| )) | ||||
| ToastDescription.displayName = ToastPrimitives.Description.displayName | ||||
| 
 | ||||
| type ToastProps = React.ComponentPropsWithoutRef<typeof Toast> | ||||
| 
 | ||||
| type ToastActionElement = React.ReactElement<typeof ToastAction> | ||||
| 
 | ||||
| export { | ||||
|   type ToastProps, | ||||
|   type ToastActionElement, | ||||
|   ToastProvider, | ||||
|   ToastViewport, | ||||
|   Toast, | ||||
|   ToastTitle, | ||||
|   ToastDescription, | ||||
|   ToastClose, | ||||
|   ToastAction, | ||||
| } | ||||
							
								
								
									
										33
									
								
								frontend/src/components/ui/toaster.tsx
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										33
									
								
								frontend/src/components/ui/toaster.tsx
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,33 @@ | |||
| import { useToast } from "@/hooks/use-toast" | ||||
| import { | ||||
|   Toast, | ||||
|   ToastClose, | ||||
|   ToastDescription, | ||||
|   ToastProvider, | ||||
|   ToastTitle, | ||||
|   ToastViewport, | ||||
| } from "@/components/ui/toast" | ||||
| 
 | ||||
| export function Toaster() { | ||||
|   const { toasts } = useToast() | ||||
| 
 | ||||
|   return ( | ||||
|     <ToastProvider> | ||||
|       {toasts.map(function ({ id, title, description, action, ...props }) { | ||||
|         return ( | ||||
|           <Toast key={id} {...props}> | ||||
|             <div className="grid gap-1"> | ||||
|               {title && <ToastTitle>{title}</ToastTitle>} | ||||
|               {description && ( | ||||
|                 <ToastDescription>{description}</ToastDescription> | ||||
|               )} | ||||
|             </div> | ||||
|             {action} | ||||
|             <ToastClose /> | ||||
|           </Toast> | ||||
|         ) | ||||
|       })} | ||||
|       <ToastViewport /> | ||||
|     </ToastProvider> | ||||
|   ) | ||||
| } | ||||
							
								
								
									
										59
									
								
								frontend/src/components/ui/toggle-group.tsx
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										59
									
								
								frontend/src/components/ui/toggle-group.tsx
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,59 @@ | |||
| import * as React from "react" | ||||
| import * as ToggleGroupPrimitive from "@radix-ui/react-toggle-group" | ||||
| import { type VariantProps } from "class-variance-authority" | ||||
| 
 | ||||
| import { cn } from "@/lib/utils" | ||||
| import { toggleVariants } from "@/components/ui/toggle" | ||||
| 
 | ||||
| const ToggleGroupContext = React.createContext< | ||||
|   VariantProps<typeof toggleVariants> | ||||
| >({ | ||||
|   size: "default", | ||||
|   variant: "default", | ||||
| }) | ||||
| 
 | ||||
| const ToggleGroup = React.forwardRef< | ||||
|   React.ElementRef<typeof ToggleGroupPrimitive.Root>, | ||||
|   React.ComponentPropsWithoutRef<typeof ToggleGroupPrimitive.Root> & | ||||
|     VariantProps<typeof toggleVariants> | ||||
| >(({ className, variant, size, children, ...props }, ref) => ( | ||||
|   <ToggleGroupPrimitive.Root | ||||
|     ref={ref} | ||||
|     className={cn("flex items-center justify-center gap-1", className)} | ||||
|     {...props} | ||||
|   > | ||||
|     <ToggleGroupContext.Provider value={{ variant, size }}> | ||||
|       {children} | ||||
|     </ToggleGroupContext.Provider> | ||||
|   </ToggleGroupPrimitive.Root> | ||||
| )) | ||||
| 
 | ||||
| ToggleGroup.displayName = ToggleGroupPrimitive.Root.displayName | ||||
| 
 | ||||
| const ToggleGroupItem = React.forwardRef< | ||||
|   React.ElementRef<typeof ToggleGroupPrimitive.Item>, | ||||
|   React.ComponentPropsWithoutRef<typeof ToggleGroupPrimitive.Item> & | ||||
|     VariantProps<typeof toggleVariants> | ||||
| >(({ className, children, variant, size, ...props }, ref) => { | ||||
|   const context = React.useContext(ToggleGroupContext) | ||||
| 
 | ||||
|   return ( | ||||
|     <ToggleGroupPrimitive.Item | ||||
|       ref={ref} | ||||
|       className={cn( | ||||
|         toggleVariants({ | ||||
|           variant: context.variant || variant, | ||||
|           size: context.size || size, | ||||
|         }), | ||||
|         className | ||||
|       )} | ||||
|       {...props} | ||||
|     > | ||||
|       {children} | ||||
|     </ToggleGroupPrimitive.Item> | ||||
|   ) | ||||
| }) | ||||
| 
 | ||||
| ToggleGroupItem.displayName = ToggleGroupPrimitive.Item.displayName | ||||
| 
 | ||||
| export { ToggleGroup, ToggleGroupItem } | ||||
							
								
								
									
										43
									
								
								frontend/src/components/ui/toggle.tsx
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										43
									
								
								frontend/src/components/ui/toggle.tsx
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,43 @@ | |||
| import * as React from "react" | ||||
| import * as TogglePrimitive from "@radix-ui/react-toggle" | ||||
| import { cva, type VariantProps } from "class-variance-authority" | ||||
| 
 | ||||
| import { cn } from "@/lib/utils" | ||||
| 
 | ||||
| const toggleVariants = cva( | ||||
|   "inline-flex items-center justify-center rounded-md text-sm font-medium ring-offset-background transition-colors hover:bg-muted hover:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=on]:bg-accent data-[state=on]:text-accent-foreground", | ||||
|   { | ||||
|     variants: { | ||||
|       variant: { | ||||
|         default: "bg-transparent", | ||||
|         outline: | ||||
|           "border border-input bg-transparent hover:bg-accent hover:text-accent-foreground", | ||||
|       }, | ||||
|       size: { | ||||
|         default: "h-10 px-3", | ||||
|         sm: "h-9 px-2.5", | ||||
|         lg: "h-11 px-5", | ||||
|       }, | ||||
|     }, | ||||
|     defaultVariants: { | ||||
|       variant: "default", | ||||
|       size: "default", | ||||
|     }, | ||||
|   } | ||||
| ) | ||||
| 
 | ||||
| const Toggle = React.forwardRef< | ||||
|   React.ElementRef<typeof TogglePrimitive.Root>, | ||||
|   React.ComponentPropsWithoutRef<typeof TogglePrimitive.Root> & | ||||
|     VariantProps<typeof toggleVariants> | ||||
| >(({ className, variant, size, ...props }, ref) => ( | ||||
|   <TogglePrimitive.Root | ||||
|     ref={ref} | ||||
|     className={cn(toggleVariants({ variant, size, className }))} | ||||
|     {...props} | ||||
|   /> | ||||
| )) | ||||
| 
 | ||||
| Toggle.displayName = TogglePrimitive.Root.displayName | ||||
| 
 | ||||
| export { Toggle, toggleVariants } | ||||
							
								
								
									
										28
									
								
								frontend/src/components/ui/tooltip.tsx
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										28
									
								
								frontend/src/components/ui/tooltip.tsx
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,28 @@ | |||
| import * as React from "react" | ||||
| import * as TooltipPrimitive from "@radix-ui/react-tooltip" | ||||
| 
 | ||||
| import { cn } from "@/lib/utils" | ||||
| 
 | ||||
| const TooltipProvider = TooltipPrimitive.Provider | ||||
| 
 | ||||
| const Tooltip = TooltipPrimitive.Root | ||||
| 
 | ||||
| const TooltipTrigger = TooltipPrimitive.Trigger | ||||
| 
 | ||||
| const TooltipContent = React.forwardRef< | ||||
|   React.ElementRef<typeof TooltipPrimitive.Content>, | ||||
|   React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Content> | ||||
| >(({ className, sideOffset = 4, ...props }, ref) => ( | ||||
|   <TooltipPrimitive.Content | ||||
|     ref={ref} | ||||
|     sideOffset={sideOffset} | ||||
|     className={cn( | ||||
|       "z-50 overflow-hidden rounded-md border bg-popover px-3 py-1.5 text-sm text-popover-foreground shadow-md animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2", | ||||
|       className | ||||
|     )} | ||||
|     {...props} | ||||
|   /> | ||||
| )) | ||||
| TooltipContent.displayName = TooltipPrimitive.Content.displayName | ||||
| 
 | ||||
| export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider } | ||||
							
								
								
									
										3
									
								
								frontend/src/components/ui/use-toast.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										3
									
								
								frontend/src/components/ui/use-toast.ts
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,3 @@ | |||
| import { useToast, toast } from "@/hooks/use-toast"; | ||||
| 
 | ||||
| export { useToast, toast }; | ||||
							
								
								
									
										19
									
								
								frontend/src/hooks/use-mobile.tsx
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										19
									
								
								frontend/src/hooks/use-mobile.tsx
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,19 @@ | |||
| import * as React from "react" | ||||
| 
 | ||||
| const MOBILE_BREAKPOINT = 768 | ||||
| 
 | ||||
| export function useIsMobile() { | ||||
|   const [isMobile, setIsMobile] = React.useState<boolean | undefined>(undefined) | ||||
| 
 | ||||
|   React.useEffect(() => { | ||||
|     const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`) | ||||
|     const onChange = () => { | ||||
|       setIsMobile(window.innerWidth < MOBILE_BREAKPOINT) | ||||
|     } | ||||
|     mql.addEventListener("change", onChange) | ||||
|     setIsMobile(window.innerWidth < MOBILE_BREAKPOINT) | ||||
|     return () => mql.removeEventListener("change", onChange) | ||||
|   }, []) | ||||
| 
 | ||||
|   return !!isMobile | ||||
| } | ||||
							
								
								
									
										191
									
								
								frontend/src/hooks/use-toast.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										191
									
								
								frontend/src/hooks/use-toast.ts
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,191 @@ | |||
| import * as React from "react" | ||||
| 
 | ||||
| import type { | ||||
|   ToastActionElement, | ||||
|   ToastProps, | ||||
| } from "@/components/ui/toast" | ||||
| 
 | ||||
| const TOAST_LIMIT = 1 | ||||
| const TOAST_REMOVE_DELAY = 1000000 | ||||
| 
 | ||||
| type ToasterToast = ToastProps & { | ||||
|   id: string | ||||
|   title?: React.ReactNode | ||||
|   description?: React.ReactNode | ||||
|   action?: ToastActionElement | ||||
| } | ||||
| 
 | ||||
| const actionTypes = { | ||||
|   ADD_TOAST: "ADD_TOAST", | ||||
|   UPDATE_TOAST: "UPDATE_TOAST", | ||||
|   DISMISS_TOAST: "DISMISS_TOAST", | ||||
|   REMOVE_TOAST: "REMOVE_TOAST", | ||||
| } as const | ||||
| 
 | ||||
| let count = 0 | ||||
| 
 | ||||
| function genId() { | ||||
|   count = (count + 1) % Number.MAX_SAFE_INTEGER | ||||
|   return count.toString() | ||||
| } | ||||
| 
 | ||||
| type ActionType = typeof actionTypes | ||||
| 
 | ||||
| type Action = | ||||
|   | { | ||||
|       type: ActionType["ADD_TOAST"] | ||||
|       toast: ToasterToast | ||||
|     } | ||||
|   | { | ||||
|       type: ActionType["UPDATE_TOAST"] | ||||
|       toast: Partial<ToasterToast> | ||||
|     } | ||||
|   | { | ||||
|       type: ActionType["DISMISS_TOAST"] | ||||
|       toastId?: ToasterToast["id"] | ||||
|     } | ||||
|   | { | ||||
|       type: ActionType["REMOVE_TOAST"] | ||||
|       toastId?: ToasterToast["id"] | ||||
|     } | ||||
| 
 | ||||
| interface State { | ||||
|   toasts: ToasterToast[] | ||||
| } | ||||
| 
 | ||||
| const toastTimeouts = new Map<string, ReturnType<typeof setTimeout>>() | ||||
| 
 | ||||
| const addToRemoveQueue = (toastId: string) => { | ||||
|   if (toastTimeouts.has(toastId)) { | ||||
|     return | ||||
|   } | ||||
| 
 | ||||
|   const timeout = setTimeout(() => { | ||||
|     toastTimeouts.delete(toastId) | ||||
|     dispatch({ | ||||
|       type: "REMOVE_TOAST", | ||||
|       toastId: toastId, | ||||
|     }) | ||||
|   }, TOAST_REMOVE_DELAY) | ||||
| 
 | ||||
|   toastTimeouts.set(toastId, timeout) | ||||
| } | ||||
| 
 | ||||
| export const reducer = (state: State, action: Action): State => { | ||||
|   switch (action.type) { | ||||
|     case "ADD_TOAST": | ||||
|       return { | ||||
|         ...state, | ||||
|         toasts: [action.toast, ...state.toasts].slice(0, TOAST_LIMIT), | ||||
|       } | ||||
| 
 | ||||
|     case "UPDATE_TOAST": | ||||
|       return { | ||||
|         ...state, | ||||
|         toasts: state.toasts.map((t) => | ||||
|           t.id === action.toast.id ? { ...t, ...action.toast } : t | ||||
|         ), | ||||
|       } | ||||
| 
 | ||||
|     case "DISMISS_TOAST": { | ||||
|       const { toastId } = action | ||||
| 
 | ||||
|       // ! Side effects ! - This could be extracted into a dismissToast() action,
 | ||||
|       // but I'll keep it here for simplicity
 | ||||
|       if (toastId) { | ||||
|         addToRemoveQueue(toastId) | ||||
|       } else { | ||||
|         state.toasts.forEach((toast) => { | ||||
|           addToRemoveQueue(toast.id) | ||||
|         }) | ||||
|       } | ||||
| 
 | ||||
|       return { | ||||
|         ...state, | ||||
|         toasts: state.toasts.map((t) => | ||||
|           t.id === toastId || toastId === undefined | ||||
|             ? { | ||||
|                 ...t, | ||||
|                 open: false, | ||||
|               } | ||||
|             : t | ||||
|         ), | ||||
|       } | ||||
|     } | ||||
|     case "REMOVE_TOAST": | ||||
|       if (action.toastId === undefined) { | ||||
|         return { | ||||
|           ...state, | ||||
|           toasts: [], | ||||
|         } | ||||
|       } | ||||
|       return { | ||||
|         ...state, | ||||
|         toasts: state.toasts.filter((t) => t.id !== action.toastId), | ||||
|       } | ||||
|   } | ||||
| } | ||||
| 
 | ||||
| const listeners: Array<(state: State) => void> = [] | ||||
| 
 | ||||
| let memoryState: State = { toasts: [] } | ||||
| 
 | ||||
| function dispatch(action: Action) { | ||||
|   memoryState = reducer(memoryState, action) | ||||
|   listeners.forEach((listener) => { | ||||
|     listener(memoryState) | ||||
|   }) | ||||
| } | ||||
| 
 | ||||
| type Toast = Omit<ToasterToast, "id"> | ||||
| 
 | ||||
| function toast({ ...props }: Toast) { | ||||
|   const id = genId() | ||||
| 
 | ||||
|   const update = (props: ToasterToast) => | ||||
|     dispatch({ | ||||
|       type: "UPDATE_TOAST", | ||||
|       toast: { ...props, id }, | ||||
|     }) | ||||
|   const dismiss = () => dispatch({ type: "DISMISS_TOAST", toastId: id }) | ||||
| 
 | ||||
|   dispatch({ | ||||
|     type: "ADD_TOAST", | ||||
|     toast: { | ||||
|       ...props, | ||||
|       id, | ||||
|       open: true, | ||||
|       onOpenChange: (open) => { | ||||
|         if (!open) dismiss() | ||||
|       }, | ||||
|     }, | ||||
|   }) | ||||
| 
 | ||||
|   return { | ||||
|     id: id, | ||||
|     dismiss, | ||||
|     update, | ||||
|   } | ||||
| } | ||||
| 
 | ||||
| function useToast() { | ||||
|   const [state, setState] = React.useState<State>(memoryState) | ||||
| 
 | ||||
|   React.useEffect(() => { | ||||
|     listeners.push(setState) | ||||
|     return () => { | ||||
|       const index = listeners.indexOf(setState) | ||||
|       if (index > -1) { | ||||
|         listeners.splice(index, 1) | ||||
|       } | ||||
|     } | ||||
|   }, [state]) | ||||
| 
 | ||||
|   return { | ||||
|     ...state, | ||||
|     toast, | ||||
|     dismiss: (toastId?: string) => dispatch({ type: "DISMISS_TOAST", toastId }), | ||||
|   } | ||||
| } | ||||
| 
 | ||||
| export { useToast, toast } | ||||
							
								
								
									
										94
									
								
								frontend/src/hooks/useAuth.tsx
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										94
									
								
								frontend/src/hooks/useAuth.tsx
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,94 @@ | |||
| /*  Autenticazione (JWT)  */ | ||||
| 
 | ||||
| import { | ||||
|   createContext, | ||||
|   useContext, | ||||
|   useState, | ||||
|   useEffect, | ||||
|   ReactNode, | ||||
| } from 'react'; | ||||
| 
 | ||||
| interface User { | ||||
|   id: string; | ||||
|   email: string; | ||||
|   name: string; | ||||
| } | ||||
| 
 | ||||
| interface AuthContextType { | ||||
|   user: User | null; | ||||
|   loading: boolean; | ||||
|   login: (email: string, password: string) => Promise<void>; | ||||
|   register: (email: string, password: string, name: string) => Promise<void>; | ||||
|   logout: () => void; | ||||
| } | ||||
| 
 | ||||
| const AuthContext = createContext<AuthContextType | undefined>(undefined); | ||||
| 
 | ||||
| const API_URL = import.meta.env.VITE_API_URL ?? 'http://localhost:6789/api'; | ||||
| 
 | ||||
| export const AuthProvider = ({ children }: { children: ReactNode }) => { | ||||
|   const [user, setUser]   = useState<User | null>(null); | ||||
|   const [loading, setLoading] = useState(true); | ||||
| 
 | ||||
|   /* al mount, prova a caricare user + token */ | ||||
|   useEffect(() => { | ||||
|     const savedUser  = localStorage.getItem('hackerspace_user'); | ||||
|     const savedToken = localStorage.getItem('hackerspace_token'); | ||||
|     if (savedUser && savedToken) setUser(JSON.parse(savedUser)); | ||||
|     setLoading(false); | ||||
|   }, []); | ||||
| 
 | ||||
|   const login = async (email: string, password: string) => { | ||||
|     const res = await fetch(`${API_URL}/auth/login`, { | ||||
|       method: 'POST', | ||||
|       headers: { 'Content-Type': 'application/json' }, | ||||
|       body: JSON.stringify({ email, password }), | ||||
|     }); | ||||
| 
 | ||||
|     if (!res.ok) { | ||||
|       const msg = (await res.json())?.message ?? 'Login fallito'; | ||||
|       throw new Error(msg); | ||||
|     } | ||||
| 
 | ||||
|     const { token, user } = await res.json(); | ||||
|     localStorage.setItem('hackerspace_token', token); | ||||
|     localStorage.setItem('hackerspace_user', JSON.stringify(user)); | ||||
|     setUser(user); | ||||
|   }; | ||||
| 
 | ||||
|   const register = async (email: string, password: string, name: string) => { | ||||
|     const res = await fetch(`${API_URL}/auth/register`, { | ||||
|       method: 'POST', | ||||
|       headers: { 'Content-Type': 'application/json' }, | ||||
|       body: JSON.stringify({ email, password, name }), | ||||
|     }); | ||||
| 
 | ||||
|     if (!res.ok) { | ||||
|       const msg = (await res.json())?.message ?? 'Registrazione fallita'; | ||||
|       throw new Error(msg); | ||||
|     } | ||||
| 
 | ||||
|     const { token, user } = await res.json(); | ||||
|     localStorage.setItem('hackerspace_token', token); | ||||
|     localStorage.setItem('hackerspace_user', JSON.stringify(user)); | ||||
|     setUser(user); | ||||
|   }; | ||||
| 
 | ||||
|   const logout = () => { | ||||
|     localStorage.removeItem('hackerspace_token'); | ||||
|     localStorage.removeItem('hackerspace_user'); | ||||
|     setUser(null); | ||||
|   }; | ||||
| 
 | ||||
|   return ( | ||||
|     <AuthContext.Provider value={{ user, loading, login, register, logout }}> | ||||
|       {children} | ||||
|     </AuthContext.Provider> | ||||
|   ); | ||||
| }; | ||||
| 
 | ||||
| export const useAuth = () => { | ||||
|   const ctx = useContext(AuthContext); | ||||
|   if (!ctx) throw new Error('useAuth must be used within AuthProvider'); | ||||
|   return ctx; | ||||
| }; | ||||
							
								
								
									
										105
									
								
								frontend/src/index.css
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										105
									
								
								frontend/src/index.css
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,105 @@ | |||
| @tailwind base; | ||||
| @tailwind components; | ||||
| @tailwind utilities; | ||||
| 
 | ||||
| /* Definition of the design system. All colors, gradients, fonts, etc should be defined here.  | ||||
| All colors MUST be HSL. | ||||
| */ | ||||
| 
 | ||||
| @layer base { | ||||
|   :root { | ||||
|     --background: 0 0% 100%; | ||||
|     --foreground: 222.2 84% 4.9%; | ||||
| 
 | ||||
|     --card: 0 0% 100%; | ||||
|     --card-foreground: 222.2 84% 4.9%; | ||||
| 
 | ||||
|     --popover: 0 0% 100%; | ||||
|     --popover-foreground: 222.2 84% 4.9%; | ||||
| 
 | ||||
|     --primary: 222.2 47.4% 11.2%; | ||||
|     --primary-foreground: 210 40% 98%; | ||||
| 
 | ||||
|     --secondary: 210 40% 96.1%; | ||||
|     --secondary-foreground: 222.2 47.4% 11.2%; | ||||
| 
 | ||||
|     --muted: 210 40% 96.1%; | ||||
|     --muted-foreground: 215.4 16.3% 46.9%; | ||||
| 
 | ||||
|     --accent: 210 40% 96.1%; | ||||
|     --accent-foreground: 222.2 47.4% 11.2%; | ||||
| 
 | ||||
|     --destructive: 0 84.2% 60.2%; | ||||
|     --destructive-foreground: 210 40% 98%; | ||||
| 
 | ||||
|     --border: 214.3 31.8% 91.4%; | ||||
|     --input: 214.3 31.8% 91.4%; | ||||
|     --ring: 222.2 84% 4.9%; | ||||
| 
 | ||||
|     --radius: 0.5rem; | ||||
| 
 | ||||
|     --sidebar-background: 0 0% 98%; | ||||
| 
 | ||||
|     --sidebar-foreground: 240 5.3% 26.1%; | ||||
| 
 | ||||
|     --sidebar-primary: 240 5.9% 10%; | ||||
| 
 | ||||
|     --sidebar-primary-foreground: 0 0% 98%; | ||||
| 
 | ||||
|     --sidebar-accent: 240 4.8% 95.9%; | ||||
| 
 | ||||
|     --sidebar-accent-foreground: 240 5.9% 10%; | ||||
| 
 | ||||
|     --sidebar-border: 220 13% 91%; | ||||
| 
 | ||||
|     --sidebar-ring: 217.2 91.2% 59.8%; | ||||
|   } | ||||
| 
 | ||||
|   .dark { | ||||
|     --background: 222.2 84% 4.9%; | ||||
|     --foreground: 210 40% 98%; | ||||
| 
 | ||||
|     --card: 222.2 84% 4.9%; | ||||
|     --card-foreground: 210 40% 98%; | ||||
| 
 | ||||
|     --popover: 222.2 84% 4.9%; | ||||
|     --popover-foreground: 210 40% 98%; | ||||
| 
 | ||||
|     --primary: 210 40% 98%; | ||||
|     --primary-foreground: 222.2 47.4% 11.2%; | ||||
| 
 | ||||
|     --secondary: 217.2 32.6% 17.5%; | ||||
|     --secondary-foreground: 210 40% 98%; | ||||
| 
 | ||||
|     --muted: 217.2 32.6% 17.5%; | ||||
|     --muted-foreground: 215 20.2% 65.1%; | ||||
| 
 | ||||
|     --accent: 217.2 32.6% 17.5%; | ||||
|     --accent-foreground: 210 40% 98%; | ||||
| 
 | ||||
|     --destructive: 0 62.8% 30.6%; | ||||
|     --destructive-foreground: 210 40% 98%; | ||||
| 
 | ||||
|     --border: 217.2 32.6% 17.5%; | ||||
|     --input: 217.2 32.6% 17.5%; | ||||
|     --ring: 212.7 26.8% 83.9%; | ||||
|     --sidebar-background: 240 5.9% 10%; | ||||
|     --sidebar-foreground: 240 4.8% 95.9%; | ||||
|     --sidebar-primary: 224.3 76.3% 48%; | ||||
|     --sidebar-primary-foreground: 0 0% 100%; | ||||
|     --sidebar-accent: 240 3.7% 15.9%; | ||||
|     --sidebar-accent-foreground: 240 4.8% 95.9%; | ||||
|     --sidebar-border: 240 3.7% 15.9%; | ||||
|     --sidebar-ring: 217.2 91.2% 59.8%; | ||||
|   } | ||||
| } | ||||
| 
 | ||||
| @layer base { | ||||
|   * { | ||||
|     @apply border-border; | ||||
|   } | ||||
| 
 | ||||
|   body { | ||||
|     @apply bg-background text-foreground; | ||||
|   } | ||||
| } | ||||
							
								
								
									
										6
									
								
								frontend/src/lib/utils.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										6
									
								
								frontend/src/lib/utils.ts
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,6 @@ | |||
| import { clsx, type ClassValue } from "clsx" | ||||
| import { twMerge } from "tailwind-merge" | ||||
| 
 | ||||
| export function cn(...inputs: ClassValue[]) { | ||||
|   return twMerge(clsx(inputs)) | ||||
| } | ||||
							
								
								
									
										5
									
								
								frontend/src/main.tsx
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										5
									
								
								frontend/src/main.tsx
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,5 @@ | |||
| import { createRoot } from 'react-dom/client' | ||||
| import App from './App.tsx' | ||||
| import './index.css' | ||||
| 
 | ||||
| createRoot(document.getElementById("root")!).render(<App />); | ||||
							
								
								
									
										27
									
								
								frontend/src/pages/Index.tsx
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										27
									
								
								frontend/src/pages/Index.tsx
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,27 @@ | |||
| 
 | ||||
| import { useState, useEffect } from 'react'; | ||||
| import { LoginForm } from '@/components/LoginForm'; | ||||
| import { InventoryDashboard } from '@/components/InventoryDashboard'; | ||||
| import { useAuth } from '@/hooks/useAuth'; | ||||
| 
 | ||||
| const Index = () => { | ||||
|   const { user, loading } = useAuth(); | ||||
| 
 | ||||
|   if (loading) { | ||||
|     return ( | ||||
|       <div className="min-h-screen flex items-center justify-center bg-slate-50"> | ||||
|         <div className="animate-pulse"> | ||||
|           <div className="w-8 h-8 bg-blue-600 rounded-full"></div> | ||||
|         </div> | ||||
|       </div> | ||||
|     ); | ||||
|   } | ||||
| 
 | ||||
|   if (!user) { | ||||
|     return <LoginForm />; | ||||
|   } | ||||
| 
 | ||||
|   return <InventoryDashboard />; | ||||
| }; | ||||
| 
 | ||||
| export default Index; | ||||
							
								
								
									
										27
									
								
								frontend/src/pages/NotFound.tsx
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										27
									
								
								frontend/src/pages/NotFound.tsx
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,27 @@ | |||
| import { useLocation } from "react-router-dom"; | ||||
| import { useEffect } from "react"; | ||||
| 
 | ||||
| const NotFound = () => { | ||||
|   const location = useLocation(); | ||||
| 
 | ||||
|   useEffect(() => { | ||||
|     console.error( | ||||
|       "404 Error: User attempted to access non-existent route:", | ||||
|       location.pathname | ||||
|     ); | ||||
|   }, [location.pathname]); | ||||
| 
 | ||||
|   return ( | ||||
|     <div className="min-h-screen flex items-center justify-center bg-gray-100"> | ||||
|       <div className="text-center"> | ||||
|         <h1 className="text-4xl font-bold mb-4">404</h1> | ||||
|         <p className="text-xl text-gray-600 mb-4">Oops! Page not found</p> | ||||
|         <a href="/" className="text-blue-500 hover:text-blue-700 underline"> | ||||
|           Return to Home | ||||
|         </a> | ||||
|       </div> | ||||
|     </div> | ||||
|   ); | ||||
| }; | ||||
| 
 | ||||
| export default NotFound; | ||||
							
								
								
									
										137
									
								
								frontend/src/services/inventoryService.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										137
									
								
								frontend/src/services/inventoryService.ts
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,137 @@ | |||
| 
 | ||||
| /*  Inventory API service  */ | ||||
| 
 | ||||
| import { | ||||
|   InventoryItem, | ||||
|   Tag, | ||||
|   CreateItemData, | ||||
|   UpdateItemData, | ||||
| } from '@/types/inventory'; | ||||
| 
 | ||||
| const API_URL = import.meta.env.VITE_API_URL ?? 'http://localhost:6789/api'; | ||||
| 
 | ||||
| // ⬇️ helper per recuperare il token JWT dal localStorage
 | ||||
| const getAuthHeader = () => { | ||||
|   const token = localStorage.getItem('hackerspace_token'); | ||||
|   return token ? { Authorization: `Bearer ${token}` } : {}; | ||||
| }; | ||||
| 
 | ||||
| export const inventoryService = { | ||||
|   async getItems(): Promise<InventoryItem[]> { | ||||
|     const res = await fetch(`${API_URL}/items`); | ||||
|     if (!res.ok) throw new Error('Impossibile scaricare gli item'); | ||||
|     return res.json(); | ||||
|   }, | ||||
| 
 | ||||
|   async getTags(): Promise<Tag[]> { | ||||
|     const res = await fetch(`${API_URL}/tags`); | ||||
|     if (!res.ok) throw new Error('Impossibile scaricare i tag'); | ||||
|     return res.json(); | ||||
|   }, | ||||
| 
 | ||||
|   async createItem(data: CreateItemData): Promise<InventoryItem> { | ||||
|     const res = await fetch(`${API_URL}/items`, { | ||||
|       method: 'POST', | ||||
|       headers: { | ||||
|         'Content-Type': 'application/json', | ||||
|         ...getAuthHeader(), | ||||
|       }, | ||||
|       body: JSON.stringify(data), | ||||
|     }); | ||||
| 
 | ||||
|     if (!res.ok) { | ||||
|       const msg = (await res.json())?.message ?? 'Errore creazione item'; | ||||
|       throw new Error(msg); | ||||
|     } | ||||
|     return res.json(); | ||||
|   }, | ||||
| 
 | ||||
|   async updateItem(itemId: string, data: UpdateItemData): Promise<InventoryItem> { | ||||
|     const res = await fetch(`${API_URL}/items/${itemId}`, { | ||||
|       method: 'PUT', | ||||
|       headers: { | ||||
|         'Content-Type': 'application/json', | ||||
|         ...getAuthHeader(), | ||||
|       }, | ||||
|       body: JSON.stringify(data), | ||||
|     }); | ||||
| 
 | ||||
|     if (!res.ok) { | ||||
|       const msg = (await res.json())?.message ?? 'Errore aggiornamento item'; | ||||
|       throw new Error(msg); | ||||
|     } | ||||
|     return res.json(); | ||||
|   }, | ||||
| 
 | ||||
|   async updateItemQuantity(itemId: string, quantity: number): Promise<InventoryItem> { | ||||
|     const res = await fetch(`${API_URL}/items/${itemId}/quantity`, { | ||||
|       method: 'PATCH', | ||||
|       headers: { | ||||
|         'Content-Type': 'application/json', | ||||
|         ...getAuthHeader(), | ||||
|       }, | ||||
|       body: JSON.stringify({ quantity }), | ||||
|     }); | ||||
| 
 | ||||
|     if (!res.ok) { | ||||
|       const msg = (await res.json())?.message ?? 'Errore aggiornamento quantità'; | ||||
|       throw new Error(msg); | ||||
|     } | ||||
|     return res.json(); | ||||
|   }, | ||||
| 
 | ||||
|   async deleteItem(itemId: string): Promise<void> { | ||||
|     const res = await fetch(`${API_URL}/items/${itemId}`, { | ||||
|       method: 'DELETE', | ||||
|       headers: { | ||||
|         ...getAuthHeader(), | ||||
|       }, | ||||
|     }); | ||||
| 
 | ||||
|     if (!res.ok) { | ||||
|       const msg = (await res.json())?.message ?? 'Errore eliminazione item'; | ||||
|       throw new Error(msg); | ||||
|     } | ||||
|   }, | ||||
| 
 | ||||
|   async createTag(name: string, color: string): Promise<Tag> { | ||||
|     const res = await fetch(`${API_URL}/tags`, { | ||||
|       method: 'POST', | ||||
|       headers: { | ||||
|         'Content-Type': 'application/json', | ||||
|         ...getAuthHeader(), | ||||
|       }, | ||||
|       body: JSON.stringify({ name, color }), | ||||
|     }); | ||||
| 
 | ||||
|     if (!res.ok) { | ||||
|       const msg = (await res.json())?.message ?? 'Errore creazione tag'; | ||||
|       throw new Error(msg); | ||||
|     } | ||||
|     return res.json(); | ||||
|   }, | ||||
| 
 | ||||
|   async deleteTag(tagId: string): Promise<void> { | ||||
|     const res = await fetch(`${API_URL}/tags/${tagId}`, { | ||||
|       method: 'DELETE', | ||||
|       headers: { | ||||
|         ...getAuthHeader(), | ||||
|       }, | ||||
|     }); | ||||
| 
 | ||||
|     if (!res.ok) { | ||||
|       const msg = (await res.json())?.message ?? 'Errore eliminazione tag'; | ||||
|       throw new Error(msg); | ||||
|     } | ||||
|   }, | ||||
| 
 | ||||
|   async searchItems(query = '', tagIds: string[] = []): Promise<InventoryItem[]> { | ||||
|     const qs = new URLSearchParams({ | ||||
|       query, | ||||
|       tagIds: tagIds.join(','), | ||||
|     }); | ||||
|     const res = await fetch(`${API_URL}/items/search?${qs.toString()}`); | ||||
|     if (!res.ok) throw new Error('Errore ricerca item'); | ||||
|     return res.json(); | ||||
|   }, | ||||
| }; | ||||
							
								
								
									
										30
									
								
								frontend/src/types/inventory.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										30
									
								
								frontend/src/types/inventory.ts
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,30 @@ | |||
| 
 | ||||
| export interface Tag { | ||||
|   _id: string; | ||||
|   name: string; | ||||
|   color: string; | ||||
| } | ||||
| 
 | ||||
| export interface InventoryItem { | ||||
|   _id: string; | ||||
|   name: string; | ||||
|   description: string; | ||||
|   quantity: number; | ||||
|   tags: Tag[]; | ||||
|   dateAdded: string; | ||||
|   addedBy: string; | ||||
| } | ||||
| 
 | ||||
| export interface CreateItemData { | ||||
|   name: string; | ||||
|   description: string; | ||||
|   quantity: number; | ||||
|   tagIds: string[]; | ||||
| } | ||||
| 
 | ||||
| export interface UpdateItemData { | ||||
|   name?: string; | ||||
|   description?: string; | ||||
|   quantity?: number; | ||||
|   tagIds?: string[]; | ||||
| } | ||||
							
								
								
									
										1
									
								
								frontend/src/vite-env.d.ts
									
										
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						
									
										1
									
								
								frontend/src/vite-env.d.ts
									
										
									
									
										vendored
									
									
										Normal file
									
								
							|  | @ -0,0 +1 @@ | |||
| /// <reference types="vite/client" />
 | ||||
							
								
								
									
										96
									
								
								frontend/tailwind.config.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										96
									
								
								frontend/tailwind.config.ts
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,96 @@ | |||
| import type { Config } from "tailwindcss"; | ||||
| 
 | ||||
| export default { | ||||
| 	darkMode: ["class"], | ||||
| 	content: [ | ||||
| 		"./pages/**/*.{ts,tsx}", | ||||
| 		"./components/**/*.{ts,tsx}", | ||||
| 		"./app/**/*.{ts,tsx}", | ||||
| 		"./src/**/*.{ts,tsx}", | ||||
| 	], | ||||
| 	prefix: "", | ||||
| 	theme: { | ||||
| 		container: { | ||||
| 			center: true, | ||||
| 			padding: '2rem', | ||||
| 			screens: { | ||||
| 				'2xl': '1400px' | ||||
| 			} | ||||
| 		}, | ||||
| 		extend: { | ||||
| 			colors: { | ||||
| 				border: 'hsl(var(--border))', | ||||
| 				input: 'hsl(var(--input))', | ||||
| 				ring: 'hsl(var(--ring))', | ||||
| 				background: 'hsl(var(--background))', | ||||
| 				foreground: 'hsl(var(--foreground))', | ||||
| 				primary: { | ||||
| 					DEFAULT: 'hsl(var(--primary))', | ||||
| 					foreground: 'hsl(var(--primary-foreground))' | ||||
| 				}, | ||||
| 				secondary: { | ||||
| 					DEFAULT: 'hsl(var(--secondary))', | ||||
| 					foreground: 'hsl(var(--secondary-foreground))' | ||||
| 				}, | ||||
| 				destructive: { | ||||
| 					DEFAULT: 'hsl(var(--destructive))', | ||||
| 					foreground: 'hsl(var(--destructive-foreground))' | ||||
| 				}, | ||||
| 				muted: { | ||||
| 					DEFAULT: 'hsl(var(--muted))', | ||||
| 					foreground: 'hsl(var(--muted-foreground))' | ||||
| 				}, | ||||
| 				accent: { | ||||
| 					DEFAULT: 'hsl(var(--accent))', | ||||
| 					foreground: 'hsl(var(--accent-foreground))' | ||||
| 				}, | ||||
| 				popover: { | ||||
| 					DEFAULT: 'hsl(var(--popover))', | ||||
| 					foreground: 'hsl(var(--popover-foreground))' | ||||
| 				}, | ||||
| 				card: { | ||||
| 					DEFAULT: 'hsl(var(--card))', | ||||
| 					foreground: 'hsl(var(--card-foreground))' | ||||
| 				}, | ||||
| 				sidebar: { | ||||
| 					DEFAULT: 'hsl(var(--sidebar-background))', | ||||
| 					foreground: 'hsl(var(--sidebar-foreground))', | ||||
| 					primary: 'hsl(var(--sidebar-primary))', | ||||
| 					'primary-foreground': 'hsl(var(--sidebar-primary-foreground))', | ||||
| 					accent: 'hsl(var(--sidebar-accent))', | ||||
| 					'accent-foreground': 'hsl(var(--sidebar-accent-foreground))', | ||||
| 					border: 'hsl(var(--sidebar-border))', | ||||
| 					ring: 'hsl(var(--sidebar-ring))' | ||||
| 				} | ||||
| 			}, | ||||
| 			borderRadius: { | ||||
| 				lg: 'var(--radius)', | ||||
| 				md: 'calc(var(--radius) - 2px)', | ||||
| 				sm: 'calc(var(--radius) - 4px)' | ||||
| 			}, | ||||
| 			keyframes: { | ||||
| 				'accordion-down': { | ||||
| 					from: { | ||||
| 						height: '0' | ||||
| 					}, | ||||
| 					to: { | ||||
| 						height: 'var(--radix-accordion-content-height)' | ||||
| 					} | ||||
| 				}, | ||||
| 				'accordion-up': { | ||||
| 					from: { | ||||
| 						height: 'var(--radix-accordion-content-height)' | ||||
| 					}, | ||||
| 					to: { | ||||
| 						height: '0' | ||||
| 					} | ||||
| 				} | ||||
| 			}, | ||||
| 			animation: { | ||||
| 				'accordion-down': 'accordion-down 0.2s ease-out', | ||||
| 				'accordion-up': 'accordion-up 0.2s ease-out' | ||||
| 			} | ||||
| 		} | ||||
| 	}, | ||||
| 	plugins: [require("tailwindcss-animate")], | ||||
| } satisfies Config; | ||||
							
								
								
									
										30
									
								
								frontend/tsconfig.app.json
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										30
									
								
								frontend/tsconfig.app.json
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,30 @@ | |||
| { | ||||
|   "compilerOptions": { | ||||
|     "target": "ES2020", | ||||
|     "useDefineForClassFields": true, | ||||
|     "lib": ["ES2020", "DOM", "DOM.Iterable"], | ||||
|     "module": "ESNext", | ||||
|     "skipLibCheck": true, | ||||
| 
 | ||||
|     /* Bundler mode */ | ||||
|     "moduleResolution": "bundler", | ||||
|     "allowImportingTsExtensions": true, | ||||
|     "isolatedModules": true, | ||||
|     "moduleDetection": "force", | ||||
|     "noEmit": true, | ||||
|     "jsx": "react-jsx", | ||||
| 
 | ||||
|     /* Linting */ | ||||
|     "strict": false, | ||||
|     "noUnusedLocals": false, | ||||
|     "noUnusedParameters": false, | ||||
|     "noImplicitAny": false, | ||||
|     "noFallthroughCasesInSwitch": false, | ||||
| 
 | ||||
|     "baseUrl": ".", | ||||
|     "paths": { | ||||
|       "@/*": ["./src/*"] | ||||
|     } | ||||
|   }, | ||||
|   "include": ["src"] | ||||
| } | ||||
							
								
								
									
										19
									
								
								frontend/tsconfig.json
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										19
									
								
								frontend/tsconfig.json
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,19 @@ | |||
| { | ||||
|   "files": [], | ||||
|   "references": [ | ||||
|     { "path": "./tsconfig.app.json" }, | ||||
|     { "path": "./tsconfig.node.json" } | ||||
|   ], | ||||
|   "compilerOptions": { | ||||
|     "baseUrl": ".", | ||||
|     "paths": { | ||||
|       "@/*": ["./src/*"] | ||||
|     }, | ||||
|     "noImplicitAny": false, | ||||
|     "noUnusedParameters": false, | ||||
|     "skipLibCheck": true, | ||||
|     "allowJs": true, | ||||
|     "noUnusedLocals": false, | ||||
|     "strictNullChecks": false | ||||
|   } | ||||
| } | ||||
Some files were not shown because too many files have changed in this diff Show more
		Loading…
	
	Add table
		Add a link
		
	
		Reference in a new issue