feat: implement sidebar components and authentication flow
- Added sidebar menu components including sidebar-menu-item, sidebar-menu-skeleton, sidebar-menu-sub-button, sidebar-menu-sub-item, sidebar-menu-sub, sidebar-menu, sidebar-provider, sidebar-rail, sidebar-separator, sidebar-trigger, and sidebar. - Introduced skeleton loading states for sidebar items. - Integrated tooltip components for enhanced user interaction. - Developed mobile responsiveness using media queries. - Established authentication flow with Keycloak, including login, logout, and token management. - Implemented server-side redirection based on authentication status. - Enhanced error handling and logging for authentication processes.
This commit is contained in:
@@ -31,6 +31,7 @@ CORE_DB_PASSWORD=postgres
|
|||||||
|
|
||||||
# ----- Frontend -----
|
# ----- Frontend -----
|
||||||
NODE_ENV=development
|
NODE_ENV=development
|
||||||
PUBLIC_API_URL=http://localhost:8000/api
|
VITE_API_URL=http://localhost:8000/api
|
||||||
PUBLIC_KEYCLOAK_REALM=master
|
VITE_KEYCLOAK_REALM=master
|
||||||
PUBLIC_KEYCLOAK_URL=http://localhost:8080
|
VITE_KEYCLOAK_URL=http://localhost:8080
|
||||||
|
VITE_KEYCLOAK_CLIENT_ID=anexo76-frontend
|
||||||
|
|||||||
@@ -125,3 +125,17 @@ class ExchangeCodeRequestDTO(BaseModel):
|
|||||||
"tenant_slug": "empresa-abc"
|
"tenant_slug": "empresa-abc"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class SetCookieRequestDTO(BaseModel):
|
||||||
|
"""DTO para establecer cookies de autenticación"""
|
||||||
|
access_token: str = Field(..., description="Access token JWT")
|
||||||
|
refresh_token: str = Field(..., description="Refresh token JWT")
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
json_schema_extra = {
|
||||||
|
"example": {
|
||||||
|
"access_token": "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9...",
|
||||||
|
"refresh_token": "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9..."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
"""
|
"""
|
||||||
Endpoints API para autenticación
|
Endpoints API para autenticación
|
||||||
"""
|
"""
|
||||||
from fastapi import APIRouter, Depends, HTTPException
|
from fastapi import APIRouter, Depends, HTTPException, Response
|
||||||
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
|
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
@@ -15,7 +15,8 @@ from .dto import (
|
|||||||
LogoutRequestDTO,
|
LogoutRequestDTO,
|
||||||
RegisterRequestDTO,
|
RegisterRequestDTO,
|
||||||
RegisterResponseDTO,
|
RegisterResponseDTO,
|
||||||
ExchangeCodeRequestDTO
|
ExchangeCodeRequestDTO,
|
||||||
|
SetCookieRequestDTO
|
||||||
)
|
)
|
||||||
from .service import AuthService
|
from .service import AuthService
|
||||||
|
|
||||||
@@ -117,4 +118,65 @@ async def exchange_code(
|
|||||||
externo y Keycloak lo redirige al frontend con el código en los query params.
|
externo y Keycloak lo redirige al frontend con el código en los query params.
|
||||||
"""
|
"""
|
||||||
service = AuthService(db)
|
service = AuthService(db)
|
||||||
return service.exchange_code(exchange_data)
|
return service.exchange_code(exchange_data)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/set-cookie")
|
||||||
|
async def set_cookie(
|
||||||
|
cookie_data: SetCookieRequestDTO,
|
||||||
|
response: Response,
|
||||||
|
db: Session = Depends(get_core_db)
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Establece cookies HttpOnly con los tokens de autenticación
|
||||||
|
|
||||||
|
Este endpoint se llama desde el frontend después de una autenticación
|
||||||
|
SSO exitosa para establecer las cookies de sesión necesarias para
|
||||||
|
la validación server-side en los layouts protegidos.
|
||||||
|
|
||||||
|
Las cookies se configuran como:
|
||||||
|
- HttpOnly: No accesibles desde JavaScript (mayor seguridad)
|
||||||
|
- Secure: Solo se envían por HTTPS (en producción)
|
||||||
|
- SameSite=Lax: Protección contra CSRF
|
||||||
|
- Max-Age: Tiempo de vida del token
|
||||||
|
"""
|
||||||
|
# Validar que los tokens sean válidos decodificándolos
|
||||||
|
service = AuthService(db)
|
||||||
|
try:
|
||||||
|
# Validar el access token
|
||||||
|
user_info = service.get_user_info(cookie_data.access_token)
|
||||||
|
|
||||||
|
# Establecer las cookies
|
||||||
|
# Access token cookie
|
||||||
|
response.set_cookie(
|
||||||
|
key="access_token",
|
||||||
|
value=cookie_data.access_token,
|
||||||
|
httponly=True, # No accesible desde JavaScript
|
||||||
|
secure=False, # TODO: Cambiar a True en producción con HTTPS
|
||||||
|
samesite="lax", # Protección CSRF
|
||||||
|
max_age=3600, # 1 hora (ajustar según configuración del token)
|
||||||
|
path="/"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Refresh token cookie
|
||||||
|
response.set_cookie(
|
||||||
|
key="refresh_token",
|
||||||
|
value=cookie_data.refresh_token,
|
||||||
|
httponly=True,
|
||||||
|
secure=False, # TODO: Cambiar a True en producción con HTTPS
|
||||||
|
samesite="lax",
|
||||||
|
max_age=86400, # 24 horas (ajustar según configuración del token)
|
||||||
|
path="/"
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"message": "Cookies establecidas correctamente",
|
||||||
|
"user": user_info
|
||||||
|
}
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail=f"Error validando tokens: {str(e)}"
|
||||||
|
)
|
||||||
@@ -59,15 +59,21 @@ class TenantMiddleware(BaseHTTPMiddleware):
|
|||||||
user_info = verify_token(token)
|
user_info = verify_token(token)
|
||||||
tenant_id = get_tenant_from_token(user_info)
|
tenant_id = get_tenant_from_token(user_info)
|
||||||
|
|
||||||
|
# ⚠️ NOTA: tenant_id puede ser None para usuarios SSO que aún no tienen tenant asignado
|
||||||
|
# En ese caso, el endpoint específico deberá manejarlo
|
||||||
if not tenant_id:
|
if not tenant_id:
|
||||||
raise HTTPException(status_code=400, detail="Tenant ID not found in token")
|
logger.warning(f"⚠️ Token sin tenant_id para usuario: {user_info.get('sub', 'unknown')}")
|
||||||
|
# No lanzamos error aquí, dejamos que el endpoint decida qué hacer
|
||||||
|
|
||||||
# Agregar tenant_id al state del request
|
# Agregar tenant_id al state del request (puede ser None)
|
||||||
request.state.tenant_id = tenant_id
|
request.state.tenant_id = tenant_id
|
||||||
request.state.user_info = user_info
|
request.state.user_info = user_info
|
||||||
|
|
||||||
|
except HTTPException:
|
||||||
|
# Re-lanzar HTTPException directamente
|
||||||
|
raise
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Tenant validation error: {str(e)}")
|
logger.error(f"❌ Tenant validation error: {str(e)}")
|
||||||
raise HTTPException(status_code=401, detail="Invalid authentication")
|
raise HTTPException(status_code=401, detail="Invalid authentication")
|
||||||
|
|
||||||
response = await call_next(request)
|
response = await call_next(request)
|
||||||
|
|||||||
@@ -209,11 +209,15 @@ services:
|
|||||||
container_name: anexo76-frontend
|
container_name: anexo76-frontend
|
||||||
environment:
|
environment:
|
||||||
- NODE_ENV=${NODE_ENV:-development}
|
- NODE_ENV=${NODE_ENV:-development}
|
||||||
- PUBLIC_API_URL=${PUBLIC_API_URL:-http://localhost:8000}
|
- VITE_API_URL=${VITE_API_URL:-http://localhost:8000/api/}
|
||||||
- PUBLIC_KEYCLOAK_URL=${PUBLIC_KEYCLOAK_URL:-http://localhost:8080}
|
- INTERNAL_API_URL=${INTERNAL_API_URL:-http://backend:8000/api/}
|
||||||
- PUBLIC_KEYCLOAK_REALM=${KEYCLOAK_REALM:-master}
|
- VITE_KEYCLOAK_URL=${VITE_KEYCLOAK_URL:-http://localhost:8080}
|
||||||
- PUBLIC_KEYCLOAK_CLIENT_ID=${KEYCLOAK_FRONTEND_CLIENT_ID:-anexo76-frontend}
|
- VITE_KEYCLOAK_REALM=${VITE_KEYCLOAK_REALM:-master}
|
||||||
- VITE_HMR_HOST=localhost
|
- VITE_KEYCLOAK_CLIENT_ID=${VITE_KEYCLOAK_CLIENT_ID:-anexo76-frontend}
|
||||||
|
- KEYCLOAK_URL=${KEYCLOAK_URL:-http://keycloak:8080}
|
||||||
|
- KEYCLOAK_REALM=${KEYCLOAK_REALM:-master}
|
||||||
|
- KEYCLOAK_CLIENT_ID=${KEYCLOAK_CLIENT_ID:-anexo76-backend}
|
||||||
|
- KEYCLOAK_CLIENT_SECRET=${KEYCLOAK_CLIENT_SECRET:-zRU5NuvUFtBSOuh7Kdc372AItoWGLgz9}
|
||||||
ports:
|
ports:
|
||||||
- "5180:5180"
|
- "5180:5180"
|
||||||
depends_on:
|
depends_on:
|
||||||
@@ -226,6 +230,7 @@ services:
|
|||||||
- ./scripts/frontend-entrypoint.sh:/frontend-entrypoint.sh:ro
|
- ./scripts/frontend-entrypoint.sh:/frontend-entrypoint.sh:ro
|
||||||
networks:
|
networks:
|
||||||
- frontend-net
|
- frontend-net
|
||||||
|
- auth-net
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
command: ["pnpm", "run", "dev", "--", "--host", "0.0.0.0"]
|
command: ["pnpm", "run", "dev", "--", "--host", "0.0.0.0"]
|
||||||
healthcheck:
|
healthcheck:
|
||||||
|
|||||||
@@ -109,7 +109,6 @@ Target: BROKER_USERNAME
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
||||||
## Paso 4: Verificar en tu App
|
## Paso 4: Verificar en tu App
|
||||||
|
|
||||||
1. Asegúrate de que el frontend esté corriendo: `http://localhost:5173`
|
1. Asegúrate de que el frontend esté corriendo: `http://localhost:5173`
|
||||||
@@ -197,9 +196,9 @@ curl -s -o /dev/null -w "%{http_code}" "http://localhost:8080/realms/master/brok
|
|||||||
3. ✅ Redirect URI en Azure: `http://localhost:8080/realms/master/broker/microsoft/endpoint`
|
3. ✅ Redirect URI en Azure: `http://localhost:8080/realms/master/broker/microsoft/endpoint`
|
||||||
4. ✅ Variables de entorno en frontend (.env):
|
4. ✅ Variables de entorno en frontend (.env):
|
||||||
```
|
```
|
||||||
PUBLIC_KEYCLOAK_URL=http://localhost:8080
|
VITE_KEYCLOAK_URL=http://localhost:8080
|
||||||
PUBLIC_KEYCLOAK_REALM=master
|
VITE_KEYCLOAK_REALM=master
|
||||||
PUBLIC_KEYCLOAK_CLIENT_ID=anexo76-frontend
|
VITE_KEYCLOAK_CLIENT_ID=anexo76-frontend
|
||||||
```
|
```
|
||||||
|
|
||||||
**El flujo correcto es:**
|
**El flujo correcto es:**
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
# Environment variables para frontend
|
# Environment variables para frontend
|
||||||
PUBLIC_API_URL=http://localhost:8000/api/
|
VITE_API_URL=http://localhost:8000/api/
|
||||||
|
|
||||||
# Configuración de Keycloak para SSO
|
# Configuración de Keycloak para SSO
|
||||||
PUBLIC_KEYCLOAK_URL=http://localhost:8080
|
VITE_KEYCLOAK_URL=http://localhost:8080
|
||||||
PUBLIC_KEYCLOAK_REALM=master
|
VITE_KEYCLOAK_REALM=master
|
||||||
PUBLIC_KEYCLOAK_CLIENT_ID=anexo76-frontend
|
VITE_KEYCLOAK_CLIENT_ID=anexo76-frontend
|
||||||
|
|
||||||
# Opcional: Habilitar/deshabilitar proveedores SSO
|
# Opcional: Habilitar/deshabilitar proveedores SSO
|
||||||
PUBLIC_ENABLE_MICROSOFT_SSO=true
|
PUBLIC_ENABLE_MICROSOFT_SSO=true
|
||||||
|
|||||||
@@ -3,10 +3,7 @@
|
|||||||
"singleQuote": true,
|
"singleQuote": true,
|
||||||
"trailingComma": "none",
|
"trailingComma": "none",
|
||||||
"printWidth": 100,
|
"printWidth": 100,
|
||||||
"plugins": [
|
"plugins": ["prettier-plugin-svelte", "prettier-plugin-tailwindcss"],
|
||||||
"prettier-plugin-svelte",
|
|
||||||
"prettier-plugin-tailwindcss"
|
|
||||||
],
|
|
||||||
"overrides": [
|
"overrides": [
|
||||||
{
|
{
|
||||||
"files": "*.svelte",
|
"files": "*.svelte",
|
||||||
|
|||||||
16
frontend/components.json
Normal file
16
frontend/components.json
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://shadcn-svelte.com/schema.json",
|
||||||
|
"tailwind": {
|
||||||
|
"css": "src/app.css",
|
||||||
|
"baseColor": "zinc"
|
||||||
|
},
|
||||||
|
"aliases": {
|
||||||
|
"components": "$lib/components",
|
||||||
|
"utils": "$lib/utils",
|
||||||
|
"ui": "$lib/components/ui",
|
||||||
|
"hooks": "$lib/hooks",
|
||||||
|
"lib": "$lib"
|
||||||
|
},
|
||||||
|
"typescript": true,
|
||||||
|
"registry": "https://shadcn-svelte.com/registry"
|
||||||
|
}
|
||||||
@@ -20,15 +20,19 @@
|
|||||||
"@eslint/compat": "^1.4.0",
|
"@eslint/compat": "^1.4.0",
|
||||||
"@eslint/js": "^9.36.0",
|
"@eslint/js": "^9.36.0",
|
||||||
"@inlang/paraglide-js": "^2.3.2",
|
"@inlang/paraglide-js": "^2.3.2",
|
||||||
|
"@internationalized/date": "^3.10.0",
|
||||||
|
"@lucide/svelte": "^0.544.0",
|
||||||
"@playwright/test": "^1.55.1",
|
"@playwright/test": "^1.55.1",
|
||||||
"@sveltejs/adapter-node": "^5.3.2",
|
"@sveltejs/adapter-node": "^5.3.2",
|
||||||
"@sveltejs/kit": "^2.43.2",
|
"@sveltejs/kit": "^2.43.2",
|
||||||
"@sveltejs/vite-plugin-svelte": "^6.2.0",
|
"@sveltejs/vite-plugin-svelte": "^6.2.0",
|
||||||
"@tailwindcss/forms": "^0.5.10",
|
"@tailwindcss/forms": "^0.5.10",
|
||||||
"@tailwindcss/typography": "^0.5.18",
|
"@tailwindcss/typography": "^0.5.19",
|
||||||
"@tailwindcss/vite": "^4.1.13",
|
"@tailwindcss/vite": "^4.1.14",
|
||||||
"@types/node": "^20",
|
"@types/node": "^20",
|
||||||
"@vitest/browser": "^3.2.4",
|
"@vitest/browser": "^3.2.4",
|
||||||
|
"bits-ui": "^2.14.2",
|
||||||
|
"clsx": "^2.1.1",
|
||||||
"eslint": "^9.36.0",
|
"eslint": "^9.36.0",
|
||||||
"eslint-config-prettier": "^10.1.8",
|
"eslint-config-prettier": "^10.1.8",
|
||||||
"eslint-plugin-svelte": "^3.12.4",
|
"eslint-plugin-svelte": "^3.12.4",
|
||||||
@@ -36,10 +40,13 @@
|
|||||||
"playwright": "^1.55.1",
|
"playwright": "^1.55.1",
|
||||||
"prettier": "^3.6.2",
|
"prettier": "^3.6.2",
|
||||||
"prettier-plugin-svelte": "^3.4.0",
|
"prettier-plugin-svelte": "^3.4.0",
|
||||||
"prettier-plugin-tailwindcss": "^0.6.14",
|
"prettier-plugin-tailwindcss": "^0.7.1",
|
||||||
"svelte": "^5.39.5",
|
"svelte": "^5.39.5",
|
||||||
"svelte-check": "^4.3.2",
|
"svelte-check": "^4.3.2",
|
||||||
"tailwindcss": "^4.1.13",
|
"tailwind-merge": "^3.3.1",
|
||||||
|
"tailwind-variants": "^3.1.1",
|
||||||
|
"tailwindcss": "^4.1.14",
|
||||||
|
"tw-animate-css": "^1.4.0",
|
||||||
"typescript": "^5.9.2",
|
"typescript": "^5.9.2",
|
||||||
"typescript-eslint": "^8.44.1",
|
"typescript-eslint": "^8.44.1",
|
||||||
"vite": "^7.1.7",
|
"vite": "^7.1.7",
|
||||||
|
|||||||
189
frontend/pnpm-lock.yaml
generated
189
frontend/pnpm-lock.yaml
generated
@@ -21,6 +21,12 @@ importers:
|
|||||||
'@inlang/paraglide-js':
|
'@inlang/paraglide-js':
|
||||||
specifier: ^2.3.2
|
specifier: ^2.3.2
|
||||||
version: 2.4.0
|
version: 2.4.0
|
||||||
|
'@internationalized/date':
|
||||||
|
specifier: ^3.10.0
|
||||||
|
version: 3.10.0
|
||||||
|
'@lucide/svelte':
|
||||||
|
specifier: ^0.544.0
|
||||||
|
version: 0.544.0(svelte@5.40.2)
|
||||||
'@playwright/test':
|
'@playwright/test':
|
||||||
specifier: ^1.55.1
|
specifier: ^1.55.1
|
||||||
version: 1.56.1
|
version: 1.56.1
|
||||||
@@ -37,10 +43,10 @@ importers:
|
|||||||
specifier: ^0.5.10
|
specifier: ^0.5.10
|
||||||
version: 0.5.10(tailwindcss@4.1.14)
|
version: 0.5.10(tailwindcss@4.1.14)
|
||||||
'@tailwindcss/typography':
|
'@tailwindcss/typography':
|
||||||
specifier: ^0.5.18
|
specifier: ^0.5.19
|
||||||
version: 0.5.19(tailwindcss@4.1.14)
|
version: 0.5.19(tailwindcss@4.1.14)
|
||||||
'@tailwindcss/vite':
|
'@tailwindcss/vite':
|
||||||
specifier: ^4.1.13
|
specifier: ^4.1.14
|
||||||
version: 4.1.14(vite@7.1.10(@types/node@20.19.22)(jiti@2.6.1)(lightningcss@1.30.1))
|
version: 4.1.14(vite@7.1.10(@types/node@20.19.22)(jiti@2.6.1)(lightningcss@1.30.1))
|
||||||
'@types/node':
|
'@types/node':
|
||||||
specifier: ^20
|
specifier: ^20
|
||||||
@@ -48,6 +54,12 @@ importers:
|
|||||||
'@vitest/browser':
|
'@vitest/browser':
|
||||||
specifier: ^3.2.4
|
specifier: ^3.2.4
|
||||||
version: 3.2.4(playwright@1.56.1)(vite@7.1.10(@types/node@20.19.22)(jiti@2.6.1)(lightningcss@1.30.1))(vitest@3.2.4)
|
version: 3.2.4(playwright@1.56.1)(vite@7.1.10(@types/node@20.19.22)(jiti@2.6.1)(lightningcss@1.30.1))(vitest@3.2.4)
|
||||||
|
bits-ui:
|
||||||
|
specifier: ^2.14.2
|
||||||
|
version: 2.14.2(@internationalized/date@3.10.0)(@sveltejs/kit@2.47.1(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.40.2)(vite@7.1.10(@types/node@20.19.22)(jiti@2.6.1)(lightningcss@1.30.1)))(svelte@5.40.2)(vite@7.1.10(@types/node@20.19.22)(jiti@2.6.1)(lightningcss@1.30.1)))(svelte@5.40.2)
|
||||||
|
clsx:
|
||||||
|
specifier: ^2.1.1
|
||||||
|
version: 2.1.1
|
||||||
eslint:
|
eslint:
|
||||||
specifier: ^9.36.0
|
specifier: ^9.36.0
|
||||||
version: 9.38.0(jiti@2.6.1)
|
version: 9.38.0(jiti@2.6.1)
|
||||||
@@ -70,17 +82,26 @@ importers:
|
|||||||
specifier: ^3.4.0
|
specifier: ^3.4.0
|
||||||
version: 3.4.0(prettier@3.6.2)(svelte@5.40.2)
|
version: 3.4.0(prettier@3.6.2)(svelte@5.40.2)
|
||||||
prettier-plugin-tailwindcss:
|
prettier-plugin-tailwindcss:
|
||||||
specifier: ^0.6.14
|
specifier: ^0.7.1
|
||||||
version: 0.6.14(prettier-plugin-svelte@3.4.0(prettier@3.6.2)(svelte@5.40.2))(prettier@3.6.2)
|
version: 0.7.1(prettier-plugin-svelte@3.4.0(prettier@3.6.2)(svelte@5.40.2))(prettier@3.6.2)
|
||||||
svelte:
|
svelte:
|
||||||
specifier: ^5.39.5
|
specifier: ^5.39.5
|
||||||
version: 5.40.2
|
version: 5.40.2
|
||||||
svelte-check:
|
svelte-check:
|
||||||
specifier: ^4.3.2
|
specifier: ^4.3.2
|
||||||
version: 4.3.3(picomatch@4.0.3)(svelte@5.40.2)(typescript@5.9.3)
|
version: 4.3.3(picomatch@4.0.3)(svelte@5.40.2)(typescript@5.9.3)
|
||||||
|
tailwind-merge:
|
||||||
|
specifier: ^3.3.1
|
||||||
|
version: 3.3.1
|
||||||
|
tailwind-variants:
|
||||||
|
specifier: ^3.1.1
|
||||||
|
version: 3.1.1(tailwind-merge@3.3.1)(tailwindcss@4.1.14)
|
||||||
tailwindcss:
|
tailwindcss:
|
||||||
specifier: ^4.1.13
|
specifier: ^4.1.14
|
||||||
version: 4.1.14
|
version: 4.1.14
|
||||||
|
tw-animate-css:
|
||||||
|
specifier: ^1.4.0
|
||||||
|
version: 1.4.0
|
||||||
typescript:
|
typescript:
|
||||||
specifier: ^5.9.2
|
specifier: ^5.9.2
|
||||||
version: 5.9.3
|
version: 5.9.3
|
||||||
@@ -314,6 +335,15 @@ packages:
|
|||||||
resolution: {integrity: sha512-sB5uyeq+dwCWyPi31B2gQlVlo+j5brPlWx4yZBrEaRo/nhdDE8Xke1gsGgtiBdaBTxuTkceLVuVt/pclrasb0A==}
|
resolution: {integrity: sha512-sB5uyeq+dwCWyPi31B2gQlVlo+j5brPlWx4yZBrEaRo/nhdDE8Xke1gsGgtiBdaBTxuTkceLVuVt/pclrasb0A==}
|
||||||
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
||||||
|
|
||||||
|
'@floating-ui/core@1.7.3':
|
||||||
|
resolution: {integrity: sha512-sGnvb5dmrJaKEZ+LDIpguvdX3bDlEllmv4/ClQ9awcmCZrlx5jQyyMWFM5kBI+EyNOCDDiKk8il0zeuX3Zlg/w==}
|
||||||
|
|
||||||
|
'@floating-ui/dom@1.7.4':
|
||||||
|
resolution: {integrity: sha512-OOchDgh4F2CchOX94cRVqhvy7b3AFb+/rQXyswmzmGakRfkMgoWVjfnLWkRirfLEfuD4ysVW16eXzwt3jHIzKA==}
|
||||||
|
|
||||||
|
'@floating-ui/utils@0.2.10':
|
||||||
|
resolution: {integrity: sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ==}
|
||||||
|
|
||||||
'@humanfs/core@0.19.1':
|
'@humanfs/core@0.19.1':
|
||||||
resolution: {integrity: sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==}
|
resolution: {integrity: sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==}
|
||||||
engines: {node: '>=18.18.0'}
|
engines: {node: '>=18.18.0'}
|
||||||
@@ -341,6 +371,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-cvz/C1rF5WBxzHbEoiBoI6Sz6q6M+TdxfWkEGBYTD77opY8i8WN01prUWXEM87GPF4SZcyIySez9U0Ccm12oFQ==}
|
resolution: {integrity: sha512-cvz/C1rF5WBxzHbEoiBoI6Sz6q6M+TdxfWkEGBYTD77opY8i8WN01prUWXEM87GPF4SZcyIySez9U0Ccm12oFQ==}
|
||||||
engines: {node: '>=18.0.0'}
|
engines: {node: '>=18.0.0'}
|
||||||
|
|
||||||
|
'@internationalized/date@3.10.0':
|
||||||
|
resolution: {integrity: sha512-oxDR/NTEJ1k+UFVQElaNIk65E/Z83HK1z1WI3lQyhTtnNg4R5oVXaPzK3jcpKG8UHKDVuDQHzn+wsxSz8RP3aw==}
|
||||||
|
|
||||||
'@isaacs/fs-minipass@4.0.1':
|
'@isaacs/fs-minipass@4.0.1':
|
||||||
resolution: {integrity: sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w==}
|
resolution: {integrity: sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w==}
|
||||||
engines: {node: '>=18.0.0'}
|
engines: {node: '>=18.0.0'}
|
||||||
@@ -368,6 +401,11 @@ packages:
|
|||||||
'@lix-js/server-protocol-schema@0.1.1':
|
'@lix-js/server-protocol-schema@0.1.1':
|
||||||
resolution: {integrity: sha512-jBeALB6prAbtr5q4vTuxnRZZv1M2rKe8iNqRQhFJ4Tv7150unEa0vKyz0hs8Gl3fUGsWaNJBh3J8++fpbrpRBQ==}
|
resolution: {integrity: sha512-jBeALB6prAbtr5q4vTuxnRZZv1M2rKe8iNqRQhFJ4Tv7150unEa0vKyz0hs8Gl3fUGsWaNJBh3J8++fpbrpRBQ==}
|
||||||
|
|
||||||
|
'@lucide/svelte@0.544.0':
|
||||||
|
resolution: {integrity: sha512-9f9O6uxng2pLB01sxNySHduJN3HTl5p0HDu4H26VR51vhZfiMzyOMe9Mhof3XAk4l813eTtl+/DYRvGyoRR+yw==}
|
||||||
|
peerDependencies:
|
||||||
|
svelte: ^5
|
||||||
|
|
||||||
'@nodelib/fs.scandir@2.1.5':
|
'@nodelib/fs.scandir@2.1.5':
|
||||||
resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==}
|
resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==}
|
||||||
engines: {node: '>= 8'}
|
engines: {node: '>= 8'}
|
||||||
@@ -582,6 +620,9 @@ packages:
|
|||||||
svelte: ^5.0.0
|
svelte: ^5.0.0
|
||||||
vite: ^6.3.0 || ^7.0.0
|
vite: ^6.3.0 || ^7.0.0
|
||||||
|
|
||||||
|
'@swc/helpers@0.5.17':
|
||||||
|
resolution: {integrity: sha512-5IKx/Y13RsYd+sauPb2x+U/xZikHjolzfuDgTAl/Tdf3Q8rslRvC19NKDLgAJQ6wsqADk10ntlv08nPFw/gO/A==}
|
||||||
|
|
||||||
'@tailwindcss/forms@0.5.10':
|
'@tailwindcss/forms@0.5.10':
|
||||||
resolution: {integrity: sha512-utI1ONF6uf/pPNO68kmN1b8rEwNXv3czukalo8VtJH8ksIkZXr3Q3VYudZLkCsDd4Wku120uF02hYK25XGPorw==}
|
resolution: {integrity: sha512-utI1ONF6uf/pPNO68kmN1b8rEwNXv3czukalo8VtJH8ksIkZXr3Q3VYudZLkCsDd4Wku120uF02hYK25XGPorw==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
@@ -868,6 +909,13 @@ packages:
|
|||||||
balanced-match@1.0.2:
|
balanced-match@1.0.2:
|
||||||
resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==}
|
resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==}
|
||||||
|
|
||||||
|
bits-ui@2.14.2:
|
||||||
|
resolution: {integrity: sha512-YqpAJj/nRTZjf7IlgUC3QlepVZ7YFiAQWpZaYUOAZFW5Py+g5DYkhEDTdNFI5SReo7l1rct/nRpMK4pfL9Xffw==}
|
||||||
|
engines: {node: '>=20'}
|
||||||
|
peerDependencies:
|
||||||
|
'@internationalized/date': ^3.8.1
|
||||||
|
svelte: ^5.33.0
|
||||||
|
|
||||||
brace-expansion@1.1.12:
|
brace-expansion@1.1.12:
|
||||||
resolution: {integrity: sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==}
|
resolution: {integrity: sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==}
|
||||||
|
|
||||||
@@ -1195,6 +1243,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==}
|
resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==}
|
||||||
engines: {node: '>=0.8.19'}
|
engines: {node: '>=0.8.19'}
|
||||||
|
|
||||||
|
inline-style-parser@0.2.6:
|
||||||
|
resolution: {integrity: sha512-gtGXVaBdl5mAes3rPcMedEBm12ibjt1kDMFfheul1wUAOVEJW60voNdMVzVkfLN06O7ZaD/rxhfKgtlgtTbMjg==}
|
||||||
|
|
||||||
is-core-module@2.16.1:
|
is-core-module@2.16.1:
|
||||||
resolution: {integrity: sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==}
|
resolution: {integrity: sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==}
|
||||||
engines: {node: '>= 0.4'}
|
engines: {node: '>= 0.4'}
|
||||||
@@ -1510,9 +1561,9 @@ packages:
|
|||||||
prettier: ^3.0.0
|
prettier: ^3.0.0
|
||||||
svelte: ^3.2.0 || ^4.0.0-next.0 || ^5.0.0-next.0
|
svelte: ^3.2.0 || ^4.0.0-next.0 || ^5.0.0-next.0
|
||||||
|
|
||||||
prettier-plugin-tailwindcss@0.6.14:
|
prettier-plugin-tailwindcss@0.7.1:
|
||||||
resolution: {integrity: sha512-pi2e/+ZygeIqntN+vC573BcW5Cve8zUB0SSAGxqpB4f96boZF4M3phPVoOFCeypwkpRYdi7+jQ5YJJUwrkGUAg==}
|
resolution: {integrity: sha512-Bzv1LZcuiR1Sk02iJTS1QzlFNp/o5l2p3xkopwOrbPmtMeh3fK9rVW5M3neBQzHq+kGKj/4LGQMTNcTH4NGPtQ==}
|
||||||
engines: {node: '>=14.21.3'}
|
engines: {node: '>=20.19'}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
'@ianvs/prettier-plugin-sort-imports': '*'
|
'@ianvs/prettier-plugin-sort-imports': '*'
|
||||||
'@prettier/plugin-hermes': '*'
|
'@prettier/plugin-hermes': '*'
|
||||||
@@ -1524,14 +1575,12 @@ packages:
|
|||||||
prettier: ^3.0
|
prettier: ^3.0
|
||||||
prettier-plugin-astro: '*'
|
prettier-plugin-astro: '*'
|
||||||
prettier-plugin-css-order: '*'
|
prettier-plugin-css-order: '*'
|
||||||
prettier-plugin-import-sort: '*'
|
|
||||||
prettier-plugin-jsdoc: '*'
|
prettier-plugin-jsdoc: '*'
|
||||||
prettier-plugin-marko: '*'
|
prettier-plugin-marko: '*'
|
||||||
prettier-plugin-multiline-arrays: '*'
|
prettier-plugin-multiline-arrays: '*'
|
||||||
prettier-plugin-organize-attributes: '*'
|
prettier-plugin-organize-attributes: '*'
|
||||||
prettier-plugin-organize-imports: '*'
|
prettier-plugin-organize-imports: '*'
|
||||||
prettier-plugin-sort-imports: '*'
|
prettier-plugin-sort-imports: '*'
|
||||||
prettier-plugin-style-order: '*'
|
|
||||||
prettier-plugin-svelte: '*'
|
prettier-plugin-svelte: '*'
|
||||||
peerDependenciesMeta:
|
peerDependenciesMeta:
|
||||||
'@ianvs/prettier-plugin-sort-imports':
|
'@ianvs/prettier-plugin-sort-imports':
|
||||||
@@ -1552,8 +1601,6 @@ packages:
|
|||||||
optional: true
|
optional: true
|
||||||
prettier-plugin-css-order:
|
prettier-plugin-css-order:
|
||||||
optional: true
|
optional: true
|
||||||
prettier-plugin-import-sort:
|
|
||||||
optional: true
|
|
||||||
prettier-plugin-jsdoc:
|
prettier-plugin-jsdoc:
|
||||||
optional: true
|
optional: true
|
||||||
prettier-plugin-marko:
|
prettier-plugin-marko:
|
||||||
@@ -1566,8 +1613,6 @@ packages:
|
|||||||
optional: true
|
optional: true
|
||||||
prettier-plugin-sort-imports:
|
prettier-plugin-sort-imports:
|
||||||
optional: true
|
optional: true
|
||||||
prettier-plugin-style-order:
|
|
||||||
optional: true
|
|
||||||
prettier-plugin-svelte:
|
prettier-plugin-svelte:
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
@@ -1615,6 +1660,15 @@ packages:
|
|||||||
run-parallel@1.2.0:
|
run-parallel@1.2.0:
|
||||||
resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==}
|
resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==}
|
||||||
|
|
||||||
|
runed@0.35.1:
|
||||||
|
resolution: {integrity: sha512-2F4Q/FZzbeJTFdIS/PuOoPRSm92sA2LhzTnv6FXhCoENb3huf5+fDuNOg1LNvGOouy3u/225qxmuJvcV3IZK5Q==}
|
||||||
|
peerDependencies:
|
||||||
|
'@sveltejs/kit': ^2.21.0
|
||||||
|
svelte: ^5.7.0
|
||||||
|
peerDependenciesMeta:
|
||||||
|
'@sveltejs/kit':
|
||||||
|
optional: true
|
||||||
|
|
||||||
sade@1.8.1:
|
sade@1.8.1:
|
||||||
resolution: {integrity: sha512-xal3CZX1Xlo/k4ApwCFrHVACi9fBqJ7V+mwhBsuf/1IOKbBy098Fex+Wa/5QMubw09pSZ/u8EY8PWgevJsXp1A==}
|
resolution: {integrity: sha512-xal3CZX1Xlo/k4ApwCFrHVACi9fBqJ7V+mwhBsuf/1IOKbBy098Fex+Wa/5QMubw09pSZ/u8EY8PWgevJsXp1A==}
|
||||||
engines: {node: '>=6'}
|
engines: {node: '>=6'}
|
||||||
@@ -1664,6 +1718,9 @@ packages:
|
|||||||
strip-literal@3.1.0:
|
strip-literal@3.1.0:
|
||||||
resolution: {integrity: sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==}
|
resolution: {integrity: sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==}
|
||||||
|
|
||||||
|
style-to-object@1.0.12:
|
||||||
|
resolution: {integrity: sha512-ddJqYnoT4t97QvN2C95bCgt+m7AAgXjVnkk/jxAfmp7EAB8nnqqZYEbMd3em7/vEomDb2LAQKAy1RFfv41mdNw==}
|
||||||
|
|
||||||
supports-color@7.2.0:
|
supports-color@7.2.0:
|
||||||
resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==}
|
resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==}
|
||||||
engines: {node: '>=8'}
|
engines: {node: '>=8'}
|
||||||
@@ -1689,10 +1746,32 @@ packages:
|
|||||||
svelte:
|
svelte:
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
svelte-toolbelt@0.10.6:
|
||||||
|
resolution: {integrity: sha512-YWuX+RE+CnWYx09yseAe4ZVMM7e7GRFZM6OYWpBKOb++s+SQ8RBIMMe+Bs/CznBMc0QPLjr+vDBxTAkozXsFXQ==}
|
||||||
|
engines: {node: '>=18', pnpm: '>=8.7.0'}
|
||||||
|
peerDependencies:
|
||||||
|
svelte: ^5.30.2
|
||||||
|
|
||||||
svelte@5.40.2:
|
svelte@5.40.2:
|
||||||
resolution: {integrity: sha512-wr/SwBVCVfeHU8FZr48vRrzSpWdBBzGo5mlErjGzeW4reJhK/CWutLZbk/eHwhKqO17ccjeTcvsqjrT4aK3wZA==}
|
resolution: {integrity: sha512-wr/SwBVCVfeHU8FZr48vRrzSpWdBBzGo5mlErjGzeW4reJhK/CWutLZbk/eHwhKqO17ccjeTcvsqjrT4aK3wZA==}
|
||||||
engines: {node: '>=18'}
|
engines: {node: '>=18'}
|
||||||
|
|
||||||
|
tabbable@6.3.0:
|
||||||
|
resolution: {integrity: sha512-EIHvdY5bPLuWForiR/AN2Bxngzpuwn1is4asboytXtpTgsArc+WmSJKVLlhdh71u7jFcryDqB2A8lQvj78MkyQ==}
|
||||||
|
|
||||||
|
tailwind-merge@3.3.1:
|
||||||
|
resolution: {integrity: sha512-gBXpgUm/3rp1lMZZrM/w7D8GKqshif0zAymAhbCyIt8KMe+0v9DQ7cdYLR4FHH/cKpdTXb+A/tKKU3eolfsI+g==}
|
||||||
|
|
||||||
|
tailwind-variants@3.1.1:
|
||||||
|
resolution: {integrity: sha512-ftLXe3krnqkMHsuBTEmaVUXYovXtPyTK7ckEfDRXS8PBZx0bAUas+A0jYxuKA5b8qg++wvQ3d2MQ7l/xeZxbZQ==}
|
||||||
|
engines: {node: '>=16.x', pnpm: '>=7.x'}
|
||||||
|
peerDependencies:
|
||||||
|
tailwind-merge: '>=3.0.0'
|
||||||
|
tailwindcss: '*'
|
||||||
|
peerDependenciesMeta:
|
||||||
|
tailwind-merge:
|
||||||
|
optional: true
|
||||||
|
|
||||||
tailwindcss@4.1.14:
|
tailwindcss@4.1.14:
|
||||||
resolution: {integrity: sha512-b7pCxjGO98LnxVkKjaZSDeNuljC4ueKUddjENJOADtubtdo8llTaJy7HwBMeLNSSo2N5QIAgklslK1+Ir8r6CA==}
|
resolution: {integrity: sha512-b7pCxjGO98LnxVkKjaZSDeNuljC4ueKUddjENJOADtubtdo8llTaJy7HwBMeLNSSo2N5QIAgklslK1+Ir8r6CA==}
|
||||||
|
|
||||||
@@ -1740,6 +1819,12 @@ packages:
|
|||||||
peerDependencies:
|
peerDependencies:
|
||||||
typescript: '>=4.8.4'
|
typescript: '>=4.8.4'
|
||||||
|
|
||||||
|
tslib@2.8.1:
|
||||||
|
resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==}
|
||||||
|
|
||||||
|
tw-animate-css@1.4.0:
|
||||||
|
resolution: {integrity: sha512-7bziOlRqH0hJx80h/3mbicLW7o8qLsH5+RaLR2t+OHM3D0JlWGODQKQ4cxbK7WlvmUxpcj6Kgu6EKqjrGFe3QQ==}
|
||||||
|
|
||||||
type-check@0.4.0:
|
type-check@0.4.0:
|
||||||
resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==}
|
resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==}
|
||||||
engines: {node: '>= 0.8.0'}
|
engines: {node: '>= 0.8.0'}
|
||||||
@@ -2051,6 +2136,17 @@ snapshots:
|
|||||||
'@eslint/core': 0.16.0
|
'@eslint/core': 0.16.0
|
||||||
levn: 0.4.1
|
levn: 0.4.1
|
||||||
|
|
||||||
|
'@floating-ui/core@1.7.3':
|
||||||
|
dependencies:
|
||||||
|
'@floating-ui/utils': 0.2.10
|
||||||
|
|
||||||
|
'@floating-ui/dom@1.7.4':
|
||||||
|
dependencies:
|
||||||
|
'@floating-ui/core': 1.7.3
|
||||||
|
'@floating-ui/utils': 0.2.10
|
||||||
|
|
||||||
|
'@floating-ui/utils@0.2.10': {}
|
||||||
|
|
||||||
'@humanfs/core@0.19.1': {}
|
'@humanfs/core@0.19.1': {}
|
||||||
|
|
||||||
'@humanfs/node@0.16.7':
|
'@humanfs/node@0.16.7':
|
||||||
@@ -2088,6 +2184,10 @@ snapshots:
|
|||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- babel-plugin-macros
|
- babel-plugin-macros
|
||||||
|
|
||||||
|
'@internationalized/date@3.10.0':
|
||||||
|
dependencies:
|
||||||
|
'@swc/helpers': 0.5.17
|
||||||
|
|
||||||
'@isaacs/fs-minipass@4.0.1':
|
'@isaacs/fs-minipass@4.0.1':
|
||||||
dependencies:
|
dependencies:
|
||||||
minipass: 7.1.2
|
minipass: 7.1.2
|
||||||
@@ -2125,6 +2225,10 @@ snapshots:
|
|||||||
|
|
||||||
'@lix-js/server-protocol-schema@0.1.1': {}
|
'@lix-js/server-protocol-schema@0.1.1': {}
|
||||||
|
|
||||||
|
'@lucide/svelte@0.544.0(svelte@5.40.2)':
|
||||||
|
dependencies:
|
||||||
|
svelte: 5.40.2
|
||||||
|
|
||||||
'@nodelib/fs.scandir@2.1.5':
|
'@nodelib/fs.scandir@2.1.5':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@nodelib/fs.stat': 2.0.5
|
'@nodelib/fs.stat': 2.0.5
|
||||||
@@ -2303,6 +2407,10 @@ snapshots:
|
|||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- supports-color
|
- supports-color
|
||||||
|
|
||||||
|
'@swc/helpers@0.5.17':
|
||||||
|
dependencies:
|
||||||
|
tslib: 2.8.1
|
||||||
|
|
||||||
'@tailwindcss/forms@0.5.10(tailwindcss@4.1.14)':
|
'@tailwindcss/forms@0.5.10(tailwindcss@4.1.14)':
|
||||||
dependencies:
|
dependencies:
|
||||||
mini-svg-data-uri: 1.4.4
|
mini-svg-data-uri: 1.4.4
|
||||||
@@ -2610,6 +2718,19 @@ snapshots:
|
|||||||
|
|
||||||
balanced-match@1.0.2: {}
|
balanced-match@1.0.2: {}
|
||||||
|
|
||||||
|
bits-ui@2.14.2(@internationalized/date@3.10.0)(@sveltejs/kit@2.47.1(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.40.2)(vite@7.1.10(@types/node@20.19.22)(jiti@2.6.1)(lightningcss@1.30.1)))(svelte@5.40.2)(vite@7.1.10(@types/node@20.19.22)(jiti@2.6.1)(lightningcss@1.30.1)))(svelte@5.40.2):
|
||||||
|
dependencies:
|
||||||
|
'@floating-ui/core': 1.7.3
|
||||||
|
'@floating-ui/dom': 1.7.4
|
||||||
|
'@internationalized/date': 3.10.0
|
||||||
|
esm-env: 1.2.2
|
||||||
|
runed: 0.35.1(@sveltejs/kit@2.47.1(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.40.2)(vite@7.1.10(@types/node@20.19.22)(jiti@2.6.1)(lightningcss@1.30.1)))(svelte@5.40.2)(vite@7.1.10(@types/node@20.19.22)(jiti@2.6.1)(lightningcss@1.30.1)))(svelte@5.40.2)
|
||||||
|
svelte: 5.40.2
|
||||||
|
svelte-toolbelt: 0.10.6(@sveltejs/kit@2.47.1(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.40.2)(vite@7.1.10(@types/node@20.19.22)(jiti@2.6.1)(lightningcss@1.30.1)))(svelte@5.40.2)(vite@7.1.10(@types/node@20.19.22)(jiti@2.6.1)(lightningcss@1.30.1)))(svelte@5.40.2)
|
||||||
|
tabbable: 6.3.0
|
||||||
|
transitivePeerDependencies:
|
||||||
|
- '@sveltejs/kit'
|
||||||
|
|
||||||
brace-expansion@1.1.12:
|
brace-expansion@1.1.12:
|
||||||
dependencies:
|
dependencies:
|
||||||
balanced-match: 1.0.2
|
balanced-match: 1.0.2
|
||||||
@@ -2931,6 +3052,8 @@ snapshots:
|
|||||||
|
|
||||||
imurmurhash@0.1.4: {}
|
imurmurhash@0.1.4: {}
|
||||||
|
|
||||||
|
inline-style-parser@0.2.6: {}
|
||||||
|
|
||||||
is-core-module@2.16.1:
|
is-core-module@2.16.1:
|
||||||
dependencies:
|
dependencies:
|
||||||
hasown: 2.0.2
|
hasown: 2.0.2
|
||||||
@@ -3171,7 +3294,7 @@ snapshots:
|
|||||||
prettier: 3.6.2
|
prettier: 3.6.2
|
||||||
svelte: 5.40.2
|
svelte: 5.40.2
|
||||||
|
|
||||||
prettier-plugin-tailwindcss@0.6.14(prettier-plugin-svelte@3.4.0(prettier@3.6.2)(svelte@5.40.2))(prettier@3.6.2):
|
prettier-plugin-tailwindcss@0.7.1(prettier-plugin-svelte@3.4.0(prettier@3.6.2)(svelte@5.40.2))(prettier@3.6.2):
|
||||||
dependencies:
|
dependencies:
|
||||||
prettier: 3.6.2
|
prettier: 3.6.2
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
@@ -3235,6 +3358,15 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
queue-microtask: 1.2.3
|
queue-microtask: 1.2.3
|
||||||
|
|
||||||
|
runed@0.35.1(@sveltejs/kit@2.47.1(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.40.2)(vite@7.1.10(@types/node@20.19.22)(jiti@2.6.1)(lightningcss@1.30.1)))(svelte@5.40.2)(vite@7.1.10(@types/node@20.19.22)(jiti@2.6.1)(lightningcss@1.30.1)))(svelte@5.40.2):
|
||||||
|
dependencies:
|
||||||
|
dequal: 2.0.3
|
||||||
|
esm-env: 1.2.2
|
||||||
|
lz-string: 1.5.0
|
||||||
|
svelte: 5.40.2
|
||||||
|
optionalDependencies:
|
||||||
|
'@sveltejs/kit': 2.47.1(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.40.2)(vite@7.1.10(@types/node@20.19.22)(jiti@2.6.1)(lightningcss@1.30.1)))(svelte@5.40.2)(vite@7.1.10(@types/node@20.19.22)(jiti@2.6.1)(lightningcss@1.30.1))
|
||||||
|
|
||||||
sade@1.8.1:
|
sade@1.8.1:
|
||||||
dependencies:
|
dependencies:
|
||||||
mri: 1.2.0
|
mri: 1.2.0
|
||||||
@@ -3274,6 +3406,10 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
js-tokens: 9.0.1
|
js-tokens: 9.0.1
|
||||||
|
|
||||||
|
style-to-object@1.0.12:
|
||||||
|
dependencies:
|
||||||
|
inline-style-parser: 0.2.6
|
||||||
|
|
||||||
supports-color@7.2.0:
|
supports-color@7.2.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
has-flag: 4.0.0
|
has-flag: 4.0.0
|
||||||
@@ -3303,6 +3439,15 @@ snapshots:
|
|||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
svelte: 5.40.2
|
svelte: 5.40.2
|
||||||
|
|
||||||
|
svelte-toolbelt@0.10.6(@sveltejs/kit@2.47.1(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.40.2)(vite@7.1.10(@types/node@20.19.22)(jiti@2.6.1)(lightningcss@1.30.1)))(svelte@5.40.2)(vite@7.1.10(@types/node@20.19.22)(jiti@2.6.1)(lightningcss@1.30.1)))(svelte@5.40.2):
|
||||||
|
dependencies:
|
||||||
|
clsx: 2.1.1
|
||||||
|
runed: 0.35.1(@sveltejs/kit@2.47.1(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.40.2)(vite@7.1.10(@types/node@20.19.22)(jiti@2.6.1)(lightningcss@1.30.1)))(svelte@5.40.2)(vite@7.1.10(@types/node@20.19.22)(jiti@2.6.1)(lightningcss@1.30.1)))(svelte@5.40.2)
|
||||||
|
style-to-object: 1.0.12
|
||||||
|
svelte: 5.40.2
|
||||||
|
transitivePeerDependencies:
|
||||||
|
- '@sveltejs/kit'
|
||||||
|
|
||||||
svelte@5.40.2:
|
svelte@5.40.2:
|
||||||
dependencies:
|
dependencies:
|
||||||
'@jridgewell/remapping': 2.3.5
|
'@jridgewell/remapping': 2.3.5
|
||||||
@@ -3320,6 +3465,16 @@ snapshots:
|
|||||||
magic-string: 0.30.19
|
magic-string: 0.30.19
|
||||||
zimmerframe: 1.1.4
|
zimmerframe: 1.1.4
|
||||||
|
|
||||||
|
tabbable@6.3.0: {}
|
||||||
|
|
||||||
|
tailwind-merge@3.3.1: {}
|
||||||
|
|
||||||
|
tailwind-variants@3.1.1(tailwind-merge@3.3.1)(tailwindcss@4.1.14):
|
||||||
|
dependencies:
|
||||||
|
tailwindcss: 4.1.14
|
||||||
|
optionalDependencies:
|
||||||
|
tailwind-merge: 3.3.1
|
||||||
|
|
||||||
tailwindcss@4.1.14: {}
|
tailwindcss@4.1.14: {}
|
||||||
|
|
||||||
tapable@2.3.0: {}
|
tapable@2.3.0: {}
|
||||||
@@ -3357,6 +3512,10 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
typescript: 5.9.3
|
typescript: 5.9.3
|
||||||
|
|
||||||
|
tslib@2.8.1: {}
|
||||||
|
|
||||||
|
tw-animate-css@1.4.0: {}
|
||||||
|
|
||||||
type-check@0.4.0:
|
type-check@0.4.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
prelude-ls: 1.2.1
|
prelude-ls: 1.2.1
|
||||||
|
|||||||
@@ -1,3 +1,123 @@
|
|||||||
@import 'tailwindcss';
|
@import 'tailwindcss';
|
||||||
|
|
||||||
@plugin '@tailwindcss/forms';
|
@plugin '@tailwindcss/forms';
|
||||||
@plugin '@tailwindcss/typography';
|
@plugin '@tailwindcss/typography';
|
||||||
|
|
||||||
|
@custom-variant dark (&:is(.dark *));
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--radius: 0.65rem;
|
||||||
|
--background: oklch(1 0 0);
|
||||||
|
--foreground: oklch(0.141 0.005 285.823);
|
||||||
|
--card: oklch(1 0 0);
|
||||||
|
--card-foreground: oklch(0.141 0.005 285.823);
|
||||||
|
--popover: oklch(1 0 0);
|
||||||
|
--popover-foreground: oklch(0.141 0.005 285.823);
|
||||||
|
--primary: oklch(0.623 0.214 259.815);
|
||||||
|
--primary-foreground: oklch(0.97 0.014 254.604);
|
||||||
|
--secondary: oklch(0.967 0.001 286.375);
|
||||||
|
--secondary-foreground: oklch(0.21 0.006 285.885);
|
||||||
|
--muted: oklch(0.967 0.001 286.375);
|
||||||
|
--muted-foreground: oklch(0.552 0.016 285.938);
|
||||||
|
--accent: oklch(0.967 0.001 286.375);
|
||||||
|
--accent-foreground: oklch(0.21 0.006 285.885);
|
||||||
|
--destructive: oklch(0.577 0.245 27.325);
|
||||||
|
--border: oklch(0.92 0.004 286.32);
|
||||||
|
--input: oklch(0.92 0.004 286.32);
|
||||||
|
--ring: oklch(0.623 0.214 259.815);
|
||||||
|
--chart-1: oklch(0.646 0.222 41.116);
|
||||||
|
--chart-2: oklch(0.6 0.118 184.704);
|
||||||
|
--chart-3: oklch(0.398 0.07 227.392);
|
||||||
|
--chart-4: oklch(0.828 0.189 84.429);
|
||||||
|
--chart-5: oklch(0.769 0.188 70.08);
|
||||||
|
--sidebar: oklch(0.985 0 0);
|
||||||
|
--sidebar-foreground: oklch(0.141 0.005 285.823);
|
||||||
|
--sidebar-primary: oklch(0.623 0.214 259.815);
|
||||||
|
--sidebar-primary-foreground: oklch(0.97 0.014 254.604);
|
||||||
|
--sidebar-accent: oklch(0.967 0.001 286.375);
|
||||||
|
--sidebar-accent-foreground: oklch(0.21 0.006 285.885);
|
||||||
|
--sidebar-border: oklch(0.92 0.004 286.32);
|
||||||
|
--sidebar-ring: oklch(0.623 0.214 259.815);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark {
|
||||||
|
--background: oklch(0.141 0.005 285.823);
|
||||||
|
--foreground: oklch(0.985 0 0);
|
||||||
|
--card: oklch(0.21 0.006 285.885);
|
||||||
|
--card-foreground: oklch(0.985 0 0);
|
||||||
|
--popover: oklch(0.21 0.006 285.885);
|
||||||
|
--popover-foreground: oklch(0.985 0 0);
|
||||||
|
--primary: oklch(0.546 0.245 262.881);
|
||||||
|
--primary-foreground: oklch(0.379 0.146 265.522);
|
||||||
|
--secondary: oklch(0.274 0.006 286.033);
|
||||||
|
--secondary-foreground: oklch(0.985 0 0);
|
||||||
|
--muted: oklch(0.274 0.006 286.033);
|
||||||
|
--muted-foreground: oklch(0.705 0.015 286.067);
|
||||||
|
--accent: oklch(0.274 0.006 286.033);
|
||||||
|
--accent-foreground: oklch(0.985 0 0);
|
||||||
|
--destructive: oklch(0.704 0.191 22.216);
|
||||||
|
--border: oklch(1 0 0 / 10%);
|
||||||
|
--input: oklch(1 0 0 / 15%);
|
||||||
|
--ring: oklch(0.488 0.243 264.376);
|
||||||
|
--chart-1: oklch(0.488 0.243 264.376);
|
||||||
|
--chart-2: oklch(0.696 0.17 162.48);
|
||||||
|
--chart-3: oklch(0.769 0.188 70.08);
|
||||||
|
--chart-4: oklch(0.627 0.265 303.9);
|
||||||
|
--chart-5: oklch(0.645 0.246 16.439);
|
||||||
|
--sidebar: oklch(0.21 0.006 285.885);
|
||||||
|
--sidebar-foreground: oklch(0.985 0 0);
|
||||||
|
--sidebar-primary: oklch(0.546 0.245 262.881);
|
||||||
|
--sidebar-primary-foreground: oklch(0.379 0.146 265.522);
|
||||||
|
--sidebar-accent: oklch(0.274 0.006 286.033);
|
||||||
|
--sidebar-accent-foreground: oklch(0.985 0 0);
|
||||||
|
--sidebar-border: oklch(1 0 0 / 10%);
|
||||||
|
--sidebar-ring: oklch(0.488 0.243 264.376);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@theme inline {
|
||||||
|
--radius-sm: calc(var(--radius) - 4px);
|
||||||
|
--radius-md: calc(var(--radius) - 2px);
|
||||||
|
--radius-lg: var(--radius);
|
||||||
|
--radius-xl: calc(var(--radius) + 4px);
|
||||||
|
--color-background: var(--background);
|
||||||
|
--color-foreground: var(--foreground);
|
||||||
|
--color-card: var(--card);
|
||||||
|
--color-card-foreground: var(--card-foreground);
|
||||||
|
--color-popover: var(--popover);
|
||||||
|
--color-popover-foreground: var(--popover-foreground);
|
||||||
|
--color-primary: var(--primary);
|
||||||
|
--color-primary-foreground: var(--primary-foreground);
|
||||||
|
--color-secondary: var(--secondary);
|
||||||
|
--color-secondary-foreground: var(--secondary-foreground);
|
||||||
|
--color-muted: var(--muted);
|
||||||
|
--color-muted-foreground: var(--muted-foreground);
|
||||||
|
--color-accent: var(--accent);
|
||||||
|
--color-accent-foreground: var(--accent-foreground);
|
||||||
|
--color-destructive: var(--destructive);
|
||||||
|
--color-border: var(--border);
|
||||||
|
--color-input: var(--input);
|
||||||
|
--color-ring: var(--ring);
|
||||||
|
--color-chart-1: var(--chart-1);
|
||||||
|
--color-chart-2: var(--chart-2);
|
||||||
|
--color-chart-3: var(--chart-3);
|
||||||
|
--color-chart-4: var(--chart-4);
|
||||||
|
--color-chart-5: var(--chart-5);
|
||||||
|
--color-sidebar: var(--sidebar);
|
||||||
|
--color-sidebar-foreground: var(--sidebar-foreground);
|
||||||
|
--color-sidebar-primary: var(--sidebar-primary);
|
||||||
|
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
|
||||||
|
--color-sidebar-accent: var(--sidebar-accent);
|
||||||
|
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
||||||
|
--color-sidebar-border: var(--sidebar-border);
|
||||||
|
--color-sidebar-ring: var(--sidebar-ring);
|
||||||
|
}
|
||||||
|
|
||||||
|
@layer base {
|
||||||
|
* {
|
||||||
|
@apply border-border outline-ring/50;
|
||||||
|
}
|
||||||
|
body {
|
||||||
|
@apply bg-background text-foreground;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
5
frontend/src/app.d.ts
vendored
5
frontend/src/app.d.ts
vendored
@@ -3,7 +3,10 @@
|
|||||||
declare global {
|
declare global {
|
||||||
namespace App {
|
namespace App {
|
||||||
// interface Error {}
|
// interface Error {}
|
||||||
// interface Locals {}
|
interface Locals {
|
||||||
|
token: string | null;
|
||||||
|
isAuthenticated: boolean;
|
||||||
|
}
|
||||||
// interface PageData {}
|
// interface PageData {}
|
||||||
// interface PageState {}
|
// interface PageState {}
|
||||||
// interface Platform {}
|
// interface Platform {}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import type { Handle } from '@sveltejs/kit';
|
import type { Handle } from '@sveltejs/kit';
|
||||||
import { paraglideMiddleware } from '$lib/paraglide/server';
|
import { paraglideMiddleware } from '$lib/paraglide/server';
|
||||||
|
import { sequence } from '@sveltejs/kit/hooks';
|
||||||
|
|
||||||
const handleParaglide: Handle = ({ event, resolve }) => paraglideMiddleware(event.request, ({ request, locale }) => {
|
const handleParaglide: Handle = ({ event, resolve }) => paraglideMiddleware(event.request, ({ request, locale }) => {
|
||||||
event.request = request;
|
event.request = request;
|
||||||
@@ -9,4 +10,15 @@ const handleParaglide: Handle = ({ event, resolve }) => paraglideMiddleware(even
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
export const handle: Handle = handleParaglide;
|
const handleAuth: Handle = async ({ event, resolve }) => {
|
||||||
|
// Obtener el token de las cookies
|
||||||
|
const token = event.cookies.get('access_token');
|
||||||
|
|
||||||
|
// Agregar el token a los locals para que esté disponible en toda la app
|
||||||
|
event.locals.token = token || null;
|
||||||
|
event.locals.isAuthenticated = !!token;
|
||||||
|
|
||||||
|
return resolve(event);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const handle: Handle = sequence(handleAuth, handleParaglide);
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
*/
|
*/
|
||||||
import { getToken } from './auth';
|
import { getToken } from './auth';
|
||||||
|
|
||||||
const API_BASE_URL = import.meta.env.PUBLIC_API_URL;
|
const API_BASE_URL = import.meta.env.VITE_API_URL;
|
||||||
|
|
||||||
export interface ApiResponse<T = any> {
|
export interface ApiResponse<T = any> {
|
||||||
data?: T;
|
data?: T;
|
||||||
@@ -49,7 +49,6 @@ async function fetchApi<T = any>(
|
|||||||
status: response.status
|
status: response.status
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error en fetchApi:', error);
|
|
||||||
return {
|
return {
|
||||||
error: 'Error de conexión con el servidor',
|
error: 'Error de conexión con el servidor',
|
||||||
status: 0
|
status: 0
|
||||||
@@ -77,8 +76,11 @@ export const api = {
|
|||||||
|
|
||||||
// Endpoints específicos
|
// Endpoints específicos
|
||||||
auth: {
|
auth: {
|
||||||
|
login: (credentials: { username: string; password: string; tenant_slug: string }) =>
|
||||||
|
api.post('/v1/auth/login', credentials),
|
||||||
|
logout: (data: { refresh_token: string }) => api.post('/v1/auth/logout', data),
|
||||||
me: () => api.get('/v1/auth/me'),
|
me: () => api.get('/v1/auth/me'),
|
||||||
health: () => api.get('//health')
|
health: () => api.get('/health')
|
||||||
},
|
},
|
||||||
|
|
||||||
tenants: {
|
tenants: {
|
||||||
|
|||||||
@@ -24,14 +24,48 @@ export interface AuthState {
|
|||||||
|
|
||||||
// Configuración de Keycloak
|
// Configuración de Keycloak
|
||||||
const keycloakConfig = {
|
const keycloakConfig = {
|
||||||
url: import.meta.env.PUBLIC_KEYCLOAK_URL,
|
url: import.meta.env.VITE_KEYCLOAK_URL,
|
||||||
realm: import.meta.env.PUBLIC_KEYCLOAK_REALM,
|
realm: import.meta.env.VITE_KEYCLOAK_REALM,
|
||||||
clientId: import.meta.env.PUBLIC_KEYCLOAK_CLIENT_ID
|
clientId: import.meta.env.VITE_KEYCLOAK_CLIENT_ID
|
||||||
};
|
};
|
||||||
|
|
||||||
// Instancia de Keycloak
|
// Instancia de Keycloak
|
||||||
let keycloakInstance: Keycloak | null = null;
|
let keycloakInstance: Keycloak | null = null;
|
||||||
|
|
||||||
|
// Helper para obtener cookies
|
||||||
|
const getCookie = (name: string): string | null => {
|
||||||
|
if (!browser) return null;
|
||||||
|
const value = `; ${document.cookie}`;
|
||||||
|
const parts = value.split(`; ${name}=`);
|
||||||
|
if (parts.length === 2) return parts.pop()?.split(';').shift() || null;
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Helper para establecer cookies con las opciones correctas según el entorno
|
||||||
|
const setCookie = (name: string, value: string, days: number = 7) => {
|
||||||
|
if (!browser) return;
|
||||||
|
const expirationDate = new Date();
|
||||||
|
expirationDate.setDate(expirationDate.getDate() + days);
|
||||||
|
|
||||||
|
// En desarrollo (localhost), no usar Secure flag
|
||||||
|
const isSecure = window.location.protocol === 'https:';
|
||||||
|
const secureFlag = isSecure ? '; Secure' : '';
|
||||||
|
|
||||||
|
const cookieString = `${name}=${value}; path=/; expires=${expirationDate.toUTCString()}; SameSite=Lax${secureFlag}`;
|
||||||
|
document.cookie = cookieString;
|
||||||
|
|
||||||
|
// Verificar que se estableció
|
||||||
|
const verification = getCookie(name);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Helper para eliminar cookies
|
||||||
|
const deleteCookie = (name: string) => {
|
||||||
|
if (!browser) return;
|
||||||
|
const isSecure = window.location.protocol === 'https:';
|
||||||
|
const secureFlag = isSecure ? '; Secure' : '';
|
||||||
|
document.cookie = `${name}=; path=/; expires=Thu, 01 Jan 1970 00:00:00 UTC; SameSite=Lax${secureFlag}`;
|
||||||
|
};
|
||||||
|
|
||||||
// Store de autenticación
|
// Store de autenticación
|
||||||
const createAuthStore = () => {
|
const createAuthStore = () => {
|
||||||
const { subscribe, set, update } = writable<AuthState>({
|
const { subscribe, set, update } = writable<AuthState>({
|
||||||
@@ -87,6 +121,13 @@ export const initAuth = async (): Promise<boolean> => {
|
|||||||
if (token) {
|
if (token) {
|
||||||
authStore.setToken(token);
|
authStore.setToken(token);
|
||||||
authStore.setAuthenticated(true);
|
authStore.setAuthenticated(true);
|
||||||
|
|
||||||
|
// Sincronizar con cookies si no existe
|
||||||
|
const cookieToken = getCookie('access_token');
|
||||||
|
if (!cookieToken) {
|
||||||
|
setCookie('access_token', token);
|
||||||
|
}
|
||||||
|
|
||||||
await loadUserInfo(token);
|
await loadUserInfo(token);
|
||||||
authStore.setLoading(false);
|
authStore.setLoading(false);
|
||||||
return true;
|
return true;
|
||||||
@@ -179,7 +220,6 @@ const setupTokenRefresh = () => {
|
|||||||
?.updateToken(70)
|
?.updateToken(70)
|
||||||
.then((refreshed) => {
|
.then((refreshed) => {
|
||||||
if (refreshed) {
|
if (refreshed) {
|
||||||
console.log('Token refrescado');
|
|
||||||
authStore.setToken(keycloakInstance?.token || null);
|
authStore.setToken(keycloakInstance?.token || null);
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -212,6 +252,9 @@ export const loginWithKeycloak = async (tenantSlug?: string) => {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Inicia sesión con credenciales (username/password)
|
* Inicia sesión con credenciales (username/password)
|
||||||
|
* Nota: Esta función ya no se usa directamente desde el login form,
|
||||||
|
* el login ahora se hace mediante form actions del servidor.
|
||||||
|
* Se mantiene para compatibilidad con SSO y otros flujos.
|
||||||
*/
|
*/
|
||||||
export const login = async (credentials: {
|
export const login = async (credentials: {
|
||||||
username: string;
|
username: string;
|
||||||
@@ -219,47 +262,42 @@ export const login = async (credentials: {
|
|||||||
tenant_slug: string;
|
tenant_slug: string;
|
||||||
}): Promise<{ success: boolean; error?: string; data?: any }> => {
|
}): Promise<{ success: boolean; error?: string; data?: any }> => {
|
||||||
try {
|
try {
|
||||||
const API_BASE_URL = import.meta.env.PUBLIC_API_URL || 'http://localhost:8000/api/';
|
// Usar la API centralizada
|
||||||
// Asegurar que la URL termina con /
|
const { api } = await import('./api');
|
||||||
const baseUrl = API_BASE_URL.endsWith('/') ? API_BASE_URL : `${API_BASE_URL}/`;
|
const response = await api.auth.login(credentials);
|
||||||
|
|
||||||
const response = await fetch(`${baseUrl}v1/auth/login`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json'
|
|
||||||
},
|
|
||||||
body: JSON.stringify(credentials)
|
|
||||||
});
|
|
||||||
|
|
||||||
const data = await response.json();
|
// Si hay error en la respuesta
|
||||||
|
if (response.error) {
|
||||||
if (!response.ok) {
|
|
||||||
return {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
error: data.detail || 'Error de autenticación'
|
error: response.error
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Guardar tokens y actualizar estado
|
// Guardar tokens y actualizar estado
|
||||||
if (data.access_token) {
|
const loginData = response.data;
|
||||||
authStore.setToken(data.access_token);
|
if (loginData?.access_token) {
|
||||||
|
authStore.setToken(loginData.access_token);
|
||||||
authStore.setAuthenticated(true);
|
authStore.setAuthenticated(true);
|
||||||
|
|
||||||
// Guardar también en localStorage para persistencia
|
// Guardar también en localStorage para persistencia
|
||||||
if (browser) {
|
if (browser) {
|
||||||
localStorage.setItem('access_token', data.access_token);
|
localStorage.setItem('access_token', loginData.access_token);
|
||||||
if (data.refresh_token) {
|
if (loginData.refresh_token) {
|
||||||
localStorage.setItem('refresh_token', data.refresh_token);
|
localStorage.setItem('refresh_token', loginData.refresh_token);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Guardar en cookies para que el servidor pueda acceder
|
||||||
|
setCookie('access_token', loginData.access_token);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Cargar información del usuario
|
// Cargar información del usuario
|
||||||
await loadUserInfo(data.access_token);
|
await loadUserInfo(loginData.access_token);
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
data
|
data: loginData
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error en login:', error);
|
console.error('Error en login:', error);
|
||||||
@@ -275,18 +313,15 @@ export const login = async (credentials: {
|
|||||||
*/
|
*/
|
||||||
const loadUserInfo = async (token: string) => {
|
const loadUserInfo = async (token: string) => {
|
||||||
try {
|
try {
|
||||||
const API_BASE_URL = import.meta.env.PUBLIC_API_URL || 'http://localhost:8000/api/';
|
// Guardar temporalmente el token para que api.ts lo use
|
||||||
// Asegurar que la URL termina con /
|
authStore.setToken(token);
|
||||||
const baseUrl = API_BASE_URL.endsWith('/') ? API_BASE_URL : `${API_BASE_URL}/`;
|
|
||||||
|
|
||||||
const response = await fetch(`${baseUrl}v1/auth/me`, {
|
// Usar la API centralizada
|
||||||
headers: {
|
const { api } = await import('./api');
|
||||||
'Authorization': `Bearer ${token}`
|
const response = await api.auth.me();
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
if (response.ok) {
|
if (response.data) {
|
||||||
const data = await response.json();
|
const data = response.data;
|
||||||
const user: User = {
|
const user: User = {
|
||||||
id: data.sub || '',
|
id: data.sub || '',
|
||||||
username: data.preferred_username || data.username || '',
|
username: data.preferred_username || data.username || '',
|
||||||
@@ -306,24 +341,48 @@ const loadUserInfo = async (token: string) => {
|
|||||||
* Cierra sesión
|
* Cierra sesión
|
||||||
*/
|
*/
|
||||||
export const logout = async () => {
|
export const logout = async () => {
|
||||||
// Limpiar estado local
|
if (!browser) return;
|
||||||
authStore.reset();
|
|
||||||
|
try {
|
||||||
if (browser) {
|
// Obtener el refresh token si existe
|
||||||
|
const refreshToken = localStorage.getItem('refresh_token');
|
||||||
|
|
||||||
|
// Si hay refresh token, intentar invalidarlo en el backend
|
||||||
|
if (refreshToken) {
|
||||||
|
try {
|
||||||
|
const { api } = await import('./api');
|
||||||
|
await api.auth.logout({ refresh_token: refreshToken });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error al invalidar refresh token:', error);
|
||||||
|
// Continuar con el logout aunque falle
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Limpiar estado local
|
||||||
|
authStore.reset();
|
||||||
localStorage.removeItem('access_token');
|
localStorage.removeItem('access_token');
|
||||||
localStorage.removeItem('refresh_token');
|
localStorage.removeItem('refresh_token');
|
||||||
}
|
|
||||||
|
// Si hay instancia de Keycloak, hacer logout de Keycloak
|
||||||
// Si hay instancia de Keycloak, hacer logout de Keycloak
|
if (keycloakInstance?.authenticated) {
|
||||||
if (keycloakInstance?.authenticated) {
|
await keycloakInstance.logout({
|
||||||
await keycloakInstance.logout({
|
redirectUri: window.location.origin + '/login'
|
||||||
redirectUri: window.location.origin
|
});
|
||||||
});
|
return;
|
||||||
} else {
|
|
||||||
// Redirigir al login
|
|
||||||
if (browser) {
|
|
||||||
window.location.href = '/login';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Llamar al endpoint del servidor para limpiar cookies de SvelteKit
|
||||||
|
// Usar un formulario para hacer POST y permitir la redirección
|
||||||
|
const form = document.createElement('form');
|
||||||
|
form.method = 'POST';
|
||||||
|
form.action = '/logout';
|
||||||
|
document.body.appendChild(form);
|
||||||
|
form.submit();
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error durante logout:', error);
|
||||||
|
// Asegurar que se redirija al login aunque haya error
|
||||||
|
window.location.href = '/login';
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
194
frontend/src/lib/components/login-form.svelte
Normal file
194
frontend/src/lib/components/login-form.svelte
Normal file
@@ -0,0 +1,194 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import * as Card from "$lib/components/ui/card/index.js";
|
||||||
|
import {
|
||||||
|
FieldGroup,
|
||||||
|
Field,
|
||||||
|
FieldLabel,
|
||||||
|
FieldDescription,
|
||||||
|
FieldSeparator,
|
||||||
|
} from "$lib/components/ui/field/index.js";
|
||||||
|
import { Input } from "$lib/components/ui/input/index.js";
|
||||||
|
import { Button } from "$lib/components/ui/button/index.js";
|
||||||
|
import { cn } from "$lib/utils.js";
|
||||||
|
import type { HTMLAttributes } from "svelte/elements";
|
||||||
|
import { page } from '$app/stores';
|
||||||
|
import { enhance } from '$app/forms';
|
||||||
|
import { loginWithProvider } from '$lib/sso';
|
||||||
|
|
||||||
|
let { class: className, ...restProps }: HTMLAttributes<HTMLDivElement> = $props();
|
||||||
|
|
||||||
|
const id = $props.id();
|
||||||
|
|
||||||
|
let username = $state('demo');
|
||||||
|
let password = $state('demo123');
|
||||||
|
let tenantSlug = $state('aduanasoft');
|
||||||
|
let loading = $state(false);
|
||||||
|
|
||||||
|
// Obtener el error del servidor si existe
|
||||||
|
const error = $derived($page.form?.error || '');
|
||||||
|
|
||||||
|
// Función para limpiar cookies del cliente
|
||||||
|
function clearClientCookies() {
|
||||||
|
if (typeof localStorage !== 'undefined') {
|
||||||
|
localStorage.removeItem('access_token');
|
||||||
|
localStorage.removeItem('refresh_token');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof document !== 'undefined') {
|
||||||
|
const isSecure = window.location.protocol === 'https:';
|
||||||
|
const secureFlag = isSecure ? '; Secure' : '';
|
||||||
|
document.cookie = `access_token=; path=/; expires=Thu, 01 Jan 1970 00:00:00 UTC; SameSite=Lax${secureFlag}`;
|
||||||
|
console.log('🧹 Cookies del cliente limpiadas');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleMicrosoftLogin() {
|
||||||
|
// Guardar el tenant_slug en localStorage para recuperarlo después del callback
|
||||||
|
if (tenantSlug) {
|
||||||
|
localStorage.setItem('pending_tenant_slug', tenantSlug);
|
||||||
|
}
|
||||||
|
loginWithProvider('microsoft');
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleGoogleLogin() {
|
||||||
|
// Guardar el tenant_slug en localStorage para recuperarlo después del callback
|
||||||
|
if (tenantSlug) {
|
||||||
|
localStorage.setItem('pending_tenant_slug', tenantSlug);
|
||||||
|
}
|
||||||
|
loginWithProvider('google');
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleAppleLogin() {
|
||||||
|
// Apple no está configurado por defecto en Keycloak,
|
||||||
|
// pero puedes agregarlo siguiendo el mismo patrón
|
||||||
|
alert('Apple SSO no está configurado aún');
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class={cn("flex flex-col gap-6", className)} {...restProps}>
|
||||||
|
<Card.Root class="overflow-hidden p-0">
|
||||||
|
<Card.Content class="grid p-0 md:grid-cols-2">
|
||||||
|
<form
|
||||||
|
class="p-6 md:p-8"
|
||||||
|
method="POST"
|
||||||
|
use:enhance={() => {
|
||||||
|
loading = true;
|
||||||
|
return async ({ update, result }) => {
|
||||||
|
await update();
|
||||||
|
loading = false;
|
||||||
|
|
||||||
|
// Si hay un error, limpiar cookies del cliente
|
||||||
|
if (result.type === 'failure') {
|
||||||
|
clearClientCookies();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<FieldGroup>
|
||||||
|
<div class="flex flex-col items-center gap-2 text-center">
|
||||||
|
<h1 class="text-2xl font-bold">Anexo76</h1>
|
||||||
|
<p class="text-muted-foreground text-balance">
|
||||||
|
Inicia sesión en tu cuenta
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if error}
|
||||||
|
<div class="rounded-md bg-red-50 p-4 text-sm text-red-800">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<Field>
|
||||||
|
<FieldLabel for="tenant-{id}">Tenant</FieldLabel>
|
||||||
|
<Input
|
||||||
|
id="tenant-{id}"
|
||||||
|
name="tenant_slug"
|
||||||
|
type="text"
|
||||||
|
placeholder="aduanasoft"
|
||||||
|
bind:value={tenantSlug}
|
||||||
|
required
|
||||||
|
disabled={loading}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<Field>
|
||||||
|
<FieldLabel for="username-{id}">Usuario</FieldLabel>
|
||||||
|
<Input
|
||||||
|
id="username-{id}"
|
||||||
|
name="username"
|
||||||
|
type="text"
|
||||||
|
placeholder="demo"
|
||||||
|
bind:value={username}
|
||||||
|
required
|
||||||
|
disabled={loading}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<Field>
|
||||||
|
<div class="flex items-center">
|
||||||
|
<FieldLabel for="password-{id}">Contraseña</FieldLabel>
|
||||||
|
<a href="##" class="ml-auto text-sm underline-offset-2 hover:underline">
|
||||||
|
¿Olvidaste tu contraseña?
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<Input
|
||||||
|
id="password-{id}"
|
||||||
|
name="password"
|
||||||
|
type="password"
|
||||||
|
bind:value={password}
|
||||||
|
required
|
||||||
|
disabled={loading}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<Field>
|
||||||
|
<Button type="submit" disabled={loading}>
|
||||||
|
{loading ? 'Iniciando sesión...' : 'Iniciar sesión'}
|
||||||
|
</Button>
|
||||||
|
</Field>
|
||||||
|
<FieldSeparator class="*:data-[slot=field-separator-content]:bg-card">
|
||||||
|
O continua con
|
||||||
|
</FieldSeparator>
|
||||||
|
<Field class="grid grid-cols-3 gap-4">
|
||||||
|
<Button variant="outline" type="button" onclick={handleAppleLogin} disabled={loading}>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
|
||||||
|
<path
|
||||||
|
d="M12.152 6.896c-.948 0-2.415-1.078-3.96-1.04-2.04.027-3.91 1.183-4.961 3.014-2.117 3.675-.546 9.103 1.519 12.09 1.013 1.454 2.208 3.09 3.792 3.039 1.52-.065 2.09-.987 3.935-.987 1.831 0 2.35.987 3.96.948 1.637-.026 2.676-1.48 3.676-2.948 1.156-1.688 1.636-3.325 1.662-3.415-.039-.013-3.182-1.221-3.22-4.857-.026-3.04 2.48-4.494 2.597-4.559-1.429-2.09-3.623-2.324-4.39-2.376-2-.156-3.675 1.09-4.61 1.09zM15.53 3.83c.843-1.012 1.4-2.427 1.245-3.83-1.207.052-2.662.805-3.532 1.818-.78.896-1.454 2.338-1.273 3.714 1.338.104 2.715-.688 3.559-1.701"
|
||||||
|
fill="currentColor"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
<span class="sr-only">Login with Apple</span>
|
||||||
|
</Button>
|
||||||
|
<Button variant="outline" type="button" onclick={handleGoogleLogin} disabled={loading}>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
|
||||||
|
<path
|
||||||
|
d="M12.48 10.92v3.28h7.84c-.24 1.84-.853 3.187-1.787 4.133-1.147 1.147-2.933 2.4-6.053 2.4-4.827 0-8.6-3.893-8.6-8.72s3.773-8.72 8.6-8.72c2.6 0 4.507 1.027 5.907 2.347l2.307-2.307C18.747 1.44 16.133 0 12.48 0 5.867 0 .307 5.387.307 12s5.56 12 12.173 12c3.573 0 6.267-1.173 8.373-3.36 2.16-2.16 2.84-5.213 2.84-7.667 0-.76-.053-1.467-.173-2.053H12.48z"
|
||||||
|
fill="currentColor"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
<span class="sr-only">Login with Google</span>
|
||||||
|
</Button>
|
||||||
|
<Button variant="outline" type="button" onclick={handleMicrosoftLogin} disabled={loading}>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
|
||||||
|
<path d="M11.4 24H0V12.6h11.4V24zM24 24H12.6V12.6H24V24zM11.4 11.4H0V0h11.4v11.4zm12.6 0H12.6V0H24v11.4z" fill="currentColor"/>
|
||||||
|
</svg>
|
||||||
|
<span class="sr-only">Login with Microsoft</span>
|
||||||
|
</Button>
|
||||||
|
</Field>
|
||||||
|
<FieldDescription class="text-center">
|
||||||
|
¿No tienes una cuenta? <a href="/register">Regístrate</a>
|
||||||
|
</FieldDescription>
|
||||||
|
</FieldGroup>
|
||||||
|
</form>
|
||||||
|
<div class="bg-muted relative hidden md:block">
|
||||||
|
<img
|
||||||
|
src="/placeholder.svg"
|
||||||
|
alt="placeholder"
|
||||||
|
class="absolute inset-0 h-full w-full object-cover dark:brightness-[0.2] dark:grayscale"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Card.Content>
|
||||||
|
</Card.Root>
|
||||||
|
<FieldDescription class="px-6 text-center">
|
||||||
|
Al continuar, aceptas nuestros <a href="##">Términos de Servicio</a> y
|
||||||
|
<a href="##">Política de Privacidad</a>.
|
||||||
|
</FieldDescription>
|
||||||
|
</div>
|
||||||
45
frontend/src/lib/components/sidebar/app-sidebar.svelte
Normal file
45
frontend/src/lib/components/sidebar/app-sidebar.svelte
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { getContext } from "svelte";
|
||||||
|
import { sidebarData } from "$lib/components/sidebar/modules";
|
||||||
|
import NavMain from "./nav-main.svelte";
|
||||||
|
import NavProjects from "./nav-projects.svelte";
|
||||||
|
import NavUser from "./nav-user.svelte";
|
||||||
|
import TeamSwitcher from "./team-switcher.svelte";
|
||||||
|
import * as Sidebar from "$lib/components/ui/sidebar/index.js";
|
||||||
|
import type { ComponentProps } from "svelte";
|
||||||
|
|
||||||
|
let {
|
||||||
|
ref = $bindable(null),
|
||||||
|
collapsible = "icon",
|
||||||
|
...restProps
|
||||||
|
}: ComponentProps<typeof Sidebar.Root> = $props();
|
||||||
|
|
||||||
|
// Obtener datos del usuario desde el contexto (viene de Keycloak vía +layout.server.ts)
|
||||||
|
const userData = getContext<any>('user');
|
||||||
|
|
||||||
|
// Combinar los datos estáticos del sidebar con los datos del usuario de Keycloak
|
||||||
|
const data = $derived({
|
||||||
|
...sidebarData,
|
||||||
|
user: userData
|
||||||
|
? {
|
||||||
|
name: userData.name || userData.preferred_username || "",
|
||||||
|
email: userData.email || "",
|
||||||
|
avatar: "/avatars/default.jpg", // Puedes agregar avatar desde Keycloak si está disponible
|
||||||
|
}
|
||||||
|
: sidebarData.user,
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<Sidebar.Root {collapsible} {...restProps}>
|
||||||
|
<Sidebar.Header>
|
||||||
|
<TeamSwitcher teams={data.teams} />
|
||||||
|
</Sidebar.Header>
|
||||||
|
<Sidebar.Content>
|
||||||
|
<NavMain items={data.navMain} />
|
||||||
|
<NavProjects projects={data.projects} />
|
||||||
|
</Sidebar.Content>
|
||||||
|
<Sidebar.Footer>
|
||||||
|
<NavUser user={data.user} />
|
||||||
|
</Sidebar.Footer>
|
||||||
|
<Sidebar.Rail />
|
||||||
|
</Sidebar.Root>
|
||||||
147
frontend/src/lib/components/sidebar/modules.ts
Normal file
147
frontend/src/lib/components/sidebar/modules.ts
Normal file
@@ -0,0 +1,147 @@
|
|||||||
|
import AudioWaveformIcon from "@lucide/svelte/icons/audio-waveform";
|
||||||
|
import BookOpenIcon from "@lucide/svelte/icons/book-open";
|
||||||
|
import BotIcon from "@lucide/svelte/icons/bot";
|
||||||
|
import ChartPieIcon from "@lucide/svelte/icons/chart-pie";
|
||||||
|
import CommandIcon from "@lucide/svelte/icons/command";
|
||||||
|
import FrameIcon from "@lucide/svelte/icons/frame";
|
||||||
|
import GalleryVerticalEndIcon from "@lucide/svelte/icons/gallery-vertical-end";
|
||||||
|
import MapIcon from "@lucide/svelte/icons/map";
|
||||||
|
import Settings2Icon from "@lucide/svelte/icons/settings-2";
|
||||||
|
import SquareTerminalIcon from "@lucide/svelte/icons/square-terminal";
|
||||||
|
|
||||||
|
export interface NavItem {
|
||||||
|
title: string;
|
||||||
|
url: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NavMainItem {
|
||||||
|
title: string;
|
||||||
|
url: string;
|
||||||
|
icon: any;
|
||||||
|
isActive?: boolean;
|
||||||
|
items?: NavItem[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Project {
|
||||||
|
name: string;
|
||||||
|
url: string;
|
||||||
|
icon: any;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Team {
|
||||||
|
name: string;
|
||||||
|
logo: any;
|
||||||
|
plan: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface User {
|
||||||
|
name: string;
|
||||||
|
email: string;
|
||||||
|
avatar: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SidebarData {
|
||||||
|
user: User;
|
||||||
|
teams: Team[];
|
||||||
|
navMain: NavMainItem[];
|
||||||
|
projects: Project[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Datos estáticos del sidebar (los datos de usuario vienen de Keycloak)
|
||||||
|
export const sidebarData: SidebarData = {
|
||||||
|
user: {
|
||||||
|
name: "", // Se llena dinámicamente desde Keycloak
|
||||||
|
email: "", // Se llena dinámicamente desde Keycloak
|
||||||
|
avatar: "/avatars/default.jpg", // Avatar por defecto
|
||||||
|
},
|
||||||
|
teams: [
|
||||||
|
{
|
||||||
|
name: "Anexo76",
|
||||||
|
logo: GalleryVerticalEndIcon,
|
||||||
|
plan: "Enterprise",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
navMain: [
|
||||||
|
{
|
||||||
|
title: "Catalogos Generales",
|
||||||
|
url: "/dashboard",
|
||||||
|
icon: SquareTerminalIcon,
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
title: "",
|
||||||
|
url: "/dashboard/reference_data/code_pedimento_regimens",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Reportes",
|
||||||
|
url: "#",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
isActive: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Inventarios",
|
||||||
|
url: "#",
|
||||||
|
icon: BotIcon,
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
title: "Gestionar Inventarios",
|
||||||
|
url: "#",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Reportes",
|
||||||
|
url: "#",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Pedimentos",
|
||||||
|
url: "#",
|
||||||
|
icon: BookOpenIcon,
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
title: "Nuevo Pedimento",
|
||||||
|
url: "#",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Consultar",
|
||||||
|
url: "#",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Historial",
|
||||||
|
url: "#",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Configuración",
|
||||||
|
url: "#",
|
||||||
|
icon: Settings2Icon,
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
title: "General",
|
||||||
|
url: "#",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Licencia",
|
||||||
|
url: "#",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Usuarios",
|
||||||
|
url: "#",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
projects: [
|
||||||
|
{
|
||||||
|
name: "Reportes",
|
||||||
|
url: "#",
|
||||||
|
icon: ChartPieIcon,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Ayuda",
|
||||||
|
url: "#",
|
||||||
|
icon: FrameIcon,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
64
frontend/src/lib/components/sidebar/nav-main.svelte
Normal file
64
frontend/src/lib/components/sidebar/nav-main.svelte
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import * as Collapsible from "$lib/components/ui/collapsible/index.js";
|
||||||
|
import * as Sidebar from "$lib/components/ui/sidebar/index.js";
|
||||||
|
import ChevronRightIcon from "@lucide/svelte/icons/chevron-right";
|
||||||
|
|
||||||
|
let {
|
||||||
|
items,
|
||||||
|
}: {
|
||||||
|
items: {
|
||||||
|
title: string;
|
||||||
|
url: string;
|
||||||
|
// this should be `Component` after @lucide/svelte updates types
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
icon?: any;
|
||||||
|
isActive?: boolean;
|
||||||
|
items?: {
|
||||||
|
title: string;
|
||||||
|
url: string;
|
||||||
|
}[];
|
||||||
|
}[];
|
||||||
|
} = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<Sidebar.Group>
|
||||||
|
<Sidebar.GroupLabel>Platform</Sidebar.GroupLabel>
|
||||||
|
<Sidebar.Menu>
|
||||||
|
{#each items as item (item.title)}
|
||||||
|
<Collapsible.Root open={item.isActive} class="group/collapsible">
|
||||||
|
{#snippet child({ props })}
|
||||||
|
<Sidebar.MenuItem {...props}>
|
||||||
|
<Collapsible.Trigger>
|
||||||
|
{#snippet child({ props })}
|
||||||
|
<Sidebar.MenuButton {...props} tooltipContent={item.title}>
|
||||||
|
{#if item.icon}
|
||||||
|
<item.icon />
|
||||||
|
{/if}
|
||||||
|
<span>{item.title}</span>
|
||||||
|
<ChevronRightIcon
|
||||||
|
class="ml-auto transition-transform duration-200 group-data-[state=open]/collapsible:rotate-90"
|
||||||
|
/>
|
||||||
|
</Sidebar.MenuButton>
|
||||||
|
{/snippet}
|
||||||
|
</Collapsible.Trigger>
|
||||||
|
<Collapsible.Content>
|
||||||
|
<Sidebar.MenuSub>
|
||||||
|
{#each item.items ?? [] as subItem (subItem.title)}
|
||||||
|
<Sidebar.MenuSubItem>
|
||||||
|
<Sidebar.MenuSubButton>
|
||||||
|
{#snippet child({ props })}
|
||||||
|
<a href={subItem.url} {...props}>
|
||||||
|
<span>{subItem.title}</span>
|
||||||
|
</a>
|
||||||
|
{/snippet}
|
||||||
|
</Sidebar.MenuSubButton>
|
||||||
|
</Sidebar.MenuSubItem>
|
||||||
|
{/each}
|
||||||
|
</Sidebar.MenuSub>
|
||||||
|
</Collapsible.Content>
|
||||||
|
</Sidebar.MenuItem>
|
||||||
|
{/snippet}
|
||||||
|
</Collapsible.Root>
|
||||||
|
{/each}
|
||||||
|
</Sidebar.Menu>
|
||||||
|
</Sidebar.Group>
|
||||||
76
frontend/src/lib/components/sidebar/nav-projects.svelte
Normal file
76
frontend/src/lib/components/sidebar/nav-projects.svelte
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import * as DropdownMenu from "$lib/components/ui/dropdown-menu/index.js";
|
||||||
|
import { useSidebar } from "$lib/components/ui/sidebar/context.svelte.js";
|
||||||
|
import * as Sidebar from "$lib/components/ui/sidebar/index.js";
|
||||||
|
import EllipsisIcon from "@lucide/svelte/icons/ellipsis";
|
||||||
|
import FolderIcon from "@lucide/svelte/icons/folder";
|
||||||
|
import ForwardIcon from "@lucide/svelte/icons/forward";
|
||||||
|
import Trash2Icon from "@lucide/svelte/icons/trash-2";
|
||||||
|
|
||||||
|
let {
|
||||||
|
projects,
|
||||||
|
}: {
|
||||||
|
projects: {
|
||||||
|
name: string;
|
||||||
|
url: string;
|
||||||
|
// This should be `Component` after @lucide/svelte updates types
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
icon: any;
|
||||||
|
}[];
|
||||||
|
} = $props();
|
||||||
|
|
||||||
|
const sidebar = useSidebar();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<Sidebar.Group class="group-data-[collapsible=icon]:hidden">
|
||||||
|
<Sidebar.GroupLabel>Projects</Sidebar.GroupLabel>
|
||||||
|
<Sidebar.Menu>
|
||||||
|
{#each projects as item (item.name)}
|
||||||
|
<Sidebar.MenuItem>
|
||||||
|
<Sidebar.MenuButton>
|
||||||
|
{#snippet child({ props })}
|
||||||
|
<a href={item.url} {...props}>
|
||||||
|
<item.icon />
|
||||||
|
<span>{item.name}</span>
|
||||||
|
</a>
|
||||||
|
{/snippet}
|
||||||
|
</Sidebar.MenuButton>
|
||||||
|
<DropdownMenu.Root>
|
||||||
|
<DropdownMenu.Trigger>
|
||||||
|
{#snippet child({ props })}
|
||||||
|
<Sidebar.MenuAction showOnHover {...props}>
|
||||||
|
<EllipsisIcon />
|
||||||
|
<span class="sr-only">More</span>
|
||||||
|
</Sidebar.MenuAction>
|
||||||
|
{/snippet}
|
||||||
|
</DropdownMenu.Trigger>
|
||||||
|
<DropdownMenu.Content
|
||||||
|
class="w-48 rounded-lg"
|
||||||
|
side={sidebar.isMobile ? "bottom" : "right"}
|
||||||
|
align={sidebar.isMobile ? "end" : "start"}
|
||||||
|
>
|
||||||
|
<DropdownMenu.Item>
|
||||||
|
<FolderIcon class="text-muted-foreground" />
|
||||||
|
<span>View Project</span>
|
||||||
|
</DropdownMenu.Item>
|
||||||
|
<DropdownMenu.Item>
|
||||||
|
<ForwardIcon class="text-muted-foreground" />
|
||||||
|
<span>Share Project</span>
|
||||||
|
</DropdownMenu.Item>
|
||||||
|
<DropdownMenu.Separator />
|
||||||
|
<DropdownMenu.Item>
|
||||||
|
<Trash2Icon class="text-muted-foreground" />
|
||||||
|
<span>Delete Project</span>
|
||||||
|
</DropdownMenu.Item>
|
||||||
|
</DropdownMenu.Content>
|
||||||
|
</DropdownMenu.Root>
|
||||||
|
</Sidebar.MenuItem>
|
||||||
|
{/each}
|
||||||
|
<Sidebar.MenuItem>
|
||||||
|
<Sidebar.MenuButton class="text-sidebar-foreground/70">
|
||||||
|
<EllipsisIcon class="text-sidebar-foreground/70" />
|
||||||
|
<span>More</span>
|
||||||
|
</Sidebar.MenuButton>
|
||||||
|
</Sidebar.MenuItem>
|
||||||
|
</Sidebar.Menu>
|
||||||
|
</Sidebar.Group>
|
||||||
92
frontend/src/lib/components/sidebar/nav-user.svelte
Normal file
92
frontend/src/lib/components/sidebar/nav-user.svelte
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import * as Avatar from "$lib/components/ui/avatar/index.js";
|
||||||
|
import * as DropdownMenu from "$lib/components/ui/dropdown-menu/index.js";
|
||||||
|
import * as Sidebar from "$lib/components/ui/sidebar/index.js";
|
||||||
|
import { useSidebar } from "$lib/components/ui/sidebar/index.js";
|
||||||
|
import BadgeCheckIcon from "@lucide/svelte/icons/badge-check";
|
||||||
|
import BellIcon from "@lucide/svelte/icons/bell";
|
||||||
|
import ChevronsUpDownIcon from "@lucide/svelte/icons/chevrons-up-down";
|
||||||
|
import CreditCardIcon from "@lucide/svelte/icons/credit-card";
|
||||||
|
import LogOutIcon from "@lucide/svelte/icons/log-out";
|
||||||
|
import SparklesIcon from "@lucide/svelte/icons/sparkles";
|
||||||
|
import { logout } from "$lib/auth";
|
||||||
|
|
||||||
|
let { user }: { user: { name: string; email: string; avatar: string } } = $props();
|
||||||
|
const sidebar = useSidebar();
|
||||||
|
|
||||||
|
async function handleLogout() {
|
||||||
|
await logout();
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<Sidebar.Menu>
|
||||||
|
<Sidebar.MenuItem>
|
||||||
|
<DropdownMenu.Root>
|
||||||
|
<DropdownMenu.Trigger>
|
||||||
|
{#snippet child({ props })}
|
||||||
|
<Sidebar.MenuButton
|
||||||
|
size="lg"
|
||||||
|
class="data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground"
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<Avatar.Root class="size-8 rounded-lg">
|
||||||
|
<Avatar.Image src={user.avatar} alt={user.name} />
|
||||||
|
<Avatar.Fallback class="rounded-lg">CN</Avatar.Fallback>
|
||||||
|
</Avatar.Root>
|
||||||
|
<div class="grid flex-1 text-left text-sm leading-tight">
|
||||||
|
<span class="truncate font-medium">{user.name}</span>
|
||||||
|
<span class="truncate text-xs">{user.email}</span>
|
||||||
|
</div>
|
||||||
|
<ChevronsUpDownIcon class="ml-auto size-4" />
|
||||||
|
</Sidebar.MenuButton>
|
||||||
|
{/snippet}
|
||||||
|
</DropdownMenu.Trigger>
|
||||||
|
<DropdownMenu.Content
|
||||||
|
class="w-(--bits-dropdown-menu-anchor-width) min-w-56 rounded-lg"
|
||||||
|
side={sidebar.isMobile ? "bottom" : "right"}
|
||||||
|
align="end"
|
||||||
|
sideOffset={4}
|
||||||
|
>
|
||||||
|
<DropdownMenu.Label class="p-0 font-normal">
|
||||||
|
<div class="flex items-center gap-2 px-1 py-1.5 text-left text-sm">
|
||||||
|
<Avatar.Root class="size-8 rounded-lg">
|
||||||
|
<Avatar.Image src={user.avatar} alt={user.name} />
|
||||||
|
<Avatar.Fallback class="rounded-lg">CN</Avatar.Fallback>
|
||||||
|
</Avatar.Root>
|
||||||
|
<div class="grid flex-1 text-left text-sm leading-tight">
|
||||||
|
<span class="truncate font-medium">{user.name}</span>
|
||||||
|
<span class="truncate text-xs">{user.email}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</DropdownMenu.Label>
|
||||||
|
<DropdownMenu.Separator />
|
||||||
|
<DropdownMenu.Group>
|
||||||
|
<DropdownMenu.Item>
|
||||||
|
<SparklesIcon />
|
||||||
|
Upgrade to Pro
|
||||||
|
</DropdownMenu.Item>
|
||||||
|
</DropdownMenu.Group>
|
||||||
|
<DropdownMenu.Separator />
|
||||||
|
<DropdownMenu.Group>
|
||||||
|
<DropdownMenu.Item>
|
||||||
|
<BadgeCheckIcon />
|
||||||
|
Account
|
||||||
|
</DropdownMenu.Item>
|
||||||
|
<DropdownMenu.Item>
|
||||||
|
<CreditCardIcon />
|
||||||
|
Billing
|
||||||
|
</DropdownMenu.Item>
|
||||||
|
<DropdownMenu.Item>
|
||||||
|
<BellIcon />
|
||||||
|
Notifications
|
||||||
|
</DropdownMenu.Item>
|
||||||
|
</DropdownMenu.Group>
|
||||||
|
<DropdownMenu.Separator />
|
||||||
|
<DropdownMenu.Item onclick={handleLogout}>
|
||||||
|
<LogOutIcon />
|
||||||
|
Log out
|
||||||
|
</DropdownMenu.Item>
|
||||||
|
</DropdownMenu.Content>
|
||||||
|
</DropdownMenu.Root>
|
||||||
|
</Sidebar.MenuItem>
|
||||||
|
</Sidebar.Menu>
|
||||||
69
frontend/src/lib/components/sidebar/team-switcher.svelte
Normal file
69
frontend/src/lib/components/sidebar/team-switcher.svelte
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import * as DropdownMenu from "$lib/components/ui/dropdown-menu/index.js";
|
||||||
|
import * as Sidebar from "$lib/components/ui/sidebar/index.js";
|
||||||
|
import { useSidebar } from "$lib/components/ui/sidebar/index.js";
|
||||||
|
import ChevronsUpDownIcon from "@lucide/svelte/icons/chevrons-up-down";
|
||||||
|
import PlusIcon from "@lucide/svelte/icons/plus";
|
||||||
|
|
||||||
|
// This should be `Component` after @lucide/svelte updates types
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
let { teams }: { teams: { name: string; logo: any; plan: string }[] } = $props();
|
||||||
|
const sidebar = useSidebar();
|
||||||
|
|
||||||
|
let activeTeam = $state(teams[0]);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<Sidebar.Menu>
|
||||||
|
<Sidebar.MenuItem>
|
||||||
|
<DropdownMenu.Root>
|
||||||
|
<DropdownMenu.Trigger>
|
||||||
|
{#snippet child({ props })}
|
||||||
|
<Sidebar.MenuButton
|
||||||
|
{...props}
|
||||||
|
size="lg"
|
||||||
|
class="data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="bg-sidebar-primary text-sidebar-primary-foreground flex aspect-square size-8 items-center justify-center rounded-lg"
|
||||||
|
>
|
||||||
|
<activeTeam.logo class="size-4" />
|
||||||
|
</div>
|
||||||
|
<div class="grid flex-1 text-left text-sm leading-tight">
|
||||||
|
<span class="truncate font-medium">
|
||||||
|
{activeTeam.name}
|
||||||
|
</span>
|
||||||
|
<span class="truncate text-xs">{activeTeam.plan}</span>
|
||||||
|
</div>
|
||||||
|
<ChevronsUpDownIcon class="ml-auto" />
|
||||||
|
</Sidebar.MenuButton>
|
||||||
|
{/snippet}
|
||||||
|
</DropdownMenu.Trigger>
|
||||||
|
<DropdownMenu.Content
|
||||||
|
class="w-(--bits-dropdown-menu-anchor-width) min-w-56 rounded-lg"
|
||||||
|
align="start"
|
||||||
|
side={sidebar.isMobile ? "bottom" : "right"}
|
||||||
|
sideOffset={4}
|
||||||
|
>
|
||||||
|
<DropdownMenu.Label class="text-muted-foreground text-xs">Teams</DropdownMenu.Label>
|
||||||
|
{#each teams as team, index (team.name)}
|
||||||
|
<DropdownMenu.Item onSelect={() => (activeTeam = team)} class="gap-2 p-2">
|
||||||
|
<div class="flex size-6 items-center justify-center rounded-md border">
|
||||||
|
<team.logo class="size-3.5 shrink-0" />
|
||||||
|
</div>
|
||||||
|
{team.name}
|
||||||
|
<DropdownMenu.Shortcut>⌘{index + 1}</DropdownMenu.Shortcut>
|
||||||
|
</DropdownMenu.Item>
|
||||||
|
{/each}
|
||||||
|
<DropdownMenu.Separator />
|
||||||
|
<DropdownMenu.Item class="gap-2 p-2">
|
||||||
|
<div
|
||||||
|
class="flex size-6 items-center justify-center rounded-md border bg-transparent"
|
||||||
|
>
|
||||||
|
<PlusIcon class="size-4" />
|
||||||
|
</div>
|
||||||
|
<div class="text-muted-foreground font-medium">Add team</div>
|
||||||
|
</DropdownMenu.Item>
|
||||||
|
</DropdownMenu.Content>
|
||||||
|
</DropdownMenu.Root>
|
||||||
|
</Sidebar.MenuItem>
|
||||||
|
</Sidebar.Menu>
|
||||||
17
frontend/src/lib/components/ui/avatar/avatar-fallback.svelte
Normal file
17
frontend/src/lib/components/ui/avatar/avatar-fallback.svelte
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { Avatar as AvatarPrimitive } from "bits-ui";
|
||||||
|
import { cn } from "$lib/utils.js";
|
||||||
|
|
||||||
|
let {
|
||||||
|
ref = $bindable(null),
|
||||||
|
class: className,
|
||||||
|
...restProps
|
||||||
|
}: AvatarPrimitive.FallbackProps = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<AvatarPrimitive.Fallback
|
||||||
|
bind:ref
|
||||||
|
data-slot="avatar-fallback"
|
||||||
|
class={cn("bg-muted flex size-full items-center justify-center rounded-full", className)}
|
||||||
|
{...restProps}
|
||||||
|
/>
|
||||||
17
frontend/src/lib/components/ui/avatar/avatar-image.svelte
Normal file
17
frontend/src/lib/components/ui/avatar/avatar-image.svelte
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { Avatar as AvatarPrimitive } from "bits-ui";
|
||||||
|
import { cn } from "$lib/utils.js";
|
||||||
|
|
||||||
|
let {
|
||||||
|
ref = $bindable(null),
|
||||||
|
class: className,
|
||||||
|
...restProps
|
||||||
|
}: AvatarPrimitive.ImageProps = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<AvatarPrimitive.Image
|
||||||
|
bind:ref
|
||||||
|
data-slot="avatar-image"
|
||||||
|
class={cn("aspect-square size-full", className)}
|
||||||
|
{...restProps}
|
||||||
|
/>
|
||||||
19
frontend/src/lib/components/ui/avatar/avatar.svelte
Normal file
19
frontend/src/lib/components/ui/avatar/avatar.svelte
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { Avatar as AvatarPrimitive } from "bits-ui";
|
||||||
|
import { cn } from "$lib/utils.js";
|
||||||
|
|
||||||
|
let {
|
||||||
|
ref = $bindable(null),
|
||||||
|
loadingStatus = $bindable("loading"),
|
||||||
|
class: className,
|
||||||
|
...restProps
|
||||||
|
}: AvatarPrimitive.RootProps = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<AvatarPrimitive.Root
|
||||||
|
bind:ref
|
||||||
|
bind:loadingStatus
|
||||||
|
data-slot="avatar"
|
||||||
|
class={cn("relative flex size-8 shrink-0 overflow-hidden rounded-full", className)}
|
||||||
|
{...restProps}
|
||||||
|
/>
|
||||||
13
frontend/src/lib/components/ui/avatar/index.ts
Normal file
13
frontend/src/lib/components/ui/avatar/index.ts
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import Root from "./avatar.svelte";
|
||||||
|
import Image from "./avatar-image.svelte";
|
||||||
|
import Fallback from "./avatar-fallback.svelte";
|
||||||
|
|
||||||
|
export {
|
||||||
|
Root,
|
||||||
|
Image,
|
||||||
|
Fallback,
|
||||||
|
//
|
||||||
|
Root as Avatar,
|
||||||
|
Image as AvatarImage,
|
||||||
|
Fallback as AvatarFallback,
|
||||||
|
};
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import EllipsisIcon from "@lucide/svelte/icons/ellipsis";
|
||||||
|
import type { HTMLAttributes } from "svelte/elements";
|
||||||
|
import { cn, type WithElementRef, type WithoutChildren } from "$lib/utils.js";
|
||||||
|
|
||||||
|
let {
|
||||||
|
ref = $bindable(null),
|
||||||
|
class: className,
|
||||||
|
...restProps
|
||||||
|
}: WithoutChildren<WithElementRef<HTMLAttributes<HTMLSpanElement>>> = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<span
|
||||||
|
bind:this={ref}
|
||||||
|
data-slot="breadcrumb-ellipsis"
|
||||||
|
role="presentation"
|
||||||
|
aria-hidden="true"
|
||||||
|
class={cn("flex size-9 items-center justify-center", className)}
|
||||||
|
{...restProps}
|
||||||
|
>
|
||||||
|
<EllipsisIcon class="size-4" />
|
||||||
|
<span class="sr-only">More</span>
|
||||||
|
</span>
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { HTMLLiAttributes } from "svelte/elements";
|
||||||
|
import { cn, type WithElementRef } from "$lib/utils.js";
|
||||||
|
|
||||||
|
let {
|
||||||
|
ref = $bindable(null),
|
||||||
|
class: className,
|
||||||
|
children,
|
||||||
|
...restProps
|
||||||
|
}: WithElementRef<HTMLLiAttributes> = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<li
|
||||||
|
bind:this={ref}
|
||||||
|
data-slot="breadcrumb-item"
|
||||||
|
class={cn("inline-flex items-center gap-1.5", className)}
|
||||||
|
{...restProps}
|
||||||
|
>
|
||||||
|
{@render children?.()}
|
||||||
|
</li>
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { HTMLAnchorAttributes } from "svelte/elements";
|
||||||
|
import type { Snippet } from "svelte";
|
||||||
|
import { cn, type WithElementRef } from "$lib/utils.js";
|
||||||
|
|
||||||
|
let {
|
||||||
|
ref = $bindable(null),
|
||||||
|
class: className,
|
||||||
|
href = undefined,
|
||||||
|
child,
|
||||||
|
children,
|
||||||
|
...restProps
|
||||||
|
}: WithElementRef<HTMLAnchorAttributes> & {
|
||||||
|
child?: Snippet<[{ props: HTMLAnchorAttributes }]>;
|
||||||
|
} = $props();
|
||||||
|
|
||||||
|
const attrs = $derived({
|
||||||
|
"data-slot": "breadcrumb-link",
|
||||||
|
class: cn("hover:text-foreground transition-colors", className),
|
||||||
|
href,
|
||||||
|
...restProps,
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if child}
|
||||||
|
{@render child({ props: attrs })}
|
||||||
|
{:else}
|
||||||
|
<a bind:this={ref} {...attrs}>
|
||||||
|
{@render children?.()}
|
||||||
|
</a>
|
||||||
|
{/if}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { HTMLOlAttributes } from "svelte/elements";
|
||||||
|
import { cn, type WithElementRef } from "$lib/utils.js";
|
||||||
|
|
||||||
|
let {
|
||||||
|
ref = $bindable(null),
|
||||||
|
class: className,
|
||||||
|
children,
|
||||||
|
...restProps
|
||||||
|
}: WithElementRef<HTMLOlAttributes> = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<ol
|
||||||
|
bind:this={ref}
|
||||||
|
data-slot="breadcrumb-list"
|
||||||
|
class={cn(
|
||||||
|
"text-muted-foreground flex flex-wrap items-center gap-1.5 break-words text-sm sm:gap-2.5",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...restProps}
|
||||||
|
>
|
||||||
|
{@render children?.()}
|
||||||
|
</ol>
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { HTMLAttributes } from "svelte/elements";
|
||||||
|
import { cn, type WithElementRef } from "$lib/utils.js";
|
||||||
|
|
||||||
|
let {
|
||||||
|
ref = $bindable(null),
|
||||||
|
class: className,
|
||||||
|
children,
|
||||||
|
...restProps
|
||||||
|
}: WithElementRef<HTMLAttributes<HTMLSpanElement>> = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<span
|
||||||
|
bind:this={ref}
|
||||||
|
data-slot="breadcrumb-page"
|
||||||
|
role="link"
|
||||||
|
aria-disabled="true"
|
||||||
|
aria-current="page"
|
||||||
|
class={cn("text-foreground font-normal", className)}
|
||||||
|
{...restProps}
|
||||||
|
>
|
||||||
|
{@render children?.()}
|
||||||
|
</span>
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import ChevronRightIcon from "@lucide/svelte/icons/chevron-right";
|
||||||
|
import { cn, type WithElementRef } from "$lib/utils.js";
|
||||||
|
import type { HTMLLiAttributes } from "svelte/elements";
|
||||||
|
|
||||||
|
let {
|
||||||
|
ref = $bindable(null),
|
||||||
|
class: className,
|
||||||
|
children,
|
||||||
|
...restProps
|
||||||
|
}: WithElementRef<HTMLLiAttributes> = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<li
|
||||||
|
bind:this={ref}
|
||||||
|
data-slot="breadcrumb-separator"
|
||||||
|
role="presentation"
|
||||||
|
aria-hidden="true"
|
||||||
|
class={cn("[&>svg]:size-3.5", className)}
|
||||||
|
{...restProps}
|
||||||
|
>
|
||||||
|
{#if children}
|
||||||
|
{@render children?.()}
|
||||||
|
{:else}
|
||||||
|
<ChevronRightIcon />
|
||||||
|
{/if}
|
||||||
|
</li>
|
||||||
21
frontend/src/lib/components/ui/breadcrumb/breadcrumb.svelte
Normal file
21
frontend/src/lib/components/ui/breadcrumb/breadcrumb.svelte
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { WithElementRef } from "$lib/utils.js";
|
||||||
|
import type { HTMLAttributes } from "svelte/elements";
|
||||||
|
|
||||||
|
let {
|
||||||
|
ref = $bindable(null),
|
||||||
|
class: className,
|
||||||
|
children,
|
||||||
|
...restProps
|
||||||
|
}: WithElementRef<HTMLAttributes<HTMLElement>> = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<nav
|
||||||
|
bind:this={ref}
|
||||||
|
data-slot="breadcrumb"
|
||||||
|
class={className}
|
||||||
|
aria-label="breadcrumb"
|
||||||
|
{...restProps}
|
||||||
|
>
|
||||||
|
{@render children?.()}
|
||||||
|
</nav>
|
||||||
25
frontend/src/lib/components/ui/breadcrumb/index.ts
Normal file
25
frontend/src/lib/components/ui/breadcrumb/index.ts
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
import Root from "./breadcrumb.svelte";
|
||||||
|
import Ellipsis from "./breadcrumb-ellipsis.svelte";
|
||||||
|
import Item from "./breadcrumb-item.svelte";
|
||||||
|
import Separator from "./breadcrumb-separator.svelte";
|
||||||
|
import Link from "./breadcrumb-link.svelte";
|
||||||
|
import List from "./breadcrumb-list.svelte";
|
||||||
|
import Page from "./breadcrumb-page.svelte";
|
||||||
|
|
||||||
|
export {
|
||||||
|
Root,
|
||||||
|
Ellipsis,
|
||||||
|
Item,
|
||||||
|
Separator,
|
||||||
|
Link,
|
||||||
|
List,
|
||||||
|
Page,
|
||||||
|
//
|
||||||
|
Root as Breadcrumb,
|
||||||
|
Ellipsis as BreadcrumbEllipsis,
|
||||||
|
Item as BreadcrumbItem,
|
||||||
|
Separator as BreadcrumbSeparator,
|
||||||
|
Link as BreadcrumbLink,
|
||||||
|
List as BreadcrumbList,
|
||||||
|
Page as BreadcrumbPage,
|
||||||
|
};
|
||||||
82
frontend/src/lib/components/ui/button/button.svelte
Normal file
82
frontend/src/lib/components/ui/button/button.svelte
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
<script lang="ts" module>
|
||||||
|
import { cn, type WithElementRef } from "$lib/utils.js";
|
||||||
|
import type { HTMLAnchorAttributes, HTMLButtonAttributes } from "svelte/elements";
|
||||||
|
import { type VariantProps, tv } from "tailwind-variants";
|
||||||
|
|
||||||
|
export const buttonVariants = tv({
|
||||||
|
base: "focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive inline-flex shrink-0 items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium outline-none transition-all focus-visible:ring-[3px] disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0",
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default: "bg-primary text-primary-foreground shadow-xs hover:bg-primary/90",
|
||||||
|
destructive:
|
||||||
|
"bg-destructive shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60 text-white",
|
||||||
|
outline:
|
||||||
|
"bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50 border",
|
||||||
|
secondary: "bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80",
|
||||||
|
ghost: "hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
|
||||||
|
link: "text-primary underline-offset-4 hover:underline",
|
||||||
|
},
|
||||||
|
size: {
|
||||||
|
default: "h-9 px-4 py-2 has-[>svg]:px-3",
|
||||||
|
sm: "h-8 gap-1.5 rounded-md px-3 has-[>svg]:px-2.5",
|
||||||
|
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
|
||||||
|
icon: "size-9",
|
||||||
|
"icon-sm": "size-8",
|
||||||
|
"icon-lg": "size-10",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: "default",
|
||||||
|
size: "default",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export type ButtonVariant = VariantProps<typeof buttonVariants>["variant"];
|
||||||
|
export type ButtonSize = VariantProps<typeof buttonVariants>["size"];
|
||||||
|
|
||||||
|
export type ButtonProps = WithElementRef<HTMLButtonAttributes> &
|
||||||
|
WithElementRef<HTMLAnchorAttributes> & {
|
||||||
|
variant?: ButtonVariant;
|
||||||
|
size?: ButtonSize;
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<script lang="ts">
|
||||||
|
let {
|
||||||
|
class: className,
|
||||||
|
variant = "default",
|
||||||
|
size = "default",
|
||||||
|
ref = $bindable(null),
|
||||||
|
href = undefined,
|
||||||
|
type = "button",
|
||||||
|
disabled,
|
||||||
|
children,
|
||||||
|
...restProps
|
||||||
|
}: ButtonProps = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if href}
|
||||||
|
<a
|
||||||
|
bind:this={ref}
|
||||||
|
data-slot="button"
|
||||||
|
class={cn(buttonVariants({ variant, size }), className)}
|
||||||
|
href={disabled ? undefined : href}
|
||||||
|
aria-disabled={disabled}
|
||||||
|
role={disabled ? "link" : undefined}
|
||||||
|
tabindex={disabled ? -1 : undefined}
|
||||||
|
{...restProps}
|
||||||
|
>
|
||||||
|
{@render children?.()}
|
||||||
|
</a>
|
||||||
|
{:else}
|
||||||
|
<button
|
||||||
|
bind:this={ref}
|
||||||
|
data-slot="button"
|
||||||
|
class={cn(buttonVariants({ variant, size }), className)}
|
||||||
|
{type}
|
||||||
|
{disabled}
|
||||||
|
{...restProps}
|
||||||
|
>
|
||||||
|
{@render children?.()}
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
17
frontend/src/lib/components/ui/button/index.ts
Normal file
17
frontend/src/lib/components/ui/button/index.ts
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
import Root, {
|
||||||
|
type ButtonProps,
|
||||||
|
type ButtonSize,
|
||||||
|
type ButtonVariant,
|
||||||
|
buttonVariants,
|
||||||
|
} from "./button.svelte";
|
||||||
|
|
||||||
|
export {
|
||||||
|
Root,
|
||||||
|
type ButtonProps as Props,
|
||||||
|
//
|
||||||
|
Root as Button,
|
||||||
|
buttonVariants,
|
||||||
|
type ButtonProps,
|
||||||
|
type ButtonSize,
|
||||||
|
type ButtonVariant,
|
||||||
|
};
|
||||||
20
frontend/src/lib/components/ui/card/card-action.svelte
Normal file
20
frontend/src/lib/components/ui/card/card-action.svelte
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { cn, type WithElementRef } from "$lib/utils.js";
|
||||||
|
import type { HTMLAttributes } from "svelte/elements";
|
||||||
|
|
||||||
|
let {
|
||||||
|
ref = $bindable(null),
|
||||||
|
class: className,
|
||||||
|
children,
|
||||||
|
...restProps
|
||||||
|
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div
|
||||||
|
bind:this={ref}
|
||||||
|
data-slot="card-action"
|
||||||
|
class={cn("col-start-2 row-span-2 row-start-1 self-start justify-self-end", className)}
|
||||||
|
{...restProps}
|
||||||
|
>
|
||||||
|
{@render children?.()}
|
||||||
|
</div>
|
||||||
15
frontend/src/lib/components/ui/card/card-content.svelte
Normal file
15
frontend/src/lib/components/ui/card/card-content.svelte
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { HTMLAttributes } from "svelte/elements";
|
||||||
|
import { cn, type WithElementRef } from "$lib/utils.js";
|
||||||
|
|
||||||
|
let {
|
||||||
|
ref = $bindable(null),
|
||||||
|
class: className,
|
||||||
|
children,
|
||||||
|
...restProps
|
||||||
|
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div bind:this={ref} data-slot="card-content" class={cn("px-6", className)} {...restProps}>
|
||||||
|
{@render children?.()}
|
||||||
|
</div>
|
||||||
20
frontend/src/lib/components/ui/card/card-description.svelte
Normal file
20
frontend/src/lib/components/ui/card/card-description.svelte
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { HTMLAttributes } from "svelte/elements";
|
||||||
|
import { cn, type WithElementRef } from "$lib/utils.js";
|
||||||
|
|
||||||
|
let {
|
||||||
|
ref = $bindable(null),
|
||||||
|
class: className,
|
||||||
|
children,
|
||||||
|
...restProps
|
||||||
|
}: WithElementRef<HTMLAttributes<HTMLParagraphElement>> = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<p
|
||||||
|
bind:this={ref}
|
||||||
|
data-slot="card-description"
|
||||||
|
class={cn("text-muted-foreground text-sm", className)}
|
||||||
|
{...restProps}
|
||||||
|
>
|
||||||
|
{@render children?.()}
|
||||||
|
</p>
|
||||||
20
frontend/src/lib/components/ui/card/card-footer.svelte
Normal file
20
frontend/src/lib/components/ui/card/card-footer.svelte
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { cn, type WithElementRef } from "$lib/utils.js";
|
||||||
|
import type { HTMLAttributes } from "svelte/elements";
|
||||||
|
|
||||||
|
let {
|
||||||
|
ref = $bindable(null),
|
||||||
|
class: className,
|
||||||
|
children,
|
||||||
|
...restProps
|
||||||
|
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div
|
||||||
|
bind:this={ref}
|
||||||
|
data-slot="card-footer"
|
||||||
|
class={cn("[.border-t]:pt-6 flex items-center px-6", className)}
|
||||||
|
{...restProps}
|
||||||
|
>
|
||||||
|
{@render children?.()}
|
||||||
|
</div>
|
||||||
23
frontend/src/lib/components/ui/card/card-header.svelte
Normal file
23
frontend/src/lib/components/ui/card/card-header.svelte
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { cn, type WithElementRef } from "$lib/utils.js";
|
||||||
|
import type { HTMLAttributes } from "svelte/elements";
|
||||||
|
|
||||||
|
let {
|
||||||
|
ref = $bindable(null),
|
||||||
|
class: className,
|
||||||
|
children,
|
||||||
|
...restProps
|
||||||
|
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div
|
||||||
|
bind:this={ref}
|
||||||
|
data-slot="card-header"
|
||||||
|
class={cn(
|
||||||
|
"@container/card-header has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6 grid auto-rows-min grid-rows-[auto_auto] items-start gap-1.5 px-6",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...restProps}
|
||||||
|
>
|
||||||
|
{@render children?.()}
|
||||||
|
</div>
|
||||||
20
frontend/src/lib/components/ui/card/card-title.svelte
Normal file
20
frontend/src/lib/components/ui/card/card-title.svelte
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { HTMLAttributes } from "svelte/elements";
|
||||||
|
import { cn, type WithElementRef } from "$lib/utils.js";
|
||||||
|
|
||||||
|
let {
|
||||||
|
ref = $bindable(null),
|
||||||
|
class: className,
|
||||||
|
children,
|
||||||
|
...restProps
|
||||||
|
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div
|
||||||
|
bind:this={ref}
|
||||||
|
data-slot="card-title"
|
||||||
|
class={cn("font-semibold leading-none", className)}
|
||||||
|
{...restProps}
|
||||||
|
>
|
||||||
|
{@render children?.()}
|
||||||
|
</div>
|
||||||
23
frontend/src/lib/components/ui/card/card.svelte
Normal file
23
frontend/src/lib/components/ui/card/card.svelte
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { HTMLAttributes } from "svelte/elements";
|
||||||
|
import { cn, type WithElementRef } from "$lib/utils.js";
|
||||||
|
|
||||||
|
let {
|
||||||
|
ref = $bindable(null),
|
||||||
|
class: className,
|
||||||
|
children,
|
||||||
|
...restProps
|
||||||
|
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div
|
||||||
|
bind:this={ref}
|
||||||
|
data-slot="card"
|
||||||
|
class={cn(
|
||||||
|
"bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...restProps}
|
||||||
|
>
|
||||||
|
{@render children?.()}
|
||||||
|
</div>
|
||||||
25
frontend/src/lib/components/ui/card/index.ts
Normal file
25
frontend/src/lib/components/ui/card/index.ts
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
import Root from "./card.svelte";
|
||||||
|
import Content from "./card-content.svelte";
|
||||||
|
import Description from "./card-description.svelte";
|
||||||
|
import Footer from "./card-footer.svelte";
|
||||||
|
import Header from "./card-header.svelte";
|
||||||
|
import Title from "./card-title.svelte";
|
||||||
|
import Action from "./card-action.svelte";
|
||||||
|
|
||||||
|
export {
|
||||||
|
Root,
|
||||||
|
Content,
|
||||||
|
Description,
|
||||||
|
Footer,
|
||||||
|
Header,
|
||||||
|
Title,
|
||||||
|
Action,
|
||||||
|
//
|
||||||
|
Root as Card,
|
||||||
|
Content as CardContent,
|
||||||
|
Description as CardDescription,
|
||||||
|
Footer as CardFooter,
|
||||||
|
Header as CardHeader,
|
||||||
|
Title as CardTitle,
|
||||||
|
Action as CardAction,
|
||||||
|
};
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { Collapsible as CollapsiblePrimitive } from "bits-ui";
|
||||||
|
|
||||||
|
let { ref = $bindable(null), ...restProps }: CollapsiblePrimitive.ContentProps = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<CollapsiblePrimitive.Content bind:ref data-slot="collapsible-content" {...restProps} />
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { Collapsible as CollapsiblePrimitive } from "bits-ui";
|
||||||
|
|
||||||
|
let { ref = $bindable(null), ...restProps }: CollapsiblePrimitive.TriggerProps = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<CollapsiblePrimitive.Trigger bind:ref data-slot="collapsible-trigger" {...restProps} />
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { Collapsible as CollapsiblePrimitive } from "bits-ui";
|
||||||
|
|
||||||
|
let {
|
||||||
|
ref = $bindable(null),
|
||||||
|
open = $bindable(false),
|
||||||
|
...restProps
|
||||||
|
}: CollapsiblePrimitive.RootProps = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<CollapsiblePrimitive.Root bind:ref bind:open data-slot="collapsible" {...restProps} />
|
||||||
13
frontend/src/lib/components/ui/collapsible/index.ts
Normal file
13
frontend/src/lib/components/ui/collapsible/index.ts
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import Root from "./collapsible.svelte";
|
||||||
|
import Trigger from "./collapsible-trigger.svelte";
|
||||||
|
import Content from "./collapsible-content.svelte";
|
||||||
|
|
||||||
|
export {
|
||||||
|
Root,
|
||||||
|
Content,
|
||||||
|
Trigger,
|
||||||
|
//
|
||||||
|
Root as Collapsible,
|
||||||
|
Content as CollapsibleContent,
|
||||||
|
Trigger as CollapsibleTrigger,
|
||||||
|
};
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { DropdownMenu as DropdownMenuPrimitive } from "bits-ui";
|
||||||
|
import CheckIcon from "@lucide/svelte/icons/check";
|
||||||
|
import MinusIcon from "@lucide/svelte/icons/minus";
|
||||||
|
import { cn, type WithoutChildrenOrChild } from "$lib/utils.js";
|
||||||
|
import type { Snippet } from "svelte";
|
||||||
|
|
||||||
|
let {
|
||||||
|
ref = $bindable(null),
|
||||||
|
checked = $bindable(false),
|
||||||
|
indeterminate = $bindable(false),
|
||||||
|
class: className,
|
||||||
|
children: childrenProp,
|
||||||
|
...restProps
|
||||||
|
}: WithoutChildrenOrChild<DropdownMenuPrimitive.CheckboxItemProps> & {
|
||||||
|
children?: Snippet;
|
||||||
|
} = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<DropdownMenuPrimitive.CheckboxItem
|
||||||
|
bind:ref
|
||||||
|
bind:checked
|
||||||
|
bind:indeterminate
|
||||||
|
data-slot="dropdown-menu-checkbox-item"
|
||||||
|
class={cn(
|
||||||
|
"focus:bg-accent focus:text-accent-foreground outline-hidden relative flex cursor-default select-none items-center gap-2 rounded-sm py-1.5 pl-8 pr-2 text-sm data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...restProps}
|
||||||
|
>
|
||||||
|
{#snippet children({ checked, indeterminate })}
|
||||||
|
<span class="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
|
||||||
|
{#if indeterminate}
|
||||||
|
<MinusIcon class="size-4" />
|
||||||
|
{:else}
|
||||||
|
<CheckIcon class={cn("size-4", !checked && "text-transparent")} />
|
||||||
|
{/if}
|
||||||
|
</span>
|
||||||
|
{@render childrenProp?.()}
|
||||||
|
{/snippet}
|
||||||
|
</DropdownMenuPrimitive.CheckboxItem>
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { cn } from "$lib/utils.js";
|
||||||
|
import { DropdownMenu as DropdownMenuPrimitive } from "bits-ui";
|
||||||
|
|
||||||
|
let {
|
||||||
|
ref = $bindable(null),
|
||||||
|
sideOffset = 4,
|
||||||
|
portalProps,
|
||||||
|
class: className,
|
||||||
|
...restProps
|
||||||
|
}: DropdownMenuPrimitive.ContentProps & {
|
||||||
|
portalProps?: DropdownMenuPrimitive.PortalProps;
|
||||||
|
} = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<DropdownMenuPrimitive.Portal {...portalProps}>
|
||||||
|
<DropdownMenuPrimitive.Content
|
||||||
|
bind:ref
|
||||||
|
data-slot="dropdown-menu-content"
|
||||||
|
{sideOffset}
|
||||||
|
class={cn(
|
||||||
|
"bg-popover 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 max-h-(--bits-dropdown-menu-content-available-height) origin-(--bits-dropdown-menu-content-transform-origin) z-50 min-w-[8rem] overflow-y-auto overflow-x-hidden rounded-md border p-1 shadow-md outline-none",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...restProps}
|
||||||
|
/>
|
||||||
|
</DropdownMenuPrimitive.Portal>
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { DropdownMenu as DropdownMenuPrimitive } from "bits-ui";
|
||||||
|
import { cn } from "$lib/utils.js";
|
||||||
|
import type { ComponentProps } from "svelte";
|
||||||
|
|
||||||
|
let {
|
||||||
|
ref = $bindable(null),
|
||||||
|
class: className,
|
||||||
|
inset,
|
||||||
|
...restProps
|
||||||
|
}: ComponentProps<typeof DropdownMenuPrimitive.GroupHeading> & {
|
||||||
|
inset?: boolean;
|
||||||
|
} = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<DropdownMenuPrimitive.GroupHeading
|
||||||
|
bind:ref
|
||||||
|
data-slot="dropdown-menu-group-heading"
|
||||||
|
data-inset={inset}
|
||||||
|
class={cn("px-2 py-1.5 text-sm font-semibold data-[inset]:pl-8", className)}
|
||||||
|
{...restProps}
|
||||||
|
/>
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { DropdownMenu as DropdownMenuPrimitive } from "bits-ui";
|
||||||
|
|
||||||
|
let { ref = $bindable(null), ...restProps }: DropdownMenuPrimitive.GroupProps = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<DropdownMenuPrimitive.Group bind:ref data-slot="dropdown-menu-group" {...restProps} />
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { cn } from "$lib/utils.js";
|
||||||
|
import { DropdownMenu as DropdownMenuPrimitive } from "bits-ui";
|
||||||
|
|
||||||
|
let {
|
||||||
|
ref = $bindable(null),
|
||||||
|
class: className,
|
||||||
|
inset,
|
||||||
|
variant = "default",
|
||||||
|
...restProps
|
||||||
|
}: DropdownMenuPrimitive.ItemProps & {
|
||||||
|
inset?: boolean;
|
||||||
|
variant?: "default" | "destructive";
|
||||||
|
} = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<DropdownMenuPrimitive.Item
|
||||||
|
bind:ref
|
||||||
|
data-slot="dropdown-menu-item"
|
||||||
|
data-inset={inset}
|
||||||
|
data-variant={variant}
|
||||||
|
class={cn(
|
||||||
|
"data-highlighted:bg-accent data-highlighted:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:data-highlighted:bg-destructive/10 dark:data-[variant=destructive]:data-highlighted:bg-destructive/20 data-[variant=destructive]:data-highlighted:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*='text-'])]:text-muted-foreground outline-hidden relative flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm data-[disabled]:pointer-events-none data-[inset]:pl-8 data-[disabled]:opacity-50 [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...restProps}
|
||||||
|
/>
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { cn, type WithElementRef } from "$lib/utils.js";
|
||||||
|
import type { HTMLAttributes } from "svelte/elements";
|
||||||
|
|
||||||
|
let {
|
||||||
|
ref = $bindable(null),
|
||||||
|
class: className,
|
||||||
|
inset,
|
||||||
|
children,
|
||||||
|
...restProps
|
||||||
|
}: WithElementRef<HTMLAttributes<HTMLDivElement>> & {
|
||||||
|
inset?: boolean;
|
||||||
|
} = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div
|
||||||
|
bind:this={ref}
|
||||||
|
data-slot="dropdown-menu-label"
|
||||||
|
data-inset={inset}
|
||||||
|
class={cn("px-2 py-1.5 text-sm font-semibold data-[inset]:pl-8", className)}
|
||||||
|
{...restProps}
|
||||||
|
>
|
||||||
|
{@render children?.()}
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { DropdownMenu as DropdownMenuPrimitive } from "bits-ui";
|
||||||
|
|
||||||
|
let {
|
||||||
|
ref = $bindable(null),
|
||||||
|
value = $bindable(),
|
||||||
|
...restProps
|
||||||
|
}: DropdownMenuPrimitive.RadioGroupProps = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<DropdownMenuPrimitive.RadioGroup
|
||||||
|
bind:ref
|
||||||
|
bind:value
|
||||||
|
data-slot="dropdown-menu-radio-group"
|
||||||
|
{...restProps}
|
||||||
|
/>
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { DropdownMenu as DropdownMenuPrimitive } from "bits-ui";
|
||||||
|
import CircleIcon from "@lucide/svelte/icons/circle";
|
||||||
|
import { cn, type WithoutChild } from "$lib/utils.js";
|
||||||
|
|
||||||
|
let {
|
||||||
|
ref = $bindable(null),
|
||||||
|
class: className,
|
||||||
|
children: childrenProp,
|
||||||
|
...restProps
|
||||||
|
}: WithoutChild<DropdownMenuPrimitive.RadioItemProps> = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<DropdownMenuPrimitive.RadioItem
|
||||||
|
bind:ref
|
||||||
|
data-slot="dropdown-menu-radio-item"
|
||||||
|
class={cn(
|
||||||
|
"focus:bg-accent focus:text-accent-foreground outline-hidden relative flex cursor-default select-none items-center gap-2 rounded-sm py-1.5 pl-8 pr-2 text-sm data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...restProps}
|
||||||
|
>
|
||||||
|
{#snippet children({ checked })}
|
||||||
|
<span class="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
|
||||||
|
{#if checked}
|
||||||
|
<CircleIcon class="size-2 fill-current" />
|
||||||
|
{/if}
|
||||||
|
</span>
|
||||||
|
{@render childrenProp?.({ checked })}
|
||||||
|
{/snippet}
|
||||||
|
</DropdownMenuPrimitive.RadioItem>
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { DropdownMenu as DropdownMenuPrimitive } from "bits-ui";
|
||||||
|
import { cn } from "$lib/utils.js";
|
||||||
|
|
||||||
|
let {
|
||||||
|
ref = $bindable(null),
|
||||||
|
class: className,
|
||||||
|
...restProps
|
||||||
|
}: DropdownMenuPrimitive.SeparatorProps = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<DropdownMenuPrimitive.Separator
|
||||||
|
bind:ref
|
||||||
|
data-slot="dropdown-menu-separator"
|
||||||
|
class={cn("bg-border -mx-1 my-1 h-px", className)}
|
||||||
|
{...restProps}
|
||||||
|
/>
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { HTMLAttributes } from "svelte/elements";
|
||||||
|
import { cn, type WithElementRef } from "$lib/utils.js";
|
||||||
|
|
||||||
|
let {
|
||||||
|
ref = $bindable(null),
|
||||||
|
class: className,
|
||||||
|
children,
|
||||||
|
...restProps
|
||||||
|
}: WithElementRef<HTMLAttributes<HTMLSpanElement>> = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<span
|
||||||
|
bind:this={ref}
|
||||||
|
data-slot="dropdown-menu-shortcut"
|
||||||
|
class={cn("text-muted-foreground ml-auto text-xs tracking-widest", className)}
|
||||||
|
{...restProps}
|
||||||
|
>
|
||||||
|
{@render children?.()}
|
||||||
|
</span>
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { DropdownMenu as DropdownMenuPrimitive } from "bits-ui";
|
||||||
|
import { cn } from "$lib/utils.js";
|
||||||
|
|
||||||
|
let {
|
||||||
|
ref = $bindable(null),
|
||||||
|
class: className,
|
||||||
|
...restProps
|
||||||
|
}: DropdownMenuPrimitive.SubContentProps = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<DropdownMenuPrimitive.SubContent
|
||||||
|
bind:ref
|
||||||
|
data-slot="dropdown-menu-sub-content"
|
||||||
|
class={cn(
|
||||||
|
"bg-popover 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 origin-(--bits-dropdown-menu-content-transform-origin) z-50 min-w-[8rem] overflow-hidden rounded-md border p-1 shadow-lg",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...restProps}
|
||||||
|
/>
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { DropdownMenu as DropdownMenuPrimitive } from "bits-ui";
|
||||||
|
import ChevronRightIcon from "@lucide/svelte/icons/chevron-right";
|
||||||
|
import { cn } from "$lib/utils.js";
|
||||||
|
|
||||||
|
let {
|
||||||
|
ref = $bindable(null),
|
||||||
|
class: className,
|
||||||
|
inset,
|
||||||
|
children,
|
||||||
|
...restProps
|
||||||
|
}: DropdownMenuPrimitive.SubTriggerProps & {
|
||||||
|
inset?: boolean;
|
||||||
|
} = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<DropdownMenuPrimitive.SubTrigger
|
||||||
|
bind:ref
|
||||||
|
data-slot="dropdown-menu-sub-trigger"
|
||||||
|
data-inset={inset}
|
||||||
|
class={cn(
|
||||||
|
"data-highlighted:bg-accent data-highlighted:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground outline-hidden [&_svg:not([class*='text-'])]:text-muted-foreground flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm data-[disabled]:pointer-events-none data-[inset]:pl-8 data-[disabled]:opacity-50 [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...restProps}
|
||||||
|
>
|
||||||
|
{@render children?.()}
|
||||||
|
<ChevronRightIcon class="ml-auto size-4" />
|
||||||
|
</DropdownMenuPrimitive.SubTrigger>
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { DropdownMenu as DropdownMenuPrimitive } from "bits-ui";
|
||||||
|
|
||||||
|
let { ref = $bindable(null), ...restProps }: DropdownMenuPrimitive.TriggerProps = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<DropdownMenuPrimitive.Trigger bind:ref data-slot="dropdown-menu-trigger" {...restProps} />
|
||||||
49
frontend/src/lib/components/ui/dropdown-menu/index.ts
Normal file
49
frontend/src/lib/components/ui/dropdown-menu/index.ts
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
import { DropdownMenu as DropdownMenuPrimitive } from "bits-ui";
|
||||||
|
import CheckboxItem from "./dropdown-menu-checkbox-item.svelte";
|
||||||
|
import Content from "./dropdown-menu-content.svelte";
|
||||||
|
import Group from "./dropdown-menu-group.svelte";
|
||||||
|
import Item from "./dropdown-menu-item.svelte";
|
||||||
|
import Label from "./dropdown-menu-label.svelte";
|
||||||
|
import RadioGroup from "./dropdown-menu-radio-group.svelte";
|
||||||
|
import RadioItem from "./dropdown-menu-radio-item.svelte";
|
||||||
|
import Separator from "./dropdown-menu-separator.svelte";
|
||||||
|
import Shortcut from "./dropdown-menu-shortcut.svelte";
|
||||||
|
import Trigger from "./dropdown-menu-trigger.svelte";
|
||||||
|
import SubContent from "./dropdown-menu-sub-content.svelte";
|
||||||
|
import SubTrigger from "./dropdown-menu-sub-trigger.svelte";
|
||||||
|
import GroupHeading from "./dropdown-menu-group-heading.svelte";
|
||||||
|
const Sub = DropdownMenuPrimitive.Sub;
|
||||||
|
const Root = DropdownMenuPrimitive.Root;
|
||||||
|
|
||||||
|
export {
|
||||||
|
CheckboxItem,
|
||||||
|
Content,
|
||||||
|
Root as DropdownMenu,
|
||||||
|
CheckboxItem as DropdownMenuCheckboxItem,
|
||||||
|
Content as DropdownMenuContent,
|
||||||
|
Group as DropdownMenuGroup,
|
||||||
|
Item as DropdownMenuItem,
|
||||||
|
Label as DropdownMenuLabel,
|
||||||
|
RadioGroup as DropdownMenuRadioGroup,
|
||||||
|
RadioItem as DropdownMenuRadioItem,
|
||||||
|
Separator as DropdownMenuSeparator,
|
||||||
|
Shortcut as DropdownMenuShortcut,
|
||||||
|
Sub as DropdownMenuSub,
|
||||||
|
SubContent as DropdownMenuSubContent,
|
||||||
|
SubTrigger as DropdownMenuSubTrigger,
|
||||||
|
Trigger as DropdownMenuTrigger,
|
||||||
|
GroupHeading as DropdownMenuGroupHeading,
|
||||||
|
Group,
|
||||||
|
GroupHeading,
|
||||||
|
Item,
|
||||||
|
Label,
|
||||||
|
RadioGroup,
|
||||||
|
RadioItem,
|
||||||
|
Root,
|
||||||
|
Separator,
|
||||||
|
Shortcut,
|
||||||
|
Sub,
|
||||||
|
SubContent,
|
||||||
|
SubTrigger,
|
||||||
|
Trigger,
|
||||||
|
};
|
||||||
20
frontend/src/lib/components/ui/field/field-content.svelte
Normal file
20
frontend/src/lib/components/ui/field/field-content.svelte
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { cn, type WithElementRef } from "$lib/utils.js";
|
||||||
|
import type { HTMLAttributes } from "svelte/elements";
|
||||||
|
|
||||||
|
let {
|
||||||
|
ref = $bindable(null),
|
||||||
|
class: className,
|
||||||
|
children,
|
||||||
|
...restProps
|
||||||
|
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div
|
||||||
|
bind:this={ref}
|
||||||
|
data-slot="field-content"
|
||||||
|
class={cn("group/field-content flex flex-1 flex-col gap-1.5 leading-snug", className)}
|
||||||
|
{...restProps}
|
||||||
|
>
|
||||||
|
{@render children?.()}
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { cn, type WithElementRef } from "$lib/utils.js";
|
||||||
|
import type { HTMLAttributes } from "svelte/elements";
|
||||||
|
|
||||||
|
let {
|
||||||
|
ref = $bindable(null),
|
||||||
|
class: className,
|
||||||
|
children,
|
||||||
|
...restProps
|
||||||
|
}: WithElementRef<HTMLAttributes<HTMLParagraphElement>> = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<p
|
||||||
|
bind:this={ref}
|
||||||
|
data-slot="field-description"
|
||||||
|
class={cn(
|
||||||
|
"text-muted-foreground text-sm font-normal leading-normal group-has-[[data-orientation=horizontal]]/field:text-balance",
|
||||||
|
"nth-last-2:-mt-1 last:mt-0 [[data-variant=legend]+&]:-mt-1.5",
|
||||||
|
"[&>a:hover]:text-primary [&>a]:underline [&>a]:underline-offset-4",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...restProps}
|
||||||
|
>
|
||||||
|
{@render children?.()}
|
||||||
|
</p>
|
||||||
58
frontend/src/lib/components/ui/field/field-error.svelte
Normal file
58
frontend/src/lib/components/ui/field/field-error.svelte
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { cn, type WithElementRef } from "$lib/utils.js";
|
||||||
|
import type { HTMLAttributes } from "svelte/elements";
|
||||||
|
import type { Snippet } from "svelte";
|
||||||
|
|
||||||
|
let {
|
||||||
|
ref = $bindable(null),
|
||||||
|
class: className,
|
||||||
|
children,
|
||||||
|
errors,
|
||||||
|
...restProps
|
||||||
|
}: WithElementRef<HTMLAttributes<HTMLDivElement>> & {
|
||||||
|
children?: Snippet;
|
||||||
|
errors?: { message?: string }[];
|
||||||
|
} = $props();
|
||||||
|
|
||||||
|
const hasContent = $derived.by(() => {
|
||||||
|
// has slotted error
|
||||||
|
if (children) return true;
|
||||||
|
|
||||||
|
// no errors
|
||||||
|
if (!errors) return false;
|
||||||
|
|
||||||
|
// has an error but no message
|
||||||
|
if (errors.length === 1 && !errors[0]?.message) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
|
||||||
|
const isMultipleErrors = $derived(errors && errors.length > 1);
|
||||||
|
const singleErrorMessage = $derived(errors && errors.length === 1 && errors[0]?.message);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if hasContent}
|
||||||
|
<div
|
||||||
|
bind:this={ref}
|
||||||
|
role="alert"
|
||||||
|
data-slot="field-error"
|
||||||
|
class={cn("text-destructive text-sm font-normal", className)}
|
||||||
|
{...restProps}
|
||||||
|
>
|
||||||
|
{#if children}
|
||||||
|
{@render children()}
|
||||||
|
{:else if singleErrorMessage}
|
||||||
|
{singleErrorMessage}
|
||||||
|
{:else if isMultipleErrors}
|
||||||
|
<ul class="ml-4 flex list-disc flex-col gap-1">
|
||||||
|
{#each errors ?? [] as error, index (index)}
|
||||||
|
{#if error?.message}
|
||||||
|
<li>{error.message}</li>
|
||||||
|
{/if}
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
23
frontend/src/lib/components/ui/field/field-group.svelte
Normal file
23
frontend/src/lib/components/ui/field/field-group.svelte
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { cn, type WithElementRef } from "$lib/utils.js";
|
||||||
|
import type { HTMLAttributes } from "svelte/elements";
|
||||||
|
|
||||||
|
let {
|
||||||
|
ref = $bindable(null),
|
||||||
|
class: className,
|
||||||
|
children,
|
||||||
|
...restProps
|
||||||
|
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div
|
||||||
|
bind:this={ref}
|
||||||
|
data-slot="field-group"
|
||||||
|
class={cn(
|
||||||
|
"group/field-group @container/field-group flex w-full flex-col gap-7 data-[slot=checkbox-group]:gap-3 [&>[data-slot=field-group]]:gap-4",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...restProps}
|
||||||
|
>
|
||||||
|
{@render children?.()}
|
||||||
|
</div>
|
||||||
26
frontend/src/lib/components/ui/field/field-label.svelte
Normal file
26
frontend/src/lib/components/ui/field/field-label.svelte
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { Label } from "$lib/components/ui/label/index.js";
|
||||||
|
import { cn } from "$lib/utils.js";
|
||||||
|
import type { ComponentProps } from "svelte";
|
||||||
|
|
||||||
|
let {
|
||||||
|
ref = $bindable(null),
|
||||||
|
class: className,
|
||||||
|
children,
|
||||||
|
...restProps
|
||||||
|
}: ComponentProps<typeof Label> = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<Label
|
||||||
|
bind:ref
|
||||||
|
data-slot="field-label"
|
||||||
|
class={cn(
|
||||||
|
"group/field-label peer/field-label flex w-fit gap-2 leading-snug group-data-[disabled=true]/field:opacity-50",
|
||||||
|
"has-[>[data-slot=field]]:w-full has-[>[data-slot=field]]:flex-col has-[>[data-slot=field]]:rounded-md has-[>[data-slot=field]]:border [&>*]:data-[slot=field]:p-4",
|
||||||
|
"has-data-[state=checked]:bg-primary/5 has-data-[state=checked]:border-primary dark:has-data-[state=checked]:bg-primary/10",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...restProps}
|
||||||
|
>
|
||||||
|
{@render children?.()}
|
||||||
|
</Label>
|
||||||
29
frontend/src/lib/components/ui/field/field-legend.svelte
Normal file
29
frontend/src/lib/components/ui/field/field-legend.svelte
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { cn, type WithElementRef } from "$lib/utils.js";
|
||||||
|
import type { HTMLAttributes } from "svelte/elements";
|
||||||
|
|
||||||
|
let {
|
||||||
|
ref = $bindable(null),
|
||||||
|
class: className,
|
||||||
|
variant = "legend",
|
||||||
|
children,
|
||||||
|
...restProps
|
||||||
|
}: WithElementRef<HTMLAttributes<HTMLLegendElement>> & {
|
||||||
|
variant?: "legend" | "label";
|
||||||
|
} = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<legend
|
||||||
|
bind:this={ref}
|
||||||
|
data-slot="field-legend"
|
||||||
|
data-variant={variant}
|
||||||
|
class={cn(
|
||||||
|
"mb-3 font-medium",
|
||||||
|
"data-[variant=legend]:text-base",
|
||||||
|
"data-[variant=label]:text-sm",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...restProps}
|
||||||
|
>
|
||||||
|
{@render children?.()}
|
||||||
|
</legend>
|
||||||
38
frontend/src/lib/components/ui/field/field-separator.svelte
Normal file
38
frontend/src/lib/components/ui/field/field-separator.svelte
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { Separator } from "$lib/components/ui/separator/index.js";
|
||||||
|
import { cn, type WithElementRef } from "$lib/utils.js";
|
||||||
|
import type { HTMLAttributes } from "svelte/elements";
|
||||||
|
import type { Snippet } from "svelte";
|
||||||
|
|
||||||
|
let {
|
||||||
|
ref = $bindable(null),
|
||||||
|
class: className,
|
||||||
|
children,
|
||||||
|
...restProps
|
||||||
|
}: WithElementRef<HTMLAttributes<HTMLDivElement>> & {
|
||||||
|
children?: Snippet;
|
||||||
|
} = $props();
|
||||||
|
|
||||||
|
const hasContent = $derived(!!children);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div
|
||||||
|
bind:this={ref}
|
||||||
|
data-slot="field-separator"
|
||||||
|
data-content={hasContent}
|
||||||
|
class={cn(
|
||||||
|
"relative -my-2 h-5 text-sm group-data-[variant=outline]/field-group:-mb-2",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...restProps}
|
||||||
|
>
|
||||||
|
<Separator class="absolute inset-0 top-1/2" />
|
||||||
|
{#if children}
|
||||||
|
<span
|
||||||
|
class="bg-background text-muted-foreground relative mx-auto block w-fit px-2"
|
||||||
|
data-slot="field-separator-content"
|
||||||
|
>
|
||||||
|
{@render children()}
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
24
frontend/src/lib/components/ui/field/field-set.svelte
Normal file
24
frontend/src/lib/components/ui/field/field-set.svelte
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { cn, type WithElementRef } from "$lib/utils.js";
|
||||||
|
import type { HTMLFieldsetAttributes } from "svelte/elements";
|
||||||
|
|
||||||
|
let {
|
||||||
|
ref = $bindable(null),
|
||||||
|
class: className,
|
||||||
|
children,
|
||||||
|
...restProps
|
||||||
|
}: WithElementRef<HTMLFieldsetAttributes> = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<fieldset
|
||||||
|
bind:this={ref}
|
||||||
|
data-slot="field-set"
|
||||||
|
class={cn(
|
||||||
|
"flex flex-col gap-6",
|
||||||
|
"has-[>[data-slot=checkbox-group]]:gap-3 has-[>[data-slot=radio-group]]:gap-3",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...restProps}
|
||||||
|
>
|
||||||
|
{@render children?.()}
|
||||||
|
</fieldset>
|
||||||
23
frontend/src/lib/components/ui/field/field-title.svelte
Normal file
23
frontend/src/lib/components/ui/field/field-title.svelte
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { cn, type WithElementRef } from "$lib/utils.js";
|
||||||
|
import type { HTMLAttributes } from "svelte/elements";
|
||||||
|
|
||||||
|
let {
|
||||||
|
ref = $bindable(null),
|
||||||
|
class: className,
|
||||||
|
children,
|
||||||
|
...restProps
|
||||||
|
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div
|
||||||
|
bind:this={ref}
|
||||||
|
data-slot="field-title"
|
||||||
|
class={cn(
|
||||||
|
"flex w-fit items-center gap-2 text-sm font-medium leading-snug group-data-[disabled=true]/field:opacity-50",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...restProps}
|
||||||
|
>
|
||||||
|
{@render children?.()}
|
||||||
|
</div>
|
||||||
53
frontend/src/lib/components/ui/field/field.svelte
Normal file
53
frontend/src/lib/components/ui/field/field.svelte
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
<script lang="ts" module>
|
||||||
|
import { tv, type VariantProps } from "tailwind-variants";
|
||||||
|
|
||||||
|
export const fieldVariants = tv({
|
||||||
|
base: "group/field data-[invalid=true]:text-destructive flex w-full gap-3",
|
||||||
|
variants: {
|
||||||
|
orientation: {
|
||||||
|
vertical: "flex-col [&>*]:w-full [&>.sr-only]:w-auto",
|
||||||
|
horizontal: [
|
||||||
|
"flex-row items-center",
|
||||||
|
"[&>[data-slot=field-label]]:flex-auto",
|
||||||
|
"has-[>[data-slot=field-content]]:[&>[role=checkbox],[role=radio]]:mt-px has-[>[data-slot=field-content]]:items-start",
|
||||||
|
],
|
||||||
|
responsive: [
|
||||||
|
"@md/field-group:flex-row @md/field-group:items-center @md/field-group:[&>*]:w-auto flex-col [&>*]:w-full [&>.sr-only]:w-auto",
|
||||||
|
"@md/field-group:[&>[data-slot=field-label]]:flex-auto",
|
||||||
|
"@md/field-group:has-[>[data-slot=field-content]]:items-start @md/field-group:has-[>[data-slot=field-content]]:[&>[role=checkbox],[role=radio]]:mt-px",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
orientation: "vertical",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export type FieldOrientation = VariantProps<typeof fieldVariants>["orientation"];
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<script lang="ts">
|
||||||
|
import { cn, type WithElementRef } from "$lib/utils.js";
|
||||||
|
import type { HTMLAttributes } from "svelte/elements";
|
||||||
|
|
||||||
|
let {
|
||||||
|
ref = $bindable(null),
|
||||||
|
class: className,
|
||||||
|
orientation = "vertical",
|
||||||
|
children,
|
||||||
|
...restProps
|
||||||
|
}: WithElementRef<HTMLAttributes<HTMLDivElement>> & {
|
||||||
|
orientation?: FieldOrientation;
|
||||||
|
} = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div
|
||||||
|
bind:this={ref}
|
||||||
|
role="group"
|
||||||
|
data-slot="field"
|
||||||
|
data-orientation={orientation}
|
||||||
|
class={cn(fieldVariants({ orientation }), className)}
|
||||||
|
{...restProps}
|
||||||
|
>
|
||||||
|
{@render children?.()}
|
||||||
|
</div>
|
||||||
33
frontend/src/lib/components/ui/field/index.ts
Normal file
33
frontend/src/lib/components/ui/field/index.ts
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
import Field from "./field.svelte";
|
||||||
|
import Set from "./field-set.svelte";
|
||||||
|
import Legend from "./field-legend.svelte";
|
||||||
|
import Group from "./field-group.svelte";
|
||||||
|
import Content from "./field-content.svelte";
|
||||||
|
import Label from "./field-label.svelte";
|
||||||
|
import Title from "./field-title.svelte";
|
||||||
|
import Description from "./field-description.svelte";
|
||||||
|
import Separator from "./field-separator.svelte";
|
||||||
|
import Error from "./field-error.svelte";
|
||||||
|
|
||||||
|
export {
|
||||||
|
Field,
|
||||||
|
Set,
|
||||||
|
Legend,
|
||||||
|
Group,
|
||||||
|
Content,
|
||||||
|
Label,
|
||||||
|
Title,
|
||||||
|
Description,
|
||||||
|
Separator,
|
||||||
|
Error,
|
||||||
|
//
|
||||||
|
Set as FieldSet,
|
||||||
|
Legend as FieldLegend,
|
||||||
|
Group as FieldGroup,
|
||||||
|
Content as FieldContent,
|
||||||
|
Label as FieldLabel,
|
||||||
|
Title as FieldTitle,
|
||||||
|
Description as FieldDescription,
|
||||||
|
Separator as FieldSeparator,
|
||||||
|
Error as FieldError,
|
||||||
|
};
|
||||||
7
frontend/src/lib/components/ui/input/index.ts
Normal file
7
frontend/src/lib/components/ui/input/index.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import Root from "./input.svelte";
|
||||||
|
|
||||||
|
export {
|
||||||
|
Root,
|
||||||
|
//
|
||||||
|
Root as Input,
|
||||||
|
};
|
||||||
52
frontend/src/lib/components/ui/input/input.svelte
Normal file
52
frontend/src/lib/components/ui/input/input.svelte
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { HTMLInputAttributes, HTMLInputTypeAttribute } from "svelte/elements";
|
||||||
|
import { cn, type WithElementRef } from "$lib/utils.js";
|
||||||
|
|
||||||
|
type InputType = Exclude<HTMLInputTypeAttribute, "file">;
|
||||||
|
|
||||||
|
type Props = WithElementRef<
|
||||||
|
Omit<HTMLInputAttributes, "type"> &
|
||||||
|
({ type: "file"; files?: FileList } | { type?: InputType; files?: undefined })
|
||||||
|
>;
|
||||||
|
|
||||||
|
let {
|
||||||
|
ref = $bindable(null),
|
||||||
|
value = $bindable(),
|
||||||
|
type,
|
||||||
|
files = $bindable(),
|
||||||
|
class: className,
|
||||||
|
"data-slot": dataSlot = "input",
|
||||||
|
...restProps
|
||||||
|
}: Props = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if type === "file"}
|
||||||
|
<input
|
||||||
|
bind:this={ref}
|
||||||
|
data-slot={dataSlot}
|
||||||
|
class={cn(
|
||||||
|
"selection:bg-primary dark:bg-input/30 selection:text-primary-foreground border-input ring-offset-background placeholder:text-muted-foreground shadow-xs flex h-9 w-full min-w-0 rounded-md border bg-transparent px-3 pt-1.5 text-sm font-medium outline-none transition-[color,box-shadow] disabled:cursor-not-allowed disabled:opacity-50",
|
||||||
|
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
|
||||||
|
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
type="file"
|
||||||
|
bind:files
|
||||||
|
bind:value
|
||||||
|
{...restProps}
|
||||||
|
/>
|
||||||
|
{:else}
|
||||||
|
<input
|
||||||
|
bind:this={ref}
|
||||||
|
data-slot={dataSlot}
|
||||||
|
class={cn(
|
||||||
|
"border-input bg-background selection:bg-primary dark:bg-input/30 selection:text-primary-foreground ring-offset-background placeholder:text-muted-foreground shadow-xs flex h-9 w-full min-w-0 rounded-md border px-3 py-1 text-base outline-none transition-[color,box-shadow] disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
||||||
|
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
|
||||||
|
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{type}
|
||||||
|
bind:value
|
||||||
|
{...restProps}
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
7
frontend/src/lib/components/ui/label/index.ts
Normal file
7
frontend/src/lib/components/ui/label/index.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import Root from "./label.svelte";
|
||||||
|
|
||||||
|
export {
|
||||||
|
Root,
|
||||||
|
//
|
||||||
|
Root as Label,
|
||||||
|
};
|
||||||
20
frontend/src/lib/components/ui/label/label.svelte
Normal file
20
frontend/src/lib/components/ui/label/label.svelte
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { Label as LabelPrimitive } from "bits-ui";
|
||||||
|
import { cn } from "$lib/utils.js";
|
||||||
|
|
||||||
|
let {
|
||||||
|
ref = $bindable(null),
|
||||||
|
class: className,
|
||||||
|
...restProps
|
||||||
|
}: LabelPrimitive.RootProps = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<LabelPrimitive.Root
|
||||||
|
bind:ref
|
||||||
|
data-slot="label"
|
||||||
|
class={cn(
|
||||||
|
"flex select-none items-center gap-2 text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-50 group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...restProps}
|
||||||
|
/>
|
||||||
7
frontend/src/lib/components/ui/separator/index.ts
Normal file
7
frontend/src/lib/components/ui/separator/index.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import Root from "./separator.svelte";
|
||||||
|
|
||||||
|
export {
|
||||||
|
Root,
|
||||||
|
//
|
||||||
|
Root as Separator,
|
||||||
|
};
|
||||||
21
frontend/src/lib/components/ui/separator/separator.svelte
Normal file
21
frontend/src/lib/components/ui/separator/separator.svelte
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { Separator as SeparatorPrimitive } from "bits-ui";
|
||||||
|
import { cn } from "$lib/utils.js";
|
||||||
|
|
||||||
|
let {
|
||||||
|
ref = $bindable(null),
|
||||||
|
class: className,
|
||||||
|
"data-slot": dataSlot = "separator",
|
||||||
|
...restProps
|
||||||
|
}: SeparatorPrimitive.RootProps = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<SeparatorPrimitive.Root
|
||||||
|
bind:ref
|
||||||
|
data-slot={dataSlot}
|
||||||
|
class={cn(
|
||||||
|
"bg-border shrink-0 data-[orientation=horizontal]:h-px data-[orientation=vertical]:h-full data-[orientation=horizontal]:w-full data-[orientation=vertical]:w-px",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...restProps}
|
||||||
|
/>
|
||||||
36
frontend/src/lib/components/ui/sheet/index.ts
Normal file
36
frontend/src/lib/components/ui/sheet/index.ts
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
import { Dialog as SheetPrimitive } from "bits-ui";
|
||||||
|
import Trigger from "./sheet-trigger.svelte";
|
||||||
|
import Close from "./sheet-close.svelte";
|
||||||
|
import Overlay from "./sheet-overlay.svelte";
|
||||||
|
import Content from "./sheet-content.svelte";
|
||||||
|
import Header from "./sheet-header.svelte";
|
||||||
|
import Footer from "./sheet-footer.svelte";
|
||||||
|
import Title from "./sheet-title.svelte";
|
||||||
|
import Description from "./sheet-description.svelte";
|
||||||
|
|
||||||
|
const Root = SheetPrimitive.Root;
|
||||||
|
const Portal = SheetPrimitive.Portal;
|
||||||
|
|
||||||
|
export {
|
||||||
|
Root,
|
||||||
|
Close,
|
||||||
|
Trigger,
|
||||||
|
Portal,
|
||||||
|
Overlay,
|
||||||
|
Content,
|
||||||
|
Header,
|
||||||
|
Footer,
|
||||||
|
Title,
|
||||||
|
Description,
|
||||||
|
//
|
||||||
|
Root as Sheet,
|
||||||
|
Close as SheetClose,
|
||||||
|
Trigger as SheetTrigger,
|
||||||
|
Portal as SheetPortal,
|
||||||
|
Overlay as SheetOverlay,
|
||||||
|
Content as SheetContent,
|
||||||
|
Header as SheetHeader,
|
||||||
|
Footer as SheetFooter,
|
||||||
|
Title as SheetTitle,
|
||||||
|
Description as SheetDescription,
|
||||||
|
};
|
||||||
7
frontend/src/lib/components/ui/sheet/sheet-close.svelte
Normal file
7
frontend/src/lib/components/ui/sheet/sheet-close.svelte
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { Dialog as SheetPrimitive } from "bits-ui";
|
||||||
|
|
||||||
|
let { ref = $bindable(null), ...restProps }: SheetPrimitive.CloseProps = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<SheetPrimitive.Close bind:ref data-slot="sheet-close" {...restProps} />
|
||||||
58
frontend/src/lib/components/ui/sheet/sheet-content.svelte
Normal file
58
frontend/src/lib/components/ui/sheet/sheet-content.svelte
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
<script lang="ts" module>
|
||||||
|
import { tv, type VariantProps } from "tailwind-variants";
|
||||||
|
export const sheetVariants = tv({
|
||||||
|
base: "bg-background data-[state=open]:animate-in data-[state=closed]:animate-out fixed z-50 flex flex-col gap-4 shadow-lg transition ease-in-out data-[state=closed]:duration-300 data-[state=open]:duration-500",
|
||||||
|
variants: {
|
||||||
|
side: {
|
||||||
|
top: "data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top inset-x-0 top-0 h-auto border-b",
|
||||||
|
bottom: "data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom inset-x-0 bottom-0 h-auto border-t",
|
||||||
|
left: "data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left inset-y-0 left-0 h-full w-3/4 border-r sm:max-w-sm",
|
||||||
|
right: "data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right inset-y-0 right-0 h-full w-3/4 border-l sm:max-w-sm",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
side: "right",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export type Side = VariantProps<typeof sheetVariants>["side"];
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<script lang="ts">
|
||||||
|
import { Dialog as SheetPrimitive } from "bits-ui";
|
||||||
|
import XIcon from "@lucide/svelte/icons/x";
|
||||||
|
import type { Snippet } from "svelte";
|
||||||
|
import SheetOverlay from "./sheet-overlay.svelte";
|
||||||
|
import { cn, type WithoutChildrenOrChild } from "$lib/utils.js";
|
||||||
|
|
||||||
|
let {
|
||||||
|
ref = $bindable(null),
|
||||||
|
class: className,
|
||||||
|
side = "right",
|
||||||
|
portalProps,
|
||||||
|
children,
|
||||||
|
...restProps
|
||||||
|
}: WithoutChildrenOrChild<SheetPrimitive.ContentProps> & {
|
||||||
|
portalProps?: SheetPrimitive.PortalProps;
|
||||||
|
side?: Side;
|
||||||
|
children: Snippet;
|
||||||
|
} = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<SheetPrimitive.Portal {...portalProps}>
|
||||||
|
<SheetOverlay />
|
||||||
|
<SheetPrimitive.Content
|
||||||
|
bind:ref
|
||||||
|
data-slot="sheet-content"
|
||||||
|
class={cn(sheetVariants({ side }), className)}
|
||||||
|
{...restProps}
|
||||||
|
>
|
||||||
|
{@render children?.()}
|
||||||
|
<SheetPrimitive.Close
|
||||||
|
class="ring-offset-background focus-visible:ring-ring rounded-xs focus-visible:outline-hidden absolute right-4 top-4 opacity-70 transition-opacity hover:opacity-100 focus-visible:ring-2 focus-visible:ring-offset-2 disabled:pointer-events-none"
|
||||||
|
>
|
||||||
|
<XIcon class="size-4" />
|
||||||
|
<span class="sr-only">Close</span>
|
||||||
|
</SheetPrimitive.Close>
|
||||||
|
</SheetPrimitive.Content>
|
||||||
|
</SheetPrimitive.Portal>
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { Dialog as SheetPrimitive } from "bits-ui";
|
||||||
|
import { cn } from "$lib/utils.js";
|
||||||
|
|
||||||
|
let {
|
||||||
|
ref = $bindable(null),
|
||||||
|
class: className,
|
||||||
|
...restProps
|
||||||
|
}: SheetPrimitive.DescriptionProps = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<SheetPrimitive.Description
|
||||||
|
bind:ref
|
||||||
|
data-slot="sheet-description"
|
||||||
|
class={cn("text-muted-foreground text-sm", className)}
|
||||||
|
{...restProps}
|
||||||
|
/>
|
||||||
20
frontend/src/lib/components/ui/sheet/sheet-footer.svelte
Normal file
20
frontend/src/lib/components/ui/sheet/sheet-footer.svelte
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { cn, type WithElementRef } from "$lib/utils.js";
|
||||||
|
import type { HTMLAttributes } from "svelte/elements";
|
||||||
|
|
||||||
|
let {
|
||||||
|
ref = $bindable(null),
|
||||||
|
class: className,
|
||||||
|
children,
|
||||||
|
...restProps
|
||||||
|
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div
|
||||||
|
bind:this={ref}
|
||||||
|
data-slot="sheet-footer"
|
||||||
|
class={cn("mt-auto flex flex-col gap-2 p-4", className)}
|
||||||
|
{...restProps}
|
||||||
|
>
|
||||||
|
{@render children?.()}
|
||||||
|
</div>
|
||||||
20
frontend/src/lib/components/ui/sheet/sheet-header.svelte
Normal file
20
frontend/src/lib/components/ui/sheet/sheet-header.svelte
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { HTMLAttributes } from "svelte/elements";
|
||||||
|
import { cn, type WithElementRef } from "$lib/utils.js";
|
||||||
|
|
||||||
|
let {
|
||||||
|
ref = $bindable(null),
|
||||||
|
class: className,
|
||||||
|
children,
|
||||||
|
...restProps
|
||||||
|
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div
|
||||||
|
bind:this={ref}
|
||||||
|
data-slot="sheet-header"
|
||||||
|
class={cn("flex flex-col gap-1.5 p-4", className)}
|
||||||
|
{...restProps}
|
||||||
|
>
|
||||||
|
{@render children?.()}
|
||||||
|
</div>
|
||||||
20
frontend/src/lib/components/ui/sheet/sheet-overlay.svelte
Normal file
20
frontend/src/lib/components/ui/sheet/sheet-overlay.svelte
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { Dialog as SheetPrimitive } from "bits-ui";
|
||||||
|
import { cn } from "$lib/utils.js";
|
||||||
|
|
||||||
|
let {
|
||||||
|
ref = $bindable(null),
|
||||||
|
class: className,
|
||||||
|
...restProps
|
||||||
|
}: SheetPrimitive.OverlayProps = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<SheetPrimitive.Overlay
|
||||||
|
bind:ref
|
||||||
|
data-slot="sheet-overlay"
|
||||||
|
class={cn(
|
||||||
|
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...restProps}
|
||||||
|
/>
|
||||||
17
frontend/src/lib/components/ui/sheet/sheet-title.svelte
Normal file
17
frontend/src/lib/components/ui/sheet/sheet-title.svelte
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { Dialog as SheetPrimitive } from "bits-ui";
|
||||||
|
import { cn } from "$lib/utils.js";
|
||||||
|
|
||||||
|
let {
|
||||||
|
ref = $bindable(null),
|
||||||
|
class: className,
|
||||||
|
...restProps
|
||||||
|
}: SheetPrimitive.TitleProps = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<SheetPrimitive.Title
|
||||||
|
bind:ref
|
||||||
|
data-slot="sheet-title"
|
||||||
|
class={cn("text-foreground font-semibold", className)}
|
||||||
|
{...restProps}
|
||||||
|
/>
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { Dialog as SheetPrimitive } from "bits-ui";
|
||||||
|
|
||||||
|
let { ref = $bindable(null), ...restProps }: SheetPrimitive.TriggerProps = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<SheetPrimitive.Trigger bind:ref data-slot="sheet-trigger" {...restProps} />
|
||||||
6
frontend/src/lib/components/ui/sidebar/constants.ts
Normal file
6
frontend/src/lib/components/ui/sidebar/constants.ts
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
export const SIDEBAR_COOKIE_NAME = "sidebar:state";
|
||||||
|
export const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7;
|
||||||
|
export const SIDEBAR_WIDTH = "16rem";
|
||||||
|
export const SIDEBAR_WIDTH_MOBILE = "18rem";
|
||||||
|
export const SIDEBAR_WIDTH_ICON = "3rem";
|
||||||
|
export const SIDEBAR_KEYBOARD_SHORTCUT = "b";
|
||||||
81
frontend/src/lib/components/ui/sidebar/context.svelte.ts
Normal file
81
frontend/src/lib/components/ui/sidebar/context.svelte.ts
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
import { IsMobile } from "$lib/hooks/is-mobile.svelte.js";
|
||||||
|
import { getContext, setContext } from "svelte";
|
||||||
|
import { SIDEBAR_KEYBOARD_SHORTCUT } from "./constants.js";
|
||||||
|
|
||||||
|
type Getter<T> = () => T;
|
||||||
|
|
||||||
|
export type SidebarStateProps = {
|
||||||
|
/**
|
||||||
|
* A getter function that returns the current open state of the sidebar.
|
||||||
|
* We use a getter function here to support `bind:open` on the `Sidebar.Provider`
|
||||||
|
* component.
|
||||||
|
*/
|
||||||
|
open: Getter<boolean>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A function that sets the open state of the sidebar. To support `bind:open`, we need
|
||||||
|
* a source of truth for changing the open state to ensure it will be synced throughout
|
||||||
|
* the sub-components and any `bind:` references.
|
||||||
|
*/
|
||||||
|
setOpen: (open: boolean) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
class SidebarState {
|
||||||
|
readonly props: SidebarStateProps;
|
||||||
|
open = $derived.by(() => this.props.open());
|
||||||
|
openMobile = $state(false);
|
||||||
|
setOpen: SidebarStateProps["setOpen"];
|
||||||
|
#isMobile: IsMobile;
|
||||||
|
state = $derived.by(() => (this.open ? "expanded" : "collapsed"));
|
||||||
|
|
||||||
|
constructor(props: SidebarStateProps) {
|
||||||
|
this.setOpen = props.setOpen;
|
||||||
|
this.#isMobile = new IsMobile();
|
||||||
|
this.props = props;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convenience getter for checking if the sidebar is mobile
|
||||||
|
// without this, we would need to use `sidebar.isMobile.current` everywhere
|
||||||
|
get isMobile() {
|
||||||
|
return this.#isMobile.current;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Event handler to apply to the `<svelte:window>`
|
||||||
|
handleShortcutKeydown = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === SIDEBAR_KEYBOARD_SHORTCUT && (e.metaKey || e.ctrlKey)) {
|
||||||
|
e.preventDefault();
|
||||||
|
this.toggle();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
setOpenMobile = (value: boolean) => {
|
||||||
|
this.openMobile = value;
|
||||||
|
};
|
||||||
|
|
||||||
|
toggle = () => {
|
||||||
|
return this.#isMobile.current
|
||||||
|
? (this.openMobile = !this.openMobile)
|
||||||
|
: this.setOpen(!this.open);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const SYMBOL_KEY = "scn-sidebar";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Instantiates a new `SidebarState` instance and sets it in the context.
|
||||||
|
*
|
||||||
|
* @param props The constructor props for the `SidebarState` class.
|
||||||
|
* @returns The `SidebarState` instance.
|
||||||
|
*/
|
||||||
|
export function setSidebar(props: SidebarStateProps): SidebarState {
|
||||||
|
return setContext(Symbol.for(SYMBOL_KEY), new SidebarState(props));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retrieves the `SidebarState` instance from the context. This is a class instance,
|
||||||
|
* so you cannot destructure it.
|
||||||
|
* @returns The `SidebarState` instance.
|
||||||
|
*/
|
||||||
|
export function useSidebar(): SidebarState {
|
||||||
|
return getContext(Symbol.for(SYMBOL_KEY));
|
||||||
|
}
|
||||||
75
frontend/src/lib/components/ui/sidebar/index.ts
Normal file
75
frontend/src/lib/components/ui/sidebar/index.ts
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
import { useSidebar } from "./context.svelte.js";
|
||||||
|
import Content from "./sidebar-content.svelte";
|
||||||
|
import Footer from "./sidebar-footer.svelte";
|
||||||
|
import GroupAction from "./sidebar-group-action.svelte";
|
||||||
|
import GroupContent from "./sidebar-group-content.svelte";
|
||||||
|
import GroupLabel from "./sidebar-group-label.svelte";
|
||||||
|
import Group from "./sidebar-group.svelte";
|
||||||
|
import Header from "./sidebar-header.svelte";
|
||||||
|
import Input from "./sidebar-input.svelte";
|
||||||
|
import Inset from "./sidebar-inset.svelte";
|
||||||
|
import MenuAction from "./sidebar-menu-action.svelte";
|
||||||
|
import MenuBadge from "./sidebar-menu-badge.svelte";
|
||||||
|
import MenuButton from "./sidebar-menu-button.svelte";
|
||||||
|
import MenuItem from "./sidebar-menu-item.svelte";
|
||||||
|
import MenuSkeleton from "./sidebar-menu-skeleton.svelte";
|
||||||
|
import MenuSubButton from "./sidebar-menu-sub-button.svelte";
|
||||||
|
import MenuSubItem from "./sidebar-menu-sub-item.svelte";
|
||||||
|
import MenuSub from "./sidebar-menu-sub.svelte";
|
||||||
|
import Menu from "./sidebar-menu.svelte";
|
||||||
|
import Provider from "./sidebar-provider.svelte";
|
||||||
|
import Rail from "./sidebar-rail.svelte";
|
||||||
|
import Separator from "./sidebar-separator.svelte";
|
||||||
|
import Trigger from "./sidebar-trigger.svelte";
|
||||||
|
import Root from "./sidebar.svelte";
|
||||||
|
|
||||||
|
export {
|
||||||
|
Content,
|
||||||
|
Footer,
|
||||||
|
Group,
|
||||||
|
GroupAction,
|
||||||
|
GroupContent,
|
||||||
|
GroupLabel,
|
||||||
|
Header,
|
||||||
|
Input,
|
||||||
|
Inset,
|
||||||
|
Menu,
|
||||||
|
MenuAction,
|
||||||
|
MenuBadge,
|
||||||
|
MenuButton,
|
||||||
|
MenuItem,
|
||||||
|
MenuSkeleton,
|
||||||
|
MenuSub,
|
||||||
|
MenuSubButton,
|
||||||
|
MenuSubItem,
|
||||||
|
Provider,
|
||||||
|
Rail,
|
||||||
|
Root,
|
||||||
|
Separator,
|
||||||
|
//
|
||||||
|
Root as Sidebar,
|
||||||
|
Content as SidebarContent,
|
||||||
|
Footer as SidebarFooter,
|
||||||
|
Group as SidebarGroup,
|
||||||
|
GroupAction as SidebarGroupAction,
|
||||||
|
GroupContent as SidebarGroupContent,
|
||||||
|
GroupLabel as SidebarGroupLabel,
|
||||||
|
Header as SidebarHeader,
|
||||||
|
Input as SidebarInput,
|
||||||
|
Inset as SidebarInset,
|
||||||
|
Menu as SidebarMenu,
|
||||||
|
MenuAction as SidebarMenuAction,
|
||||||
|
MenuBadge as SidebarMenuBadge,
|
||||||
|
MenuButton as SidebarMenuButton,
|
||||||
|
MenuItem as SidebarMenuItem,
|
||||||
|
MenuSkeleton as SidebarMenuSkeleton,
|
||||||
|
MenuSub as SidebarMenuSub,
|
||||||
|
MenuSubButton as SidebarMenuSubButton,
|
||||||
|
MenuSubItem as SidebarMenuSubItem,
|
||||||
|
Provider as SidebarProvider,
|
||||||
|
Rail as SidebarRail,
|
||||||
|
Separator as SidebarSeparator,
|
||||||
|
Trigger as SidebarTrigger,
|
||||||
|
Trigger,
|
||||||
|
useSidebar,
|
||||||
|
};
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { HTMLAttributes } from "svelte/elements";
|
||||||
|
import { cn, type WithElementRef } from "$lib/utils.js";
|
||||||
|
|
||||||
|
let {
|
||||||
|
ref = $bindable(null),
|
||||||
|
class: className,
|
||||||
|
children,
|
||||||
|
...restProps
|
||||||
|
}: WithElementRef<HTMLAttributes<HTMLElement>> = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div
|
||||||
|
bind:this={ref}
|
||||||
|
data-slot="sidebar-content"
|
||||||
|
data-sidebar="content"
|
||||||
|
class={cn(
|
||||||
|
"flex min-h-0 flex-1 flex-col gap-2 overflow-auto group-data-[collapsible=icon]:overflow-hidden",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...restProps}
|
||||||
|
>
|
||||||
|
{@render children?.()}
|
||||||
|
</div>
|
||||||
21
frontend/src/lib/components/ui/sidebar/sidebar-footer.svelte
Normal file
21
frontend/src/lib/components/ui/sidebar/sidebar-footer.svelte
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { HTMLAttributes } from "svelte/elements";
|
||||||
|
import { cn, type WithElementRef } from "$lib/utils.js";
|
||||||
|
|
||||||
|
let {
|
||||||
|
ref = $bindable(null),
|
||||||
|
class: className,
|
||||||
|
children,
|
||||||
|
...restProps
|
||||||
|
}: WithElementRef<HTMLAttributes<HTMLElement>> = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div
|
||||||
|
bind:this={ref}
|
||||||
|
data-slot="sidebar-footer"
|
||||||
|
data-sidebar="footer"
|
||||||
|
class={cn("flex flex-col gap-2 p-2", className)}
|
||||||
|
{...restProps}
|
||||||
|
>
|
||||||
|
{@render children?.()}
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { cn, type WithElementRef } from "$lib/utils.js";
|
||||||
|
import type { Snippet } from "svelte";
|
||||||
|
import type { HTMLButtonAttributes } from "svelte/elements";
|
||||||
|
|
||||||
|
let {
|
||||||
|
ref = $bindable(null),
|
||||||
|
class: className,
|
||||||
|
children,
|
||||||
|
child,
|
||||||
|
...restProps
|
||||||
|
}: WithElementRef<HTMLButtonAttributes> & {
|
||||||
|
child?: Snippet<[{ props: Record<string, unknown> }]>;
|
||||||
|
} = $props();
|
||||||
|
|
||||||
|
const mergedProps = $derived({
|
||||||
|
class: cn(
|
||||||
|
"text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground outline-hidden absolute right-3 top-3.5 flex aspect-square w-5 items-center justify-center rounded-md p-0 transition-transform focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
|
||||||
|
// Increases the hit area of the button on mobile.
|
||||||
|
"after:absolute after:-inset-2 md:after:hidden",
|
||||||
|
"group-data-[collapsible=icon]:hidden",
|
||||||
|
className
|
||||||
|
),
|
||||||
|
"data-slot": "sidebar-group-action",
|
||||||
|
"data-sidebar": "group-action",
|
||||||
|
...restProps,
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if child}
|
||||||
|
{@render child({ props: mergedProps })}
|
||||||
|
{:else}
|
||||||
|
<button bind:this={ref} {...mergedProps}>
|
||||||
|
{@render children?.()}
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { cn, type WithElementRef } from "$lib/utils.js";
|
||||||
|
import type { HTMLAttributes } from "svelte/elements";
|
||||||
|
|
||||||
|
let {
|
||||||
|
ref = $bindable(null),
|
||||||
|
class: className,
|
||||||
|
children,
|
||||||
|
...restProps
|
||||||
|
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div
|
||||||
|
bind:this={ref}
|
||||||
|
data-slot="sidebar-group-content"
|
||||||
|
data-sidebar="group-content"
|
||||||
|
class={cn("w-full text-sm", className)}
|
||||||
|
{...restProps}
|
||||||
|
>
|
||||||
|
{@render children?.()}
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { cn, type WithElementRef } from "$lib/utils.js";
|
||||||
|
import type { Snippet } from "svelte";
|
||||||
|
import type { HTMLAttributes } from "svelte/elements";
|
||||||
|
|
||||||
|
let {
|
||||||
|
ref = $bindable(null),
|
||||||
|
children,
|
||||||
|
child,
|
||||||
|
class: className,
|
||||||
|
...restProps
|
||||||
|
}: WithElementRef<HTMLAttributes<HTMLElement>> & {
|
||||||
|
child?: Snippet<[{ props: Record<string, unknown> }]>;
|
||||||
|
} = $props();
|
||||||
|
|
||||||
|
const mergedProps = $derived({
|
||||||
|
class: cn(
|
||||||
|
"text-sidebar-foreground/70 ring-sidebar-ring outline-hidden flex h-8 shrink-0 items-center rounded-md px-2 text-xs font-medium transition-[margin,opacity] duration-200 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
|
||||||
|
),
|
||||||
|
"data-slot": "sidebar-group-label",
|
||||||
|
"data-sidebar": "group-label",
|
||||||
|
...restProps,
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if child}
|
||||||
|
{@render child({ props: mergedProps })}
|
||||||
|
{:else}
|
||||||
|
<div bind:this={ref} {...mergedProps}>
|
||||||
|
{@render children?.()}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
21
frontend/src/lib/components/ui/sidebar/sidebar-group.svelte
Normal file
21
frontend/src/lib/components/ui/sidebar/sidebar-group.svelte
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { HTMLAttributes } from "svelte/elements";
|
||||||
|
import { cn, type WithElementRef } from "$lib/utils.js";
|
||||||
|
|
||||||
|
let {
|
||||||
|
ref = $bindable(null),
|
||||||
|
class: className,
|
||||||
|
children,
|
||||||
|
...restProps
|
||||||
|
}: WithElementRef<HTMLAttributes<HTMLElement>> = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div
|
||||||
|
bind:this={ref}
|
||||||
|
data-slot="sidebar-group"
|
||||||
|
data-sidebar="group"
|
||||||
|
class={cn("relative flex w-full min-w-0 flex-col p-2", className)}
|
||||||
|
{...restProps}
|
||||||
|
>
|
||||||
|
{@render children?.()}
|
||||||
|
</div>
|
||||||
21
frontend/src/lib/components/ui/sidebar/sidebar-header.svelte
Normal file
21
frontend/src/lib/components/ui/sidebar/sidebar-header.svelte
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { HTMLAttributes } from "svelte/elements";
|
||||||
|
import { cn, type WithElementRef } from "$lib/utils.js";
|
||||||
|
|
||||||
|
let {
|
||||||
|
ref = $bindable(null),
|
||||||
|
class: className,
|
||||||
|
children,
|
||||||
|
...restProps
|
||||||
|
}: WithElementRef<HTMLAttributes<HTMLElement>> = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div
|
||||||
|
bind:this={ref}
|
||||||
|
data-slot="sidebar-header"
|
||||||
|
data-sidebar="header"
|
||||||
|
class={cn("flex flex-col gap-2 p-2", className)}
|
||||||
|
{...restProps}
|
||||||
|
>
|
||||||
|
{@render children?.()}
|
||||||
|
</div>
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user