clean
This commit is contained in:
parent
a9477ec881
commit
6a18a3caff
44
.gitignore
vendored
44
.gitignore
vendored
@ -1,17 +1,37 @@
|
|||||||
# Secrets / environment
|
Dockerfile
|
||||||
|
docker-compose.yml
|
||||||
.env
|
.env
|
||||||
.env.local
|
.vscode/
|
||||||
.env.*.local
|
.continue/
|
||||||
|
|
||||||
# Python
|
|
||||||
__pycache__/
|
|
||||||
*.py[cod]
|
|
||||||
.venv/
|
|
||||||
|
|
||||||
# Node
|
|
||||||
node_modules/
|
node_modules/
|
||||||
dist/
|
__pycache__/
|
||||||
|
.git/
|
||||||
|
.gitignore
|
||||||
|
.dockerignore
|
||||||
|
|
||||||
# Misc
|
# Logs
|
||||||
|
logs
|
||||||
*.log
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
pnpm-debug.log*
|
||||||
|
lerna-debug.log*
|
||||||
|
|
||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
dist-ssr
|
||||||
|
*.local
|
||||||
|
|
||||||
|
# Editor directories and files
|
||||||
|
.vscode/*
|
||||||
|
!.vscode/extensions.json
|
||||||
|
.idea
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
*.suo
|
||||||
|
*.ntvs*
|
||||||
|
*.njsproj
|
||||||
|
*.sln
|
||||||
|
*.sw?
|
||||||
|
|
||||||
|
idées.md
|
||||||
127
README.md
127
README.md
@ -1,127 +0,0 @@
|
|||||||
# Planning
|
|
||||||
|
|
||||||
Petit site de planning public + demandes de disponibilité, avec une administration séparée.
|
|
||||||
|
|
||||||
## Fonctionnalités
|
|
||||||
|
|
||||||
### Public
|
|
||||||
- consulter le planning semaine par semaine ;
|
|
||||||
- voir l'état prévu : **Disponible**, **Occupé** ou **Indisponible** ;
|
|
||||||
- envoyer une demande de disponibilité immédiate sans compte ;
|
|
||||||
- suivre la réponse automatiquement avec un lien/token privé ;
|
|
||||||
- voir une durée de disponibilité si l'admin en indique une.
|
|
||||||
|
|
||||||
### Administration
|
|
||||||
- connexion par compte admin ;
|
|
||||||
- créer une plage sur un jour ou plusieurs jours ;
|
|
||||||
- modifier/supprimer les plages directement depuis le planning ;
|
|
||||||
- recevoir et traiter les demandes ;
|
|
||||||
- accepter pour 15 min, 30 min, 1 h, une durée personnalisée, ou sans durée ;
|
|
||||||
- refuser avec un message optionnel ;
|
|
||||||
- actualisation automatique des demandes toutes les 15 secondes.
|
|
||||||
|
|
||||||
## Stack
|
|
||||||
- Frontend : React + Vite + Tailwind CSS
|
|
||||||
- Backend : Django + Django REST Framework + JWT
|
|
||||||
- Base : MariaDB/MySQL externe
|
|
||||||
- Reverse proxy du frontend : Nginx
|
|
||||||
- Notifications optionnelles : ntfy
|
|
||||||
|
|
||||||
## Ports
|
|
||||||
- Frontend : `1025`
|
|
||||||
- Backend : `1031`
|
|
||||||
|
|
||||||
Le frontend proxy automatiquement `/api/` vers le backend. Pour le domaine public, le reverse proxy principal peut donc pointer uniquement sur :
|
|
||||||
|
|
||||||
```text
|
|
||||||
http://127.0.0.1:1025
|
|
||||||
```
|
|
||||||
|
|
||||||
Le backend reste aussi directement disponible sur :
|
|
||||||
|
|
||||||
```text
|
|
||||||
http://127.0.0.1:1031
|
|
||||||
```
|
|
||||||
|
|
||||||
## Lancement
|
|
||||||
|
|
||||||
Le fichier `.env` fourni au déploiement contient la configuration réelle. Il est volontairement ignoré par Git.
|
|
||||||
|
|
||||||
```bash
|
|
||||||
docker compose up -d --build
|
|
||||||
```
|
|
||||||
|
|
||||||
Logs :
|
|
||||||
|
|
||||||
```bash
|
|
||||||
docker compose logs -f backend
|
|
||||||
docker compose logs -f frontend
|
|
||||||
```
|
|
||||||
|
|
||||||
Mise à jour :
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git pull
|
|
||||||
docker compose up -d --build
|
|
||||||
```
|
|
||||||
|
|
||||||
## Compte admin
|
|
||||||
|
|
||||||
Au démarrage du backend, les variables suivantes créent ou mettent à jour automatiquement le compte admin :
|
|
||||||
|
|
||||||
```env
|
|
||||||
ADMIN_USERNAME=...
|
|
||||||
ADMIN_PASSWORD=...
|
|
||||||
ADMIN_FIRST_NAME=...
|
|
||||||
ADMIN_LAST_NAME=...
|
|
||||||
```
|
|
||||||
|
|
||||||
L'administration est accessible sur :
|
|
||||||
|
|
||||||
```text
|
|
||||||
/admin
|
|
||||||
```
|
|
||||||
|
|
||||||
## Notifications ntfy
|
|
||||||
|
|
||||||
Le site fonctionne même sans notification externe.
|
|
||||||
|
|
||||||
Pour activer les notifications :
|
|
||||||
1. installer l'app **ntfy** sur le téléphone ;
|
|
||||||
2. choisir un nom de topic difficile à deviner ;
|
|
||||||
3. s'abonner à ce topic dans l'app ;
|
|
||||||
4. renseigner ce même topic dans `.env` :
|
|
||||||
|
|
||||||
```env
|
|
||||||
NTFY_SERVER=https://ntfy.sh
|
|
||||||
NTFY_TOPIC=mon-topic-prive
|
|
||||||
```
|
|
||||||
|
|
||||||
5. relancer le backend :
|
|
||||||
|
|
||||||
```bash
|
|
||||||
docker compose restart backend
|
|
||||||
```
|
|
||||||
|
|
||||||
Une nouvelle demande publique déclenche alors une notification avec un lien vers `/admin/requests`.
|
|
||||||
|
|
||||||
## Gitea
|
|
||||||
|
|
||||||
Dépôt prévu :
|
|
||||||
|
|
||||||
```text
|
|
||||||
https://gitea.louismazin.ovh/LouisMazin/Planning
|
|
||||||
```
|
|
||||||
|
|
||||||
Premier push depuis le dossier du projet :
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git init
|
|
||||||
git branch -M main
|
|
||||||
git remote add origin https://gitea.louismazin.ovh/LouisMazin/Planning.git
|
|
||||||
git add .
|
|
||||||
git commit -m "Initial planning app"
|
|
||||||
git push -u origin main
|
|
||||||
```
|
|
||||||
|
|
||||||
> `.env` n'est pas envoyé sur Git grâce au `.gitignore`. Garde-le uniquement sur le serveur.
|
|
||||||
20
backend/API.md
Normal file
20
backend/API.md
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
# API Planning
|
||||||
|
|
||||||
|
## Authentification
|
||||||
|
|
||||||
|
- `POST /api/auth/login/` : connexion admin (JWT).
|
||||||
|
- `POST /api/auth/refresh/` : renouvellement du JWT.
|
||||||
|
- `GET /api/users/me/` : profil du compte connecté.
|
||||||
|
|
||||||
|
## Public
|
||||||
|
|
||||||
|
- `GET /api/planning/public/schedule/?start=YYYY-MM-DD&end=YYYY-MM-DD` : planning visible par les guests.
|
||||||
|
- `POST /api/planning/public/requests/` : demande de disponibilité avec `guest_name` et `message` optionnel.
|
||||||
|
- `GET /api/planning/public/requests/<token>/` : suivi public d'une demande.
|
||||||
|
|
||||||
|
## Administration
|
||||||
|
|
||||||
|
- `GET|POST /api/planning/schedule/` : lister / créer les plages du planning.
|
||||||
|
- `GET|PUT|PATCH|DELETE /api/planning/schedule/<id>/` : modifier une plage.
|
||||||
|
- `GET /api/planning/requests/` : lister les demandes.
|
||||||
|
- `POST /api/planning/requests/<id>/respond/` : accepter/refuser, avec `duration_minutes` optionnel.
|
||||||
@ -1,4 +0,0 @@
|
|||||||
from django.apps import AppConfig
|
|
||||||
class CoreConfig(AppConfig):
|
|
||||||
default_auto_field = "django.db.models.BigAutoField"
|
|
||||||
name = "core"
|
|
||||||
16
backend/core/asgi.py
Normal file
16
backend/core/asgi.py
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
"""
|
||||||
|
ASGI config for core project.
|
||||||
|
|
||||||
|
It exposes the ASGI callable as a module-level variable named ``application``.
|
||||||
|
|
||||||
|
For more information on this file, see
|
||||||
|
https://docs.djangoproject.com/en/5.2/howto/deployment/asgi/
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
|
||||||
|
from django.core.asgi import get_asgi_application
|
||||||
|
|
||||||
|
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'core.settings')
|
||||||
|
|
||||||
|
application = get_asgi_application()
|
||||||
@ -1,16 +0,0 @@
|
|||||||
import os
|
|
||||||
from django.contrib.auth import get_user_model
|
|
||||||
from django.core.management.base import BaseCommand
|
|
||||||
class Command(BaseCommand):
|
|
||||||
def handle(self, *args, **kwargs):
|
|
||||||
User = get_user_model()
|
|
||||||
username = os.getenv("ADMIN_USERNAME", "admin")
|
|
||||||
user, _ = User.objects.get_or_create(username=username)
|
|
||||||
user.first_name = os.getenv("ADMIN_FIRST_NAME", "Admin")
|
|
||||||
user.last_name = os.getenv("ADMIN_LAST_NAME", "")
|
|
||||||
user.is_staff = True
|
|
||||||
user.is_superuser = True
|
|
||||||
user.is_active = True
|
|
||||||
user.set_password(os.getenv("ADMIN_PASSWORD", "admin123"))
|
|
||||||
user.save()
|
|
||||||
self.stdout.write(self.style.SUCCESS(f"Admin ready: {username}"))
|
|
||||||
@ -1,14 +0,0 @@
|
|||||||
import time
|
|
||||||
from django.core.management.base import BaseCommand
|
|
||||||
from django.db import connections
|
|
||||||
from django.db.utils import OperationalError
|
|
||||||
class Command(BaseCommand):
|
|
||||||
def handle(self, *args, **kwargs):
|
|
||||||
for _ in range(60):
|
|
||||||
try:
|
|
||||||
connections["default"].cursor()
|
|
||||||
self.stdout.write(self.style.SUCCESS("Database available"))
|
|
||||||
return
|
|
||||||
except OperationalError:
|
|
||||||
time.sleep(2)
|
|
||||||
raise OperationalError("Database unavailable after 120 seconds")
|
|
||||||
@ -1,35 +0,0 @@
|
|||||||
from django.db import migrations, models
|
|
||||||
import uuid
|
|
||||||
class Migration(migrations.Migration):
|
|
||||||
initial = True
|
|
||||||
dependencies = []
|
|
||||||
operations = [
|
|
||||||
migrations.CreateModel(name="ScheduleEntry", fields=[
|
|
||||||
("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
|
|
||||||
("title", models.CharField(blank=True, max_length=120)),
|
|
||||||
("description", models.TextField(blank=True)),
|
|
||||||
("start_date", models.DateField()),
|
|
||||||
("end_date", models.DateField()),
|
|
||||||
("start_time", models.TimeField()),
|
|
||||||
("end_time", models.TimeField()),
|
|
||||||
("status", models.CharField(choices=[("busy","Occupé"),("free","Disponible"),("unavailable","Indisponible")], default="busy", max_length=20)),
|
|
||||||
("color", models.CharField(blank=True, default="", max_length=16)),
|
|
||||||
("created_at", models.DateTimeField(auto_now_add=True)),
|
|
||||||
("updated_at", models.DateTimeField(auto_now=True)),
|
|
||||||
], options={"ordering":["start_date","start_time"]}),
|
|
||||||
migrations.CreateModel(name="AvailabilityRequest", fields=[
|
|
||||||
("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
|
|
||||||
("public_token", models.UUIDField(default=uuid.uuid4, editable=False, unique=True)),
|
|
||||||
("guest_name", models.CharField(max_length=80)),
|
|
||||||
("message", models.CharField(blank=True, max_length=500)),
|
|
||||||
("status", models.CharField(choices=[("pending","En attente"),("accepted","Acceptée"),("refused","Refusée")], default="pending", max_length=20)),
|
|
||||||
("duration_minutes", models.PositiveIntegerField(blank=True, null=True)),
|
|
||||||
("available_until", models.DateTimeField(blank=True, null=True)),
|
|
||||||
("response_message", models.CharField(blank=True, max_length=500)),
|
|
||||||
("requested_at", models.DateTimeField(auto_now_add=True)),
|
|
||||||
("responded_at", models.DateTimeField(blank=True, null=True)),
|
|
||||||
("requester_ip", models.GenericIPAddressField(blank=True, null=True)),
|
|
||||||
], options={"ordering":["-requested_at"]}),
|
|
||||||
migrations.AddIndex(model_name="scheduleentry", index=models.Index(fields=["start_date","end_date"], name="core_sched_dates_idx")),
|
|
||||||
migrations.AddIndex(model_name="availabilityrequest", index=models.Index(fields=["status","requested_at"], name="core_req_status_idx")),
|
|
||||||
]
|
|
||||||
@ -1,16 +0,0 @@
|
|||||||
import os, requests
|
|
||||||
|
|
||||||
def send_ntfy_notification(title, message, click_url=None):
|
|
||||||
topic = os.getenv("NTFY_TOPIC", "").strip()
|
|
||||||
if not topic:
|
|
||||||
return False
|
|
||||||
server = os.getenv("NTFY_SERVER", "https://ntfy.sh").rstrip("/")
|
|
||||||
headers = {"Title": title, "Priority": "high", "Tags": "calendar,question"}
|
|
||||||
if click_url:
|
|
||||||
headers["Click"] = click_url
|
|
||||||
try:
|
|
||||||
r = requests.post(f"{server}/{topic}", data=message.encode("utf-8"), headers=headers, timeout=8)
|
|
||||||
r.raise_for_status()
|
|
||||||
return True
|
|
||||||
except requests.RequestException:
|
|
||||||
return False
|
|
||||||
@ -1,44 +0,0 @@
|
|||||||
from rest_framework import serializers
|
|
||||||
from .models import AvailabilityRequest, ScheduleEntry
|
|
||||||
|
|
||||||
class ScheduleEntrySerializer(serializers.ModelSerializer):
|
|
||||||
status_label = serializers.CharField(source="get_status_display", read_only=True)
|
|
||||||
class Meta:
|
|
||||||
model = ScheduleEntry
|
|
||||||
fields = "__all__"
|
|
||||||
read_only_fields = ["created_at", "updated_at"]
|
|
||||||
def validate(self, attrs):
|
|
||||||
sd = attrs.get("start_date", getattr(self.instance, "start_date", None))
|
|
||||||
ed = attrs.get("end_date", getattr(self.instance, "end_date", None))
|
|
||||||
st = attrs.get("start_time", getattr(self.instance, "start_time", None))
|
|
||||||
et = attrs.get("end_time", getattr(self.instance, "end_time", None))
|
|
||||||
if sd and ed and ed < sd:
|
|
||||||
raise serializers.ValidationError({"end_date": "La date de fin doit être après la date de début."})
|
|
||||||
if st and et and et <= st:
|
|
||||||
raise serializers.ValidationError({"end_time": "L'heure de fin doit être après l'heure de début."})
|
|
||||||
return attrs
|
|
||||||
|
|
||||||
class AvailabilityRequestCreateSerializer(serializers.ModelSerializer):
|
|
||||||
class Meta:
|
|
||||||
model = AvailabilityRequest
|
|
||||||
fields = ["guest_name", "message"]
|
|
||||||
def validate_guest_name(self, value):
|
|
||||||
value = value.strip()
|
|
||||||
if len(value) < 2:
|
|
||||||
raise serializers.ValidationError("Indique ton prénom ou ton nom.")
|
|
||||||
return value
|
|
||||||
|
|
||||||
class AvailabilityRequestPublicSerializer(serializers.ModelSerializer):
|
|
||||||
status_label = serializers.CharField(source="get_status_display", read_only=True)
|
|
||||||
class Meta:
|
|
||||||
model = AvailabilityRequest
|
|
||||||
fields = ["public_token", "guest_name", "message", "status", "status_label", "duration_minutes", "available_until", "response_message", "requested_at", "responded_at"]
|
|
||||||
|
|
||||||
class AvailabilityRequestAdminSerializer(AvailabilityRequestPublicSerializer):
|
|
||||||
class Meta(AvailabilityRequestPublicSerializer.Meta):
|
|
||||||
fields = ["id"] + AvailabilityRequestPublicSerializer.Meta.fields
|
|
||||||
|
|
||||||
class AvailabilityResponseSerializer(serializers.Serializer):
|
|
||||||
status = serializers.ChoiceField(choices=["accepted", "refused"])
|
|
||||||
duration_minutes = serializers.IntegerField(required=False, allow_null=True, min_value=1, max_value=1440)
|
|
||||||
response_message = serializers.CharField(required=False, allow_blank=True, max_length=500)
|
|
||||||
111
backend/core/settings.py
Normal file
111
backend/core/settings.py
Normal file
@ -0,0 +1,111 @@
|
|||||||
|
"""Django settings for the Planning project."""
|
||||||
|
from datetime import timedelta
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from decouple import config
|
||||||
|
import dj_database_url
|
||||||
|
|
||||||
|
BASE_DIR = Path(__file__).resolve().parent.parent
|
||||||
|
SECRET_KEY = config('SECRET_KEY')
|
||||||
|
DEBUG = config('DEBUG', default=False, cast=bool)
|
||||||
|
|
||||||
|
ALLOWED_HOSTS = [host.strip() for host in config('ALLOWED_HOSTS', default='').split(',') if host.strip()]
|
||||||
|
if not ALLOWED_HOSTS:
|
||||||
|
ALLOWED_HOSTS = ['*']
|
||||||
|
|
||||||
|
CSRF_TRUSTED_ORIGINS = [origin.strip() for origin in config('CSRF_TRUSTED_ORIGINS', default='').split(',') if origin.strip()]
|
||||||
|
|
||||||
|
INSTALLED_APPS = [
|
||||||
|
'django.contrib.admin',
|
||||||
|
'django.contrib.auth',
|
||||||
|
'django.contrib.contenttypes',
|
||||||
|
'django.contrib.sessions',
|
||||||
|
'django.contrib.messages',
|
||||||
|
'django.contrib.staticfiles',
|
||||||
|
'rest_framework',
|
||||||
|
'rest_framework_simplejwt',
|
||||||
|
'rest_framework_simplejwt.token_blacklist',
|
||||||
|
'corsheaders',
|
||||||
|
'users',
|
||||||
|
'planning',
|
||||||
|
]
|
||||||
|
|
||||||
|
MIDDLEWARE = [
|
||||||
|
'corsheaders.middleware.CorsMiddleware',
|
||||||
|
'django.middleware.security.SecurityMiddleware',
|
||||||
|
'django.contrib.sessions.middleware.SessionMiddleware',
|
||||||
|
'django.middleware.common.CommonMiddleware',
|
||||||
|
'django.middleware.csrf.CsrfViewMiddleware',
|
||||||
|
'django.contrib.auth.middleware.AuthenticationMiddleware',
|
||||||
|
'django.contrib.messages.middleware.MessageMiddleware',
|
||||||
|
'django.middleware.clickjacking.XFrameOptionsMiddleware',
|
||||||
|
]
|
||||||
|
|
||||||
|
ROOT_URLCONF = 'core.urls'
|
||||||
|
TEMPLATES = [{
|
||||||
|
'BACKEND': 'django.template.backends.django.DjangoTemplates',
|
||||||
|
'DIRS': [],
|
||||||
|
'APP_DIRS': True,
|
||||||
|
'OPTIONS': {'context_processors': [
|
||||||
|
'django.template.context_processors.request',
|
||||||
|
'django.contrib.auth.context_processors.auth',
|
||||||
|
'django.contrib.messages.context_processors.messages',
|
||||||
|
]},
|
||||||
|
}]
|
||||||
|
WSGI_APPLICATION = 'core.wsgi.application'
|
||||||
|
|
||||||
|
DATABASES = {
|
||||||
|
'default': dj_database_url.config(
|
||||||
|
default=(
|
||||||
|
f"mysql://{config('MYSQL_USER', default='planning')}:"
|
||||||
|
f"{config('MYSQL_PASSWORD', default='planning')}@"
|
||||||
|
f"{config('MYSQL_HOST', default='db')}:"
|
||||||
|
f"{config('MYSQL_PORT', default='3306')}/"
|
||||||
|
f"{config('MYSQL_DATABASE', default='planning')}"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
AUTH_PASSWORD_VALIDATORS = [
|
||||||
|
{'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator'},
|
||||||
|
{'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator'},
|
||||||
|
{'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator'},
|
||||||
|
{'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator'},
|
||||||
|
]
|
||||||
|
|
||||||
|
LANGUAGE_CODE = 'fr-fr'
|
||||||
|
TIME_ZONE = 'Europe/Paris'
|
||||||
|
USE_I18N = True
|
||||||
|
USE_TZ = True
|
||||||
|
STATIC_URL = 'static/'
|
||||||
|
DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField'
|
||||||
|
|
||||||
|
CORS_ALLOWED_ORIGINS = [
|
||||||
|
'http://localhost:1025',
|
||||||
|
'http://127.0.0.1:1025',
|
||||||
|
'http://192.168.1.19:1025',
|
||||||
|
'https://planning.louismazin.ovh',
|
||||||
|
]
|
||||||
|
extra_cors = [origin.strip() for origin in config('CORS_ALLOWED_ORIGINS', default='').split(',') if origin.strip()]
|
||||||
|
for origin in extra_cors:
|
||||||
|
if origin not in CORS_ALLOWED_ORIGINS:
|
||||||
|
CORS_ALLOWED_ORIGINS.append(origin)
|
||||||
|
|
||||||
|
REST_FRAMEWORK = {
|
||||||
|
'DEFAULT_AUTHENTICATION_CLASSES': (
|
||||||
|
'rest_framework_simplejwt.authentication.JWTAuthentication',
|
||||||
|
),
|
||||||
|
'DEFAULT_PERMISSION_CLASSES': [
|
||||||
|
'rest_framework.permissions.IsAuthenticated',
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
SIMPLE_JWT = {
|
||||||
|
'ACCESS_TOKEN_LIFETIME': timedelta(days=1),
|
||||||
|
'REFRESH_TOKEN_LIFETIME': timedelta(days=36500),
|
||||||
|
'ROTATE_REFRESH_TOKENS': True,
|
||||||
|
'BLACKLIST_AFTER_ROTATION': True,
|
||||||
|
'AUTH_HEADER_TYPES': ('Bearer',),
|
||||||
|
}
|
||||||
|
|
||||||
|
SECURE_PROXY_SSL_HEADER = ('HTTP_X_FORWARDED_PROTO', 'https')
|
||||||
@ -1,14 +1,11 @@
|
|||||||
|
from django.contrib import admin
|
||||||
from django.urls import include, path
|
from django.urls import include, path
|
||||||
from rest_framework.routers import DefaultRouter
|
from rest_framework_simplejwt.views import TokenObtainPairView, TokenRefreshView
|
||||||
from .views import *
|
|
||||||
router = DefaultRouter()
|
|
||||||
router.register(r"admin/schedule", AdminScheduleViewSet, basename="admin-schedule")
|
|
||||||
router.register(r"admin/requests", AdminAvailabilityRequestViewSet, basename="admin-requests")
|
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
path("public/schedule/", PublicScheduleView.as_view()),
|
path('admin/', admin.site.urls),
|
||||||
path("public/requests/", PublicAvailabilityRequestCreateView.as_view()),
|
path('api/auth/login/', TokenObtainPairView.as_view(), name='token_obtain_pair'),
|
||||||
path("public/requests/<uuid:token>/", PublicAvailabilityRequestDetailView.as_view()),
|
path('api/auth/refresh/', TokenRefreshView.as_view(), name='token_refresh'),
|
||||||
path("admin/me/", AdminMeView.as_view()),
|
path('api/users/', include('users.urls')),
|
||||||
path("admin/requests/<int:pk>/respond/", AdminAvailabilityRequestViewSet.as_view({"post": "respond"})),
|
path('api/planning/', include('planning.urls')),
|
||||||
path("", include(router.urls)),
|
|
||||||
]
|
]
|
||||||
|
|||||||
@ -1,98 +0,0 @@
|
|||||||
from datetime import timedelta
|
|
||||||
import os
|
|
||||||
from django.utils import timezone
|
|
||||||
from rest_framework import status, viewsets
|
|
||||||
from rest_framework.exceptions import Throttled
|
|
||||||
from rest_framework.permissions import AllowAny, IsAdminUser
|
|
||||||
from rest_framework.response import Response
|
|
||||||
from rest_framework.views import APIView
|
|
||||||
from rest_framework_simplejwt.serializers import TokenObtainPairSerializer
|
|
||||||
from rest_framework_simplejwt.views import TokenObtainPairView
|
|
||||||
from .models import AvailabilityRequest, ScheduleEntry
|
|
||||||
from .notifications import send_ntfy_notification
|
|
||||||
from .serializers import *
|
|
||||||
|
|
||||||
def client_ip(request):
|
|
||||||
forwarded = request.META.get("HTTP_X_FORWARDED_FOR")
|
|
||||||
return forwarded.split(",")[0].strip() if forwarded else request.META.get("REMOTE_ADDR")
|
|
||||||
|
|
||||||
class AdminTokenSerializer(TokenObtainPairSerializer):
|
|
||||||
def validate(self, attrs):
|
|
||||||
data = super().validate(attrs)
|
|
||||||
if not self.user.is_staff:
|
|
||||||
from rest_framework.exceptions import AuthenticationFailed
|
|
||||||
raise AuthenticationFailed("Compte administrateur requis.")
|
|
||||||
data["user"] = {"username": self.user.username, "first_name": self.user.first_name, "last_name": self.user.last_name, "is_staff": True}
|
|
||||||
return data
|
|
||||||
|
|
||||||
class AdminTokenView(TokenObtainPairView):
|
|
||||||
serializer_class = AdminTokenSerializer
|
|
||||||
|
|
||||||
class PublicScheduleView(APIView):
|
|
||||||
permission_classes = [AllowAny]
|
|
||||||
def get(self, request):
|
|
||||||
qs = ScheduleEntry.objects.all()
|
|
||||||
start, end = request.query_params.get("start"), request.query_params.get("end")
|
|
||||||
if start and end:
|
|
||||||
qs = qs.filter(start_date__lte=end, end_date__gte=start)
|
|
||||||
return Response(ScheduleEntrySerializer(qs, many=True).data)
|
|
||||||
|
|
||||||
class PublicAvailabilityRequestCreateView(APIView):
|
|
||||||
permission_classes = [AllowAny]
|
|
||||||
def post(self, request):
|
|
||||||
ip = client_ip(request)
|
|
||||||
if ip and AvailabilityRequest.objects.filter(requester_ip=ip, requested_at__gte=timezone.now()-timedelta(seconds=45)).exists():
|
|
||||||
raise Throttled(detail="Une demande vient déjà d'être envoyée. Réessaie dans quelques secondes.")
|
|
||||||
s = AvailabilityRequestCreateSerializer(data=request.data)
|
|
||||||
s.is_valid(raise_exception=True)
|
|
||||||
item = s.save(requester_ip=ip)
|
|
||||||
site = os.getenv("PUBLIC_SITE_URL", "").rstrip("/")
|
|
||||||
msg = f"{item.guest_name} demande si tu es disponible maintenant."
|
|
||||||
if item.message:
|
|
||||||
msg += f"\n{item.message}"
|
|
||||||
send_ntfy_notification("Nouvelle demande de disponibilité", msg, f"{site}/admin/requests" if site else None)
|
|
||||||
return Response(AvailabilityRequestPublicSerializer(item).data, status=status.HTTP_201_CREATED)
|
|
||||||
|
|
||||||
class PublicAvailabilityRequestDetailView(APIView):
|
|
||||||
permission_classes = [AllowAny]
|
|
||||||
def get(self, request, token):
|
|
||||||
try:
|
|
||||||
item = AvailabilityRequest.objects.get(public_token=token)
|
|
||||||
except AvailabilityRequest.DoesNotExist:
|
|
||||||
return Response({"detail": "Demande introuvable."}, status=404)
|
|
||||||
return Response(AvailabilityRequestPublicSerializer(item).data)
|
|
||||||
|
|
||||||
class AdminScheduleViewSet(viewsets.ModelViewSet):
|
|
||||||
serializer_class = ScheduleEntrySerializer
|
|
||||||
permission_classes = [IsAdminUser]
|
|
||||||
def get_queryset(self):
|
|
||||||
qs = ScheduleEntry.objects.all()
|
|
||||||
start, end = self.request.query_params.get("start"), self.request.query_params.get("end")
|
|
||||||
return qs.filter(start_date__lte=end, end_date__gte=start) if start and end else qs
|
|
||||||
|
|
||||||
class AdminAvailabilityRequestViewSet(viewsets.ReadOnlyModelViewSet):
|
|
||||||
serializer_class = AvailabilityRequestAdminSerializer
|
|
||||||
permission_classes = [IsAdminUser]
|
|
||||||
def get_queryset(self):
|
|
||||||
qs = AvailabilityRequest.objects.all()
|
|
||||||
status_filter = self.request.query_params.get("status")
|
|
||||||
return qs.filter(status=status_filter) if status_filter else qs
|
|
||||||
def respond(self, request, pk=None):
|
|
||||||
item = self.get_object()
|
|
||||||
s = AvailabilityResponseSerializer(data=request.data)
|
|
||||||
s.is_valid(raise_exception=True)
|
|
||||||
new_status = s.validated_data["status"]
|
|
||||||
duration = s.validated_data.get("duration_minutes")
|
|
||||||
item.status = new_status
|
|
||||||
item.duration_minutes = duration if new_status == "accepted" else None
|
|
||||||
item.available_until = timezone.now()+timedelta(minutes=duration) if new_status == "accepted" and duration else None
|
|
||||||
item.response_message = s.validated_data.get("response_message", "")
|
|
||||||
item.responded_at = timezone.now()
|
|
||||||
item.save()
|
|
||||||
return Response(AvailabilityRequestAdminSerializer(item).data)
|
|
||||||
|
|
||||||
class AdminMeView(APIView):
|
|
||||||
permission_classes = [IsAdminUser]
|
|
||||||
def get(self, request):
|
|
||||||
u = request.user
|
|
||||||
return Response({"username": u.username, "first_name": u.first_name, "last_name": u.last_name, "is_staff": u.is_staff})
|
|
||||||
16
backend/core/wsgi.py
Normal file
16
backend/core/wsgi.py
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
"""
|
||||||
|
WSGI config for core project.
|
||||||
|
|
||||||
|
It exposes the WSGI callable as a module-level variable named ``application``.
|
||||||
|
|
||||||
|
For more information on this file, see
|
||||||
|
https://docs.djangoproject.com/en/5.2/howto/deployment/wsgi/
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
|
||||||
|
from django.core.wsgi import get_wsgi_application
|
||||||
|
|
||||||
|
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'core.settings')
|
||||||
|
|
||||||
|
application = get_wsgi_application()
|
||||||
@ -1,6 +1,22 @@
|
|||||||
#!/usr/bin/env python
|
#!/usr/bin/env python
|
||||||
import os, sys
|
"""Django's command-line utility for administrative tasks."""
|
||||||
if __name__ == "__main__":
|
import os
|
||||||
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "planning.settings")
|
import sys
|
||||||
|
|
||||||
|
def main():
|
||||||
|
"""Run administrative tasks."""
|
||||||
|
|
||||||
|
# Exécuter les commandes normales
|
||||||
|
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'core.settings')
|
||||||
|
try:
|
||||||
from django.core.management import execute_from_command_line
|
from django.core.management import execute_from_command_line
|
||||||
|
except ImportError as exc:
|
||||||
|
raise ImportError(
|
||||||
|
"Couldn't import Django. Are you sure it's installed and "
|
||||||
|
"available on your PYTHONPATH environment variable? Did you "
|
||||||
|
"forget to activate a virtual environment?"
|
||||||
|
) from exc
|
||||||
execute_from_command_line(sys.argv)
|
execute_from_command_line(sys.argv)
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
main()
|
||||||
@ -1,2 +0,0 @@
|
|||||||
import pymysql
|
|
||||||
pymysql.install_as_MySQLdb()
|
|
||||||
6
backend/planning/apps.py
Normal file
6
backend/planning/apps.py
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
from django.apps import AppConfig
|
||||||
|
|
||||||
|
|
||||||
|
class PlanningConfig(AppConfig):
|
||||||
|
default_auto_field = 'django.db.models.BigAutoField'
|
||||||
|
name = 'planning'
|
||||||
53
backend/planning/migrations/0001_initial.py
Normal file
53
backend/planning/migrations/0001_initial.py
Normal file
@ -0,0 +1,53 @@
|
|||||||
|
# Generated manually for the initial Planning schema.
|
||||||
|
import uuid
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
initial = True
|
||||||
|
dependencies = []
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='AvailabilityRequest',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('public_token', models.UUIDField(default=uuid.uuid4, editable=False, unique=True)),
|
||||||
|
('guest_name', models.CharField(max_length=80)),
|
||||||
|
('message', models.CharField(blank=True, max_length=500)),
|
||||||
|
('status', models.CharField(choices=[('pending', 'En attente'), ('accepted', 'Acceptée'), ('refused', 'Refusée')], default='pending', max_length=20)),
|
||||||
|
('duration_minutes', models.PositiveIntegerField(blank=True, null=True)),
|
||||||
|
('available_until', models.DateTimeField(blank=True, null=True)),
|
||||||
|
('response_message', models.CharField(blank=True, max_length=500)),
|
||||||
|
('requested_at', models.DateTimeField(auto_now_add=True)),
|
||||||
|
('responded_at', models.DateTimeField(blank=True, null=True)),
|
||||||
|
('requester_ip', models.GenericIPAddressField(blank=True, null=True)),
|
||||||
|
],
|
||||||
|
options={'ordering': ['-requested_at']},
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='ScheduleEntry',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('title', models.CharField(blank=True, max_length=120)),
|
||||||
|
('description', models.TextField(blank=True)),
|
||||||
|
('start_date', models.DateField()),
|
||||||
|
('end_date', models.DateField()),
|
||||||
|
('start_time', models.TimeField()),
|
||||||
|
('end_time', models.TimeField()),
|
||||||
|
('status', models.CharField(choices=[('busy', 'Occupé'), ('free', 'Disponible'), ('unavailable', 'Indisponible')], default='busy', max_length=20)),
|
||||||
|
('color', models.CharField(blank=True, default='', max_length=16)),
|
||||||
|
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||||
|
('updated_at', models.DateTimeField(auto_now=True)),
|
||||||
|
],
|
||||||
|
options={'ordering': ['start_date', 'start_time']},
|
||||||
|
),
|
||||||
|
migrations.AddIndex(
|
||||||
|
model_name='availabilityrequest',
|
||||||
|
index=models.Index(fields=['status', 'requested_at'], name='planning_request_status'),
|
||||||
|
),
|
||||||
|
migrations.AddIndex(
|
||||||
|
model_name='scheduleentry',
|
||||||
|
index=models.Index(fields=['start_date', 'end_date'], name='planning_schedule_dates'),
|
||||||
|
),
|
||||||
|
]
|
||||||
@ -1,34 +1,48 @@
|
|||||||
import uuid
|
import uuid
|
||||||
from django.db import models
|
from django.db import models
|
||||||
|
|
||||||
|
|
||||||
class ScheduleEntry(models.Model):
|
class ScheduleEntry(models.Model):
|
||||||
STATUS_CHOICES = [("busy", "Occupé"), ("free", "Disponible"), ("unavailable", "Indisponible")]
|
STATUS_CHOICES = [
|
||||||
|
('busy', 'Occupé'),
|
||||||
|
('free', 'Disponible'),
|
||||||
|
('unavailable', 'Indisponible'),
|
||||||
|
]
|
||||||
|
|
||||||
title = models.CharField(max_length=120, blank=True)
|
title = models.CharField(max_length=120, blank=True)
|
||||||
description = models.TextField(blank=True)
|
description = models.TextField(blank=True)
|
||||||
start_date = models.DateField()
|
start_date = models.DateField()
|
||||||
end_date = models.DateField()
|
end_date = models.DateField()
|
||||||
start_time = models.TimeField()
|
start_time = models.TimeField()
|
||||||
end_time = models.TimeField()
|
end_time = models.TimeField()
|
||||||
status = models.CharField(max_length=20, choices=STATUS_CHOICES, default="busy")
|
status = models.CharField(max_length=20, choices=STATUS_CHOICES, default='busy')
|
||||||
color = models.CharField(max_length=16, blank=True, default="")
|
color = models.CharField(max_length=16, blank=True, default='')
|
||||||
created_at = models.DateTimeField(auto_now_add=True)
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
updated_at = models.DateTimeField(auto_now=True)
|
updated_at = models.DateTimeField(auto_now=True)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
ordering = ["start_date", "start_time"]
|
ordering = ['start_date', 'start_time']
|
||||||
indexes = [models.Index(fields=["start_date", "end_date"], name="core_sched_dates_idx")]
|
indexes = [models.Index(fields=['start_date', 'end_date'], name='planning_schedule_dates')]
|
||||||
|
|
||||||
|
|
||||||
class AvailabilityRequest(models.Model):
|
class AvailabilityRequest(models.Model):
|
||||||
STATUS_CHOICES = [("pending", "En attente"), ("accepted", "Acceptée"), ("refused", "Refusée")]
|
STATUS_CHOICES = [
|
||||||
|
('pending', 'En attente'),
|
||||||
|
('accepted', 'Acceptée'),
|
||||||
|
('refused', 'Refusée'),
|
||||||
|
]
|
||||||
|
|
||||||
public_token = models.UUIDField(default=uuid.uuid4, unique=True, editable=False)
|
public_token = models.UUIDField(default=uuid.uuid4, unique=True, editable=False)
|
||||||
guest_name = models.CharField(max_length=80)
|
guest_name = models.CharField(max_length=80)
|
||||||
message = models.CharField(max_length=500, blank=True)
|
message = models.CharField(max_length=500, blank=True)
|
||||||
status = models.CharField(max_length=20, choices=STATUS_CHOICES, default="pending")
|
status = models.CharField(max_length=20, choices=STATUS_CHOICES, default='pending')
|
||||||
duration_minutes = models.PositiveIntegerField(null=True, blank=True)
|
duration_minutes = models.PositiveIntegerField(null=True, blank=True)
|
||||||
available_until = models.DateTimeField(null=True, blank=True)
|
available_until = models.DateTimeField(null=True, blank=True)
|
||||||
response_message = models.CharField(max_length=500, blank=True)
|
response_message = models.CharField(max_length=500, blank=True)
|
||||||
requested_at = models.DateTimeField(auto_now_add=True)
|
requested_at = models.DateTimeField(auto_now_add=True)
|
||||||
responded_at = models.DateTimeField(null=True, blank=True)
|
responded_at = models.DateTimeField(null=True, blank=True)
|
||||||
requester_ip = models.GenericIPAddressField(null=True, blank=True)
|
requester_ip = models.GenericIPAddressField(null=True, blank=True)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
ordering = ["-requested_at"]
|
ordering = ['-requested_at']
|
||||||
indexes = [models.Index(fields=["status", "requested_at"], name="core_req_status_idx")]
|
indexes = [models.Index(fields=['status', 'requested_at'], name='planning_request_status')]
|
||||||
26
backend/planning/notifications.py
Normal file
26
backend/planning/notifications.py
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
import os
|
||||||
|
import urllib.error
|
||||||
|
import urllib.request
|
||||||
|
|
||||||
|
|
||||||
|
def send_ntfy_notification(title, message, click_url=None):
|
||||||
|
server = os.getenv('NTFY_SERVER', '').rstrip('/')
|
||||||
|
topic = os.getenv('NTFY_TOPIC', '').strip()
|
||||||
|
if not server or not topic:
|
||||||
|
return False
|
||||||
|
|
||||||
|
request = urllib.request.Request(
|
||||||
|
f'{server}/{topic}',
|
||||||
|
data=message.encode('utf-8'),
|
||||||
|
method='POST',
|
||||||
|
)
|
||||||
|
request.add_header('Title', title)
|
||||||
|
request.add_header('Tags', 'calendar,question')
|
||||||
|
if click_url:
|
||||||
|
request.add_header('Click', click_url)
|
||||||
|
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(request, timeout=5):
|
||||||
|
return True
|
||||||
|
except (urllib.error.URLError, TimeoutError):
|
||||||
|
return False
|
||||||
58
backend/planning/serializers.py
Normal file
58
backend/planning/serializers.py
Normal file
@ -0,0 +1,58 @@
|
|||||||
|
from rest_framework import serializers
|
||||||
|
from .models import AvailabilityRequest, ScheduleEntry
|
||||||
|
|
||||||
|
|
||||||
|
class ScheduleEntrySerializer(serializers.ModelSerializer):
|
||||||
|
status_label = serializers.CharField(source='get_status_display', read_only=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = ScheduleEntry
|
||||||
|
fields = '__all__'
|
||||||
|
read_only_fields = ['created_at', 'updated_at']
|
||||||
|
|
||||||
|
def validate(self, attrs):
|
||||||
|
start_date = attrs.get('start_date', getattr(self.instance, 'start_date', None))
|
||||||
|
end_date = attrs.get('end_date', getattr(self.instance, 'end_date', None))
|
||||||
|
start_time = attrs.get('start_time', getattr(self.instance, 'start_time', None))
|
||||||
|
end_time = attrs.get('end_time', getattr(self.instance, 'end_time', None))
|
||||||
|
|
||||||
|
if start_date and end_date and end_date < start_date:
|
||||||
|
raise serializers.ValidationError({'end_date': 'La date de fin doit être après la date de début.'})
|
||||||
|
if start_time and end_time and end_time <= start_time:
|
||||||
|
raise serializers.ValidationError({'end_time': "L'heure de fin doit être après l'heure de début."})
|
||||||
|
return attrs
|
||||||
|
|
||||||
|
|
||||||
|
class AvailabilityRequestCreateSerializer(serializers.ModelSerializer):
|
||||||
|
class Meta:
|
||||||
|
model = AvailabilityRequest
|
||||||
|
fields = ['guest_name', 'message']
|
||||||
|
|
||||||
|
def validate_guest_name(self, value):
|
||||||
|
value = value.strip()
|
||||||
|
if len(value) < 2:
|
||||||
|
raise serializers.ValidationError('Indique ton prénom ou ton nom.')
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
class AvailabilityRequestPublicSerializer(serializers.ModelSerializer):
|
||||||
|
status_label = serializers.CharField(source='get_status_display', read_only=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = AvailabilityRequest
|
||||||
|
fields = [
|
||||||
|
'public_token', 'guest_name', 'message', 'status', 'status_label',
|
||||||
|
'duration_minutes', 'available_until', 'response_message',
|
||||||
|
'requested_at', 'responded_at',
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
class AvailabilityRequestAdminSerializer(AvailabilityRequestPublicSerializer):
|
||||||
|
class Meta(AvailabilityRequestPublicSerializer.Meta):
|
||||||
|
fields = ['id'] + AvailabilityRequestPublicSerializer.Meta.fields
|
||||||
|
|
||||||
|
|
||||||
|
class AvailabilityResponseSerializer(serializers.Serializer):
|
||||||
|
status = serializers.ChoiceField(choices=['accepted', 'refused'])
|
||||||
|
duration_minutes = serializers.IntegerField(required=False, allow_null=True, min_value=1, max_value=1440)
|
||||||
|
response_message = serializers.CharField(required=False, allow_blank=True, max_length=500)
|
||||||
221
backend/planning/services.py
Normal file
221
backend/planning/services.py
Normal file
@ -0,0 +1,221 @@
|
|||||||
|
"""Calcul unique des horaires prévus, partagé par la gestion et le récapitulatif."""
|
||||||
|
from copy import deepcopy
|
||||||
|
from datetime import date, datetime, time, timedelta
|
||||||
|
from math import lcm
|
||||||
|
|
||||||
|
from django.contrib.auth import get_user_model
|
||||||
|
from django.db import transaction
|
||||||
|
from rest_framework.exceptions import APIException, ValidationError
|
||||||
|
|
||||||
|
from .models import PlanningState, WorkSchedule, WorkScheduleSlot, ScheduleException, ExceptionSlot
|
||||||
|
|
||||||
|
|
||||||
|
class PlanningConflict(APIException):
|
||||||
|
status_code = 409
|
||||||
|
default_detail = 'Le planning a changé. Actualisez avant de réessayer.'
|
||||||
|
|
||||||
|
|
||||||
|
def lock_users(ids):
|
||||||
|
# Ordre stable pour un échange entre deux salariés (évite les interblocages).
|
||||||
|
list(get_user_model().objects.select_for_update().filter(pk__in=sorted(set(ids))).order_by('pk'))
|
||||||
|
|
||||||
|
|
||||||
|
def state_for(user_id):
|
||||||
|
return PlanningState.objects.get_or_create(user_id=user_id)[0]
|
||||||
|
|
||||||
|
|
||||||
|
def check_revision(user_id, expected):
|
||||||
|
state = state_for(user_id)
|
||||||
|
if state.revision != expected:
|
||||||
|
raise PlanningConflict()
|
||||||
|
return state
|
||||||
|
|
||||||
|
|
||||||
|
def bump_revision(user_id):
|
||||||
|
state = state_for(user_id)
|
||||||
|
state.revision += 1
|
||||||
|
state.save(update_fields=['revision'])
|
||||||
|
|
||||||
|
|
||||||
|
def slot_data(slot):
|
||||||
|
return dict(start_time=slot.start_time.strftime('%H:%M'), end_time=slot.end_time.strftime('%H:%M'), end_day_offset=slot.end_day_offset)
|
||||||
|
|
||||||
|
|
||||||
|
def date_range(start, end):
|
||||||
|
while start <= end:
|
||||||
|
yield start
|
||||||
|
start += timedelta(days=1)
|
||||||
|
|
||||||
|
|
||||||
|
def load_plan(user_id):
|
||||||
|
return (
|
||||||
|
list(WorkSchedule.objects.filter(user_id=user_id).prefetch_related('slots')),
|
||||||
|
list(ScheduleException.objects.filter(user_id=user_id).prefetch_related('slots')),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def rule_matches(rule, day):
|
||||||
|
if day < rule.start_date or (rule.end_date and day > rule.end_date):
|
||||||
|
return False
|
||||||
|
if not rule.recurring:
|
||||||
|
return True
|
||||||
|
anchor = rule.start_date - timedelta(days=rule.start_date.weekday())
|
||||||
|
return day.weekday() in rule.weekdays and ((day - anchor).days // 7) % rule.every_weeks == 0
|
||||||
|
|
||||||
|
|
||||||
|
def effective_day(user_id, day, plan=None):
|
||||||
|
schedules, rules = plan if plan is not None else load_plan(user_id)
|
||||||
|
matches = [r for r in rules if rule_matches(r, day)]
|
||||||
|
# Ponctuel > récurrent > semaine type ; à priorité égale, dernière création.
|
||||||
|
rule = max(matches, key=lambda r: (not r.recurring, r.pk), default=None)
|
||||||
|
if rule:
|
||||||
|
kind, label = rule.kind, rule.label or rule.get_kind_display()
|
||||||
|
slots = [slot_data(s) for s in rule.slots.all()] if kind == 'work' else []
|
||||||
|
source = 'exception'
|
||||||
|
else:
|
||||||
|
schedule = next((s for s in schedules if s.valid_from <= day and (not s.valid_to or s.valid_to >= day)), None)
|
||||||
|
anchor = schedule.valid_from - timedelta(days=schedule.valid_from.weekday()) if schedule else day
|
||||||
|
week_index = ((day - anchor).days // 7) % schedule.cycle_weeks if schedule else 0
|
||||||
|
slots = [slot_data(s) for s in schedule.slots.all() if s.weekday == day.weekday() and s.week_index == week_index] if schedule else []
|
||||||
|
kind = 'work' if slots else ('rest' if schedule else 'undefined')
|
||||||
|
label = 'Emploi du temps habituel' if slots else ('Repos' if schedule else 'Emploi du temps non défini')
|
||||||
|
source = 'schedule' if schedule else 'none'
|
||||||
|
return dict(user_id=user_id, date=day.isoformat(), kind=kind, label=label, source=source, slots=slots)
|
||||||
|
|
||||||
|
|
||||||
|
def period(slot, day):
|
||||||
|
return (datetime.combine(day, time.fromisoformat(slot['start_time'])),
|
||||||
|
datetime.combine(day + timedelta(days=slot.get('end_day_offset', 0)), time.fromisoformat(slot['end_time'])))
|
||||||
|
|
||||||
|
|
||||||
|
def validate_calendar(user_id):
|
||||||
|
"""Vérifie une période complète de répétition entre chaque changement de règle.
|
||||||
|
|
||||||
|
Le PPCM combine le cycle actif et les exceptions récurrentes. On contrôle
|
||||||
|
aussi la jonction avec le jour précédent et le cycle suivant.
|
||||||
|
"""
|
||||||
|
plan = load_plan(user_id)
|
||||||
|
boundaries = set()
|
||||||
|
for schedule in plan[0]:
|
||||||
|
boundaries.add(schedule.valid_from)
|
||||||
|
if schedule.valid_to:
|
||||||
|
boundaries.add(schedule.valid_to + timedelta(days=1))
|
||||||
|
for rule in plan[1]:
|
||||||
|
boundaries.add(rule.start_date)
|
||||||
|
if rule.end_date:
|
||||||
|
boundaries.add(rule.end_date + timedelta(days=1))
|
||||||
|
days = set()
|
||||||
|
ordered = sorted(boundaries)
|
||||||
|
horizon = date(2100, 1, 1)
|
||||||
|
for index, boundary in enumerate(ordered):
|
||||||
|
following = ordered[index + 1] if index + 1 < len(ordered) else horizon
|
||||||
|
active = next((s for s in plan[0] if s.valid_from <= boundary and (not s.valid_to or s.valid_to >= boundary)), None)
|
||||||
|
cycle = active.cycle_weeks if active else 1
|
||||||
|
for rule in plan[1]:
|
||||||
|
if rule.recurring and rule.start_date <= boundary and (not rule.end_date or rule.end_date >= boundary):
|
||||||
|
cycle = lcm(cycle, rule.every_weeks)
|
||||||
|
finish = min(following, boundary + timedelta(weeks=cycle))
|
||||||
|
days.update(date_range(boundary - timedelta(days=1), finish))
|
||||||
|
cache = {}
|
||||||
|
def periods(day):
|
||||||
|
if day not in cache:
|
||||||
|
cache[day] = [period(s, day) for s in effective_day(user_id, day, plan)['slots']]
|
||||||
|
return cache[day]
|
||||||
|
for day in sorted(days):
|
||||||
|
intervals = sorted(periods(day) + periods(day + timedelta(days=1)))
|
||||||
|
for previous, current in zip(intervals, intervals[1:]):
|
||||||
|
if current[0] < previous[1]:
|
||||||
|
raise ValidationError({'slots': f'Chevauchement d’horaires le {current[0]:%d/%m/%Y}. Vérifiez aussi la veille et les horaires après minuit.'})
|
||||||
|
|
||||||
|
|
||||||
|
def effective_period(user_id, start, end):
|
||||||
|
plan = load_plan(user_id)
|
||||||
|
days = [effective_day(user_id, day, plan) for day in date_range(start, end)]
|
||||||
|
segments = []
|
||||||
|
# Une vacation commencée la veille peut continuer dans la période demandée.
|
||||||
|
for day in date_range(start - timedelta(days=1), end):
|
||||||
|
data = effective_day(user_id, day, plan)
|
||||||
|
for index, slot in enumerate(data['slots']):
|
||||||
|
begin, finish = period(slot, day)
|
||||||
|
cursor = begin
|
||||||
|
while cursor < finish:
|
||||||
|
boundary = datetime.combine(cursor.date() + timedelta(days=1), time.min)
|
||||||
|
stop = min(finish, boundary)
|
||||||
|
if start <= cursor.date() <= end:
|
||||||
|
segments.append(dict(
|
||||||
|
id=f'{user_id}-{day}-{index}-{cursor.date()}', date=cursor.date().isoformat(),
|
||||||
|
start_time=cursor.strftime('%H:%M'), end_time='24:00' if stop == boundary else stop.strftime('%H:%M'),
|
||||||
|
label=data['label'], source=data['source'], user_id=user_id,
|
||||||
|
))
|
||||||
|
cursor = stop
|
||||||
|
return dict(user_id=user_id, days=days, segments=segments)
|
||||||
|
|
||||||
|
|
||||||
|
@transaction.atomic
|
||||||
|
def save_schedule(actor, data):
|
||||||
|
user = data['user']
|
||||||
|
lock_users([user.pk])
|
||||||
|
check_revision(user.pk, data['expected_revision'])
|
||||||
|
start = data['valid_from']
|
||||||
|
schedule = WorkSchedule.objects.filter(user=user, valid_from=start).first()
|
||||||
|
if schedule is None:
|
||||||
|
previous = WorkSchedule.objects.filter(user=user, valid_from__lt=start).order_by('-valid_from').first()
|
||||||
|
following = WorkSchedule.objects.filter(user=user, valid_from__gt=start).order_by('valid_from').first()
|
||||||
|
if previous:
|
||||||
|
previous.valid_to = start - timedelta(days=1)
|
||||||
|
previous.save(update_fields=['valid_to', 'updated_at'])
|
||||||
|
schedule = WorkSchedule.objects.create(user=user, valid_from=start, valid_to=following.valid_from - timedelta(days=1) if following else None, created_by=actor)
|
||||||
|
schedule.slots.all().delete()
|
||||||
|
schedule.cycle_weeks = data.get('cycle_weeks', 1)
|
||||||
|
WorkScheduleSlot.objects.bulk_create([WorkScheduleSlot(schedule=schedule, **slot) for slot in data['slots']])
|
||||||
|
schedule.save()
|
||||||
|
validate_calendar(user.pk)
|
||||||
|
bump_revision(user.pk)
|
||||||
|
return schedule
|
||||||
|
|
||||||
|
|
||||||
|
@transaction.atomic
|
||||||
|
def save_exception(actor, data, instance=None):
|
||||||
|
user = instance.user if instance else data['user']
|
||||||
|
lock_users([user.pk])
|
||||||
|
check_revision(user.pk, data['expected_revision'])
|
||||||
|
values = {key: data[key] for key in ['label', 'kind', 'start_date', 'end_date', 'recurring', 'weekdays', 'every_weeks']}
|
||||||
|
if instance:
|
||||||
|
instance = ScheduleException.objects.select_for_update().get(pk=instance.pk)
|
||||||
|
for key, value in values.items():
|
||||||
|
setattr(instance, key, value)
|
||||||
|
instance.save()
|
||||||
|
else:
|
||||||
|
instance = ScheduleException.objects.create(user=user, created_by=actor, **values)
|
||||||
|
instance.slots.all().delete()
|
||||||
|
ExceptionSlot.objects.bulk_create([ExceptionSlot(exception=instance, **slot) for slot in data['slots']])
|
||||||
|
validate_calendar(user.pk)
|
||||||
|
bump_revision(user.pk)
|
||||||
|
return instance
|
||||||
|
|
||||||
|
|
||||||
|
def snapshot(user_id, day):
|
||||||
|
result = effective_day(user_id, day)
|
||||||
|
# L'identité de la règle ne change pas le sens de la demande.
|
||||||
|
return {key: result[key] for key in ['user_id', 'date', 'kind', 'slots']}
|
||||||
|
|
||||||
|
|
||||||
|
def request_changes(user, data):
|
||||||
|
before = [snapshot(user.pk, day) for day in date_range(data['start_date'], data['end_date'])]
|
||||||
|
if data['kind'] == 'swap':
|
||||||
|
other = data.get('target_user') or user
|
||||||
|
other_day = data['target_date']
|
||||||
|
before.append(snapshot(other.pk, other_day))
|
||||||
|
if any(row['kind'] in ('absence', 'undefined') for row in before):
|
||||||
|
raise ValidationError('Une inversion nécessite deux journées définies, travaillées ou de repos.')
|
||||||
|
if not any(row['slots'] for row in before):
|
||||||
|
raise ValidationError('Au moins une des deux journées doit être travaillée.')
|
||||||
|
after = deepcopy(before)
|
||||||
|
after[0]['kind'], after[0]['slots'] = before[1]['kind'], before[1]['slots']
|
||||||
|
after[1]['kind'], after[1]['slots'] = before[0]['kind'], before[0]['slots']
|
||||||
|
else:
|
||||||
|
after = deepcopy(before)
|
||||||
|
for row in after:
|
||||||
|
row['kind'] = 'absence' if data['kind'] == 'leave' else 'work'
|
||||||
|
row['slots'] = [] if data['kind'] == 'leave' else data['proposed_slots']
|
||||||
|
return before, after
|
||||||
@ -1,72 +0,0 @@
|
|||||||
from pathlib import Path
|
|
||||||
from datetime import timedelta
|
|
||||||
import os
|
|
||||||
|
|
||||||
BASE_DIR = Path(__file__).resolve().parent.parent
|
|
||||||
SECRET_KEY = os.getenv("SECRET_KEY", "dev-secret")
|
|
||||||
DEBUG = os.getenv("DEBUG", "False").lower() == "true"
|
|
||||||
|
|
||||||
def csv_env(name, default=""):
|
|
||||||
return [x.strip() for x in os.getenv(name, default).split(",") if x.strip()]
|
|
||||||
|
|
||||||
ALLOWED_HOSTS = csv_env("ALLOWED_HOSTS", "localhost,127.0.0.1")
|
|
||||||
CSRF_TRUSTED_ORIGINS = csv_env("CSRF_TRUSTED_ORIGINS")
|
|
||||||
CORS_ALLOWED_ORIGINS = csv_env("CORS_ALLOWED_ORIGINS")
|
|
||||||
CORS_ALLOW_CREDENTIALS = True
|
|
||||||
|
|
||||||
INSTALLED_APPS = [
|
|
||||||
"django.contrib.auth", "django.contrib.contenttypes", "django.contrib.sessions",
|
|
||||||
"django.contrib.messages", "django.contrib.staticfiles", "corsheaders",
|
|
||||||
"rest_framework", "core",
|
|
||||||
]
|
|
||||||
MIDDLEWARE = [
|
|
||||||
"django.middleware.security.SecurityMiddleware",
|
|
||||||
"corsheaders.middleware.CorsMiddleware",
|
|
||||||
"django.contrib.sessions.middleware.SessionMiddleware",
|
|
||||||
"django.middleware.common.CommonMiddleware",
|
|
||||||
"django.middleware.csrf.CsrfViewMiddleware",
|
|
||||||
"django.contrib.auth.middleware.AuthenticationMiddleware",
|
|
||||||
"django.contrib.messages.middleware.MessageMiddleware",
|
|
||||||
"django.middleware.clickjacking.XFrameOptionsMiddleware",
|
|
||||||
]
|
|
||||||
ROOT_URLCONF = "planning.urls"
|
|
||||||
TEMPLATES = [{
|
|
||||||
"BACKEND": "django.template.backends.django.DjangoTemplates",
|
|
||||||
"DIRS": [], "APP_DIRS": True,
|
|
||||||
"OPTIONS": {"context_processors": [
|
|
||||||
"django.template.context_processors.request",
|
|
||||||
"django.contrib.auth.context_processors.auth",
|
|
||||||
"django.contrib.messages.context_processors.messages",
|
|
||||||
]},
|
|
||||||
}]
|
|
||||||
WSGI_APPLICATION = "planning.wsgi.application"
|
|
||||||
|
|
||||||
DATABASES = {"default": {
|
|
||||||
"ENGINE": "django.db.backends.mysql",
|
|
||||||
"NAME": os.getenv("MYSQL_DATABASE", "planning"),
|
|
||||||
"USER": os.getenv("MYSQL_USER", "planning"),
|
|
||||||
"PASSWORD": os.getenv("MYSQL_PASSWORD", ""),
|
|
||||||
"HOST": os.getenv("MYSQL_HOST", "127.0.0.1"),
|
|
||||||
"PORT": os.getenv("MYSQL_PORT", "3306"),
|
|
||||||
"OPTIONS": {"charset": "utf8mb4", "init_command": "SET sql_mode='STRICT_TRANS_TABLES'"},
|
|
||||||
}}
|
|
||||||
AUTH_PASSWORD_VALIDATORS = []
|
|
||||||
LANGUAGE_CODE = "fr-fr"
|
|
||||||
TIME_ZONE = "Europe/Paris"
|
|
||||||
USE_I18N = True
|
|
||||||
USE_TZ = True
|
|
||||||
STATIC_URL = "static/"
|
|
||||||
DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField"
|
|
||||||
REST_FRAMEWORK = {
|
|
||||||
"DEFAULT_AUTHENTICATION_CLASSES": ("rest_framework_simplejwt.authentication.JWTAuthentication",),
|
|
||||||
"DEFAULT_PERMISSION_CLASSES": ("rest_framework.permissions.IsAuthenticated",),
|
|
||||||
"DEFAULT_RENDERER_CLASSES": ("rest_framework.renderers.JSONRenderer",),
|
|
||||||
}
|
|
||||||
SIMPLE_JWT = {
|
|
||||||
"ACCESS_TOKEN_LIFETIME": timedelta(hours=12),
|
|
||||||
"REFRESH_TOKEN_LIFETIME": timedelta(days=14),
|
|
||||||
"ROTATE_REFRESH_TOKENS": True,
|
|
||||||
}
|
|
||||||
SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https")
|
|
||||||
SESSION_COOKIE_SECURE = not DEBUG
|
|
||||||
CSRF_COOKIE_SECURE = not DEBUG
|
|
||||||
24
backend/planning/tests.py
Normal file
24
backend/planning/tests.py
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
from django.contrib.auth import get_user_model
|
||||||
|
from django.test import TestCase
|
||||||
|
from rest_framework.test import APIClient
|
||||||
|
|
||||||
|
from .models import ScheduleEntry
|
||||||
|
|
||||||
|
|
||||||
|
class PlanningApiTests(TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.client = APIClient()
|
||||||
|
self.admin = get_user_model().objects.create_superuser('admin', password='test-password')
|
||||||
|
|
||||||
|
def test_public_schedule_is_public(self):
|
||||||
|
ScheduleEntry.objects.create(
|
||||||
|
start_date='2026-10-01', end_date='2026-10-01',
|
||||||
|
start_time='08:00', end_time='12:00', status='busy',
|
||||||
|
)
|
||||||
|
response = self.client.get('/api/planning/public/schedule/?start=2026-10-01&end=2026-10-07')
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self.assertEqual(len(response.json()), 1)
|
||||||
|
|
||||||
|
def test_admin_schedule_requires_auth(self):
|
||||||
|
response = self.client.get('/api/planning/schedule/')
|
||||||
|
self.assertEqual(response.status_code, 401)
|
||||||
@ -1,8 +1,22 @@
|
|||||||
from django.urls import include, path
|
from django.urls import include, path
|
||||||
from rest_framework_simplejwt.views import TokenRefreshView
|
from rest_framework.routers import DefaultRouter
|
||||||
from core.views import AdminTokenView
|
|
||||||
|
from .views import (
|
||||||
|
AdminAvailabilityRequestViewSet,
|
||||||
|
AdminScheduleViewSet,
|
||||||
|
PublicAvailabilityRequestCreateView,
|
||||||
|
PublicAvailabilityRequestDetailView,
|
||||||
|
PublicScheduleView,
|
||||||
|
)
|
||||||
|
|
||||||
|
router = DefaultRouter()
|
||||||
|
router.register(r'schedule', AdminScheduleViewSet, basename='schedule')
|
||||||
|
router.register(r'requests', AdminAvailabilityRequestViewSet, basename='requests')
|
||||||
|
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
path("api/auth/token/", AdminTokenView.as_view()),
|
path('public/schedule/', PublicScheduleView.as_view(), name='public-schedule'),
|
||||||
path("api/auth/token/refresh/", TokenRefreshView.as_view()),
|
path('public/requests/', PublicAvailabilityRequestCreateView.as_view(), name='public-request-create'),
|
||||||
path("api/", include("core.urls")),
|
path('public/requests/<uuid:token>/', PublicAvailabilityRequestDetailView.as_view(), name='public-request-detail'),
|
||||||
|
path('requests/<int:pk>/respond/', AdminAvailabilityRequestViewSet.as_view({'post': 'respond'}), name='request-respond'),
|
||||||
|
path('', include(router.urls)),
|
||||||
]
|
]
|
||||||
|
|||||||
122
backend/planning/views.py
Normal file
122
backend/planning/views.py
Normal file
@ -0,0 +1,122 @@
|
|||||||
|
from datetime import timedelta
|
||||||
|
import os
|
||||||
|
|
||||||
|
from django.utils import timezone
|
||||||
|
from rest_framework import status, viewsets
|
||||||
|
from rest_framework.exceptions import Throttled
|
||||||
|
from rest_framework.permissions import AllowAny, IsAdminUser
|
||||||
|
from rest_framework.response import Response
|
||||||
|
from rest_framework.views import APIView
|
||||||
|
|
||||||
|
from .models import AvailabilityRequest, ScheduleEntry
|
||||||
|
from .notifications import send_ntfy_notification
|
||||||
|
from .serializers import (
|
||||||
|
AvailabilityRequestAdminSerializer,
|
||||||
|
AvailabilityRequestCreateSerializer,
|
||||||
|
AvailabilityRequestPublicSerializer,
|
||||||
|
AvailabilityResponseSerializer,
|
||||||
|
ScheduleEntrySerializer,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def client_ip(request):
|
||||||
|
forwarded = request.META.get('HTTP_X_FORWARDED_FOR')
|
||||||
|
return forwarded.split(',')[0].strip() if forwarded else request.META.get('REMOTE_ADDR')
|
||||||
|
|
||||||
|
|
||||||
|
def filter_schedule(queryset, request):
|
||||||
|
start = request.query_params.get('start')
|
||||||
|
end = request.query_params.get('end')
|
||||||
|
if start and end:
|
||||||
|
queryset = queryset.filter(start_date__lte=end, end_date__gte=start)
|
||||||
|
return queryset
|
||||||
|
|
||||||
|
|
||||||
|
class PublicScheduleView(APIView):
|
||||||
|
permission_classes = [AllowAny]
|
||||||
|
|
||||||
|
def get(self, request):
|
||||||
|
queryset = filter_schedule(ScheduleEntry.objects.all(), request)
|
||||||
|
return Response(ScheduleEntrySerializer(queryset, many=True).data)
|
||||||
|
|
||||||
|
|
||||||
|
class PublicAvailabilityRequestCreateView(APIView):
|
||||||
|
permission_classes = [AllowAny]
|
||||||
|
|
||||||
|
def post(self, request):
|
||||||
|
ip = client_ip(request)
|
||||||
|
if ip and AvailabilityRequest.objects.filter(
|
||||||
|
requester_ip=ip,
|
||||||
|
requested_at__gte=timezone.now() - timedelta(seconds=45),
|
||||||
|
).exists():
|
||||||
|
raise Throttled(detail="Une demande vient déjà d'être envoyée. Réessaie dans quelques secondes.")
|
||||||
|
|
||||||
|
serializer = AvailabilityRequestCreateSerializer(data=request.data)
|
||||||
|
serializer.is_valid(raise_exception=True)
|
||||||
|
item = serializer.save(requester_ip=ip)
|
||||||
|
|
||||||
|
public_site_url = os.getenv('PUBLIC_SITE_URL', '').rstrip('/')
|
||||||
|
message = f'{item.guest_name} demande si tu es disponible maintenant.'
|
||||||
|
if item.message:
|
||||||
|
message += f'\n{item.message}'
|
||||||
|
send_ntfy_notification(
|
||||||
|
'Nouvelle demande de disponibilité',
|
||||||
|
message,
|
||||||
|
f'{public_site_url}/requests' if public_site_url else None,
|
||||||
|
)
|
||||||
|
|
||||||
|
return Response(
|
||||||
|
AvailabilityRequestPublicSerializer(item).data,
|
||||||
|
status=status.HTTP_201_CREATED,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class PublicAvailabilityRequestDetailView(APIView):
|
||||||
|
permission_classes = [AllowAny]
|
||||||
|
|
||||||
|
def get(self, request, token):
|
||||||
|
try:
|
||||||
|
item = AvailabilityRequest.objects.get(public_token=token)
|
||||||
|
except AvailabilityRequest.DoesNotExist:
|
||||||
|
return Response({'detail': 'Demande introuvable.'}, status=status.HTTP_404_NOT_FOUND)
|
||||||
|
return Response(AvailabilityRequestPublicSerializer(item).data)
|
||||||
|
|
||||||
|
|
||||||
|
class AdminScheduleViewSet(viewsets.ModelViewSet):
|
||||||
|
serializer_class = ScheduleEntrySerializer
|
||||||
|
permission_classes = [IsAdminUser]
|
||||||
|
|
||||||
|
def get_queryset(self):
|
||||||
|
return filter_schedule(ScheduleEntry.objects.all(), self.request)
|
||||||
|
|
||||||
|
|
||||||
|
class AdminAvailabilityRequestViewSet(viewsets.ReadOnlyModelViewSet):
|
||||||
|
serializer_class = AvailabilityRequestAdminSerializer
|
||||||
|
permission_classes = [IsAdminUser]
|
||||||
|
|
||||||
|
def get_queryset(self):
|
||||||
|
queryset = AvailabilityRequest.objects.all()
|
||||||
|
status_filter = self.request.query_params.get('status')
|
||||||
|
if status_filter:
|
||||||
|
queryset = queryset.filter(status=status_filter)
|
||||||
|
return queryset
|
||||||
|
|
||||||
|
def respond(self, request, pk=None):
|
||||||
|
item = self.get_object()
|
||||||
|
serializer = AvailabilityResponseSerializer(data=request.data)
|
||||||
|
serializer.is_valid(raise_exception=True)
|
||||||
|
|
||||||
|
new_status = serializer.validated_data['status']
|
||||||
|
duration = serializer.validated_data.get('duration_minutes')
|
||||||
|
item.status = new_status
|
||||||
|
item.duration_minutes = duration if new_status == 'accepted' else None
|
||||||
|
item.available_until = (
|
||||||
|
timezone.now() + timedelta(minutes=duration)
|
||||||
|
if new_status == 'accepted' and duration
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
item.response_message = serializer.validated_data.get('response_message', '')
|
||||||
|
item.responded_at = timezone.now()
|
||||||
|
item.save()
|
||||||
|
|
||||||
|
return Response(AvailabilityRequestAdminSerializer(item).data)
|
||||||
@ -1,4 +0,0 @@
|
|||||||
import os
|
|
||||||
from django.core.wsgi import get_wsgi_application
|
|
||||||
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "planning.settings")
|
|
||||||
application = get_wsgi_application()
|
|
||||||
@ -1,8 +1,7 @@
|
|||||||
Django==5.2.7
|
django
|
||||||
djangorestframework==3.16.1
|
djangorestframework
|
||||||
djangorestframework-simplejwt==5.5.1
|
djangorestframework-simplejwt
|
||||||
django-cors-headers==4.9.0
|
django-cors-headers
|
||||||
gunicorn==23.0.0
|
mysqlclient
|
||||||
PyMySQL==1.1.2
|
python-decouple
|
||||||
requests==2.32.5
|
dj-database-url
|
||||||
cryptography
|
|
||||||
|
|||||||
6
backend/users/apps.py
Normal file
6
backend/users/apps.py
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
from django.apps import AppConfig
|
||||||
|
|
||||||
|
|
||||||
|
class UsersConfig(AppConfig):
|
||||||
|
default_auto_field = 'django.db.models.BigAutoField'
|
||||||
|
name = 'users'
|
||||||
0
backend/users/management/commands/__init__.py
Normal file
0
backend/users/management/commands/__init__.py
Normal file
27
backend/users/management/commands/ensure_admin.py
Normal file
27
backend/users/management/commands/ensure_admin.py
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
import os
|
||||||
|
|
||||||
|
from django.contrib.auth import get_user_model
|
||||||
|
from django.core.management.base import BaseCommand
|
||||||
|
|
||||||
|
|
||||||
|
class Command(BaseCommand):
|
||||||
|
help = 'Crée ou met à jour le compte administrateur défini dans le .env.'
|
||||||
|
|
||||||
|
def handle(self, *args, **options):
|
||||||
|
username = os.getenv('ADMIN_USERNAME', 'admin')
|
||||||
|
password = os.getenv('ADMIN_PASSWORD', 'admin123')
|
||||||
|
first_name = os.getenv('ADMIN_FIRST_NAME', 'admin')
|
||||||
|
last_name = os.getenv('ADMIN_LAST_NAME', 'admin')
|
||||||
|
|
||||||
|
User = get_user_model()
|
||||||
|
user, created = User.objects.get_or_create(username=username)
|
||||||
|
user.first_name = first_name
|
||||||
|
user.last_name = last_name
|
||||||
|
user.is_staff = True
|
||||||
|
user.is_superuser = True
|
||||||
|
user.is_active = True
|
||||||
|
user.set_password(password)
|
||||||
|
user.save()
|
||||||
|
|
||||||
|
action = 'créé' if created else 'mis à jour'
|
||||||
|
self.stdout.write(self.style.SUCCESS(f'Administrateur {username} {action}.'))
|
||||||
0
backend/users/migrations/__init__.py
Normal file
0
backend/users/migrations/__init__.py
Normal file
6
backend/users/urls.py
Normal file
6
backend/users/urls.py
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
from django.urls import path
|
||||||
|
from .views import UserMeView
|
||||||
|
|
||||||
|
urlpatterns = [
|
||||||
|
path('me/', UserMeView.as_view(), name='me'),
|
||||||
|
]
|
||||||
20
backend/users/views.py
Normal file
20
backend/users/views.py
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
from rest_framework.permissions import IsAuthenticated
|
||||||
|
from rest_framework.response import Response
|
||||||
|
from rest_framework.views import APIView
|
||||||
|
|
||||||
|
|
||||||
|
class UserMeView(APIView):
|
||||||
|
permission_classes = [IsAuthenticated]
|
||||||
|
|
||||||
|
def get(self, request):
|
||||||
|
user = request.user
|
||||||
|
return Response({
|
||||||
|
'id': user.id,
|
||||||
|
'username': user.username,
|
||||||
|
'first_name': user.first_name,
|
||||||
|
'last_name': user.last_name,
|
||||||
|
'email': user.email,
|
||||||
|
'is_staff': user.is_staff,
|
||||||
|
'is_superuser': user.is_superuser,
|
||||||
|
'is_admin': user.is_staff or user.is_superuser,
|
||||||
|
})
|
||||||
8
frontend/.oxlintrc.json
Normal file
8
frontend/.oxlintrc.json
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"$schema": "./node_modules/oxlint/configuration_schema.json",
|
||||||
|
"plugins": ["react", "oxc"],
|
||||||
|
"rules": {
|
||||||
|
"react/rules-of-hooks": "error",
|
||||||
|
"react/only-export-components": ["warn", { "allowConstantExport": true }]
|
||||||
|
}
|
||||||
|
}
|
||||||
25
frontend/components.json
Normal file
25
frontend/components.json
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://ui.shadcn.com/schema.json",
|
||||||
|
"style": "base-nova",
|
||||||
|
"rsc": false,
|
||||||
|
"tsx": false,
|
||||||
|
"tailwind": {
|
||||||
|
"config": "",
|
||||||
|
"css": "src/index.css",
|
||||||
|
"baseColor": "neutral",
|
||||||
|
"cssVariables": true,
|
||||||
|
"prefix": ""
|
||||||
|
},
|
||||||
|
"iconLibrary": "lucide",
|
||||||
|
"rtl": false,
|
||||||
|
"aliases": {
|
||||||
|
"components": "@/components",
|
||||||
|
"utils": "@/lib/utils",
|
||||||
|
"ui": "@/components/ui",
|
||||||
|
"lib": "@/lib",
|
||||||
|
"hooks": "@/hooks"
|
||||||
|
},
|
||||||
|
"menuColor": "default",
|
||||||
|
"menuAccent": "subtle",
|
||||||
|
"registries": {}
|
||||||
|
}
|
||||||
@ -1,13 +1,13 @@
|
|||||||
<!doctype html>
|
<!doctype html>
|
||||||
<html lang="fr">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
|
<link rel="icon" type="text" href="data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 100 100%22><text y=%22.9em%22 font-size=%2290%22>🍞</text></svg>" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<meta name="theme-color" content="#0f172a" />
|
<title>Planning de Louis</title>
|
||||||
<title>Planning</title>
|
</head>
|
||||||
</head>
|
<body>
|
||||||
<body>
|
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
<script type="module" src="/src/main.jsx"></script>
|
<script type="module" src="/src/main.jsx"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
8
frontend/jsconfig.json
Normal file
8
frontend/jsconfig.json
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"paths": {
|
||||||
|
"@/*": ["./src/*"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
@ -1,25 +0,0 @@
|
|||||||
map $http_x_forwarded_proto $planning_forwarded_proto {
|
|
||||||
default $http_x_forwarded_proto;
|
|
||||||
"" $scheme;
|
|
||||||
}
|
|
||||||
|
|
||||||
server {
|
|
||||||
listen 80;
|
|
||||||
server_name _;
|
|
||||||
root /usr/share/nginx/html;
|
|
||||||
index index.html;
|
|
||||||
|
|
||||||
location /api/ {
|
|
||||||
proxy_pass http://backend:8000/api/;
|
|
||||||
proxy_http_version 1.1;
|
|
||||||
proxy_set_header Host $host;
|
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
|
||||||
proxy_set_header X-Forwarded-Proto $planning_forwarded_proto;
|
|
||||||
proxy_set_header X-Forwarded-Host $host;
|
|
||||||
}
|
|
||||||
|
|
||||||
location / {
|
|
||||||
try_files $uri $uri/ /index.html;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
7138
frontend/package-lock.json
generated
Normal file
7138
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
@ -1,25 +1,42 @@
|
|||||||
{
|
{
|
||||||
"name": "planning-frontend",
|
"name": "app",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "1.0.0",
|
"version": "0.0.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite --host 0.0.0.0",
|
"dev": "vite",
|
||||||
"build": "vite build",
|
"build": "vite build",
|
||||||
"preview": "vite preview --host 0.0.0.0"
|
"lint": "oxlint src vite.config.js",
|
||||||
|
"test": "node --test tests/*.test.js",
|
||||||
|
"preview": "vite preview"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"axios": "^1.12.2",
|
"@base-ui/react": "^1.8.0",
|
||||||
"clsx": "^2.1.1",
|
"@fontsource-variable/geist": "^5.3.0",
|
||||||
"lucide-react": "^0.545.0",
|
"@shadcn/react": "^0.3.1",
|
||||||
"react": "^19.1.1",
|
"@tailwindcss/vite": "^4.3.3",
|
||||||
"react-dom": "^19.1.1",
|
"@zxing/browser": "^0.2.1",
|
||||||
"react-router-dom": "^7.9.3"
|
"axios": "^1.20.0",
|
||||||
},
|
"class-variance-authority": "^0.7.1",
|
||||||
"devDependencies": {
|
"cmdk": "^1.1.1",
|
||||||
"@tailwindcss/vite": "^4.1.14",
|
"cn": "^0.3.0",
|
||||||
"@vitejs/plugin-react": "^5.0.4",
|
"date-fns": "^4.4.0",
|
||||||
"tailwindcss": "^4.1.14",
|
"embla-carousel-react": "^8.6.0",
|
||||||
"vite": "^7.1.9"
|
"input-otp": "^1.5.0",
|
||||||
|
"lucide-react": "^1.46.0",
|
||||||
|
"react": "^19.2.8",
|
||||||
|
"react-day-picker": "^10.0.1",
|
||||||
|
"react-dom": "^19.2.8",
|
||||||
|
"react-resizable-panels": "^4.12.4",
|
||||||
|
"react-router-dom": "^7.18.3",
|
||||||
|
"recharts": "^3.8.0",
|
||||||
|
"shadcn": "^4.21.0",
|
||||||
|
"tailwindcss": "^4.3.3",
|
||||||
|
"tw-animate-css": "^1.4.0",
|
||||||
|
"@types/react": "^19.2.18",
|
||||||
|
"@types/react-dom": "^19.2.4",
|
||||||
|
"@vitejs/plugin-react": "^6.1.0",
|
||||||
|
"oxlint": "^1.79.0",
|
||||||
|
"vite": "^8.2.2"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,64 +1,84 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { Navigate, Route, Routes, useLocation } from 'react-router-dom';
|
import { Navigate, Route, Routes } from 'react-router-dom';
|
||||||
import { authApi } from './api';
|
import { Menu } from 'lucide-react';
|
||||||
import { AdminLayout } from './components/layout';
|
import {
|
||||||
|
SidebarInset,
|
||||||
|
SidebarProvider,
|
||||||
|
useSidebar,
|
||||||
|
} from '@/components/ui/sidebar';
|
||||||
|
import { useAuth } from './context/useAuth';
|
||||||
|
import NavBar from './components/NavBar';
|
||||||
|
import AdminRoute from './components/AdminRoute';
|
||||||
|
import UnloggedRoute from './components/UnloggedRoute';
|
||||||
import PublicPage from './pages/PublicPage';
|
import PublicPage from './pages/PublicPage';
|
||||||
import AdminLoginPage from './pages/AdminLoginPage';
|
import LoginPage from './pages/LoginPage';
|
||||||
import AdminPlanningPage from './pages/AdminPlanningPage';
|
import AdminPlanningPage from './pages/AdminPlanningPage';
|
||||||
import AdminRequestsPage from './pages/AdminRequestsPage';
|
import AdminRequestsPage from './pages/AdminRequestsPage';
|
||||||
|
|
||||||
function RequireAdmin({ children }) {
|
function MobileSidebarTrigger() {
|
||||||
const location = useLocation();
|
const { isMobile, toggleSidebar, open } = useSidebar();
|
||||||
const [state, setState] = React.useState({ loading: true, ok: false });
|
const { user } = useAuth();
|
||||||
|
if (!isMobile || !user) return null;
|
||||||
|
|
||||||
React.useEffect(() => {
|
|
||||||
let active = true;
|
|
||||||
if (!localStorage.getItem('planning_access')) {
|
|
||||||
setState({ loading: false, ok: false });
|
|
||||||
return () => { active = false; };
|
|
||||||
}
|
|
||||||
|
|
||||||
authApi.me()
|
|
||||||
.then(() => active && setState({ loading: false, ok: true }))
|
|
||||||
.catch(() => {
|
|
||||||
authApi.logout();
|
|
||||||
if (active) setState({ loading: false, ok: false });
|
|
||||||
});
|
|
||||||
|
|
||||||
return () => { active = false; };
|
|
||||||
}, [location.pathname]);
|
|
||||||
|
|
||||||
if (state.loading) {
|
|
||||||
return (
|
return (
|
||||||
<div className="flex min-h-screen items-center justify-center bg-slate-50 dark:bg-slate-950">
|
<div className="flex shrink-0 items-center justify-end gap-2 px-2 py-2">
|
||||||
<div className="flex items-center gap-3 text-sm text-slate-500">
|
<button
|
||||||
<span className="size-5 animate-spin rounded-full border-2 border-slate-300 border-t-amber-500" />
|
type="button"
|
||||||
Chargement…
|
onClick={toggleSidebar}
|
||||||
|
className="rounded-md bg-sidebar-primary p-3 text-sidebar-primary-foreground transition-colors hover:bg-sidebar-primary/80"
|
||||||
|
aria-label={open ? 'Fermer le menu' : 'Ouvrir le menu'}
|
||||||
|
>
|
||||||
|
<Menu className="size-6" />
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return state.ok ? children : <Navigate to="/admin" replace state={{ from: location.pathname }} />;
|
|
||||||
}
|
|
||||||
|
|
||||||
function AdminRoute({ children }) {
|
|
||||||
return (
|
|
||||||
<RequireAdmin>
|
|
||||||
<AdminLayout>{children}</AdminLayout>
|
|
||||||
</RequireAdmin>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function App() {
|
function App() {
|
||||||
return (
|
return (
|
||||||
|
<SidebarProvider>
|
||||||
|
<NavBar />
|
||||||
|
<SidebarInset className="flex h-svh flex-col overflow-hidden">
|
||||||
|
<MobileSidebarTrigger />
|
||||||
|
<main className="min-h-0 flex-1 overflow-auto">
|
||||||
|
<div className="min-h-full w-full">
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/" element={<PublicPage />} />
|
<Route path="/" element={<PublicPage />} />
|
||||||
<Route path="/request/:token" element={<PublicPage />} />
|
<Route path="/request/:token" element={<PublicPage />} />
|
||||||
<Route path="/admin" element={<AdminLoginPage />} />
|
<Route
|
||||||
<Route path="/admin/planning" element={<AdminRoute><AdminPlanningPage /></AdminRoute>} />
|
path="/login"
|
||||||
<Route path="/admin/requests" element={<AdminRoute><AdminRequestsPage /></AdminRoute>} />
|
element={
|
||||||
|
<UnloggedRoute>
|
||||||
|
<LoginPage />
|
||||||
|
</UnloggedRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="/planning"
|
||||||
|
element={
|
||||||
|
<AdminRoute>
|
||||||
|
<AdminPlanningPage />
|
||||||
|
</AdminRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="/requests"
|
||||||
|
element={
|
||||||
|
<AdminRoute>
|
||||||
|
<AdminRequestsPage />
|
||||||
|
</AdminRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
<Route path="*" element={<Navigate to="/" replace />} />
|
<Route path="*" element={<Navigate to="/" replace />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
<footer className="site-footer shrink-0 px-3 py-2">
|
||||||
|
<p className="text-right text-sm text-muted-foreground">© 2026 Planning Louis.</p>
|
||||||
|
</footer>
|
||||||
|
</SidebarInset>
|
||||||
|
</SidebarProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export default App;
|
||||||
|
|||||||
83
frontend/src/api/axios.js
Normal file
83
frontend/src/api/axios.js
Normal file
@ -0,0 +1,83 @@
|
|||||||
|
import axios from 'axios';
|
||||||
|
import { getApiErrorMessage } from './errors.js';
|
||||||
|
|
||||||
|
const api = axios.create({
|
||||||
|
baseURL: import.meta.env?.PROD ? '/api' : `http://${window.location.hostname}:1031/api`,
|
||||||
|
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
|
||||||
|
});
|
||||||
|
|
||||||
|
const refreshClient = axios.create({ baseURL: api.defaults.baseURL });
|
||||||
|
let refreshPromise = null;
|
||||||
|
|
||||||
|
export function clearSession() {
|
||||||
|
localStorage.removeItem('token');
|
||||||
|
localStorage.removeItem('refresh');
|
||||||
|
localStorage.removeItem('user');
|
||||||
|
window.dispatchEvent(new Event('auth:logout'));
|
||||||
|
}
|
||||||
|
|
||||||
|
function expireSession() {
|
||||||
|
clearSession();
|
||||||
|
if (window.location.pathname !== '/login') {
|
||||||
|
window.location.href = '/login';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function refreshAccessToken() {
|
||||||
|
if (!refreshPromise) {
|
||||||
|
const refresh = localStorage.getItem('refresh');
|
||||||
|
if (!refresh) return Promise.reject(new Error('Aucun jeton de renouvellement.'));
|
||||||
|
|
||||||
|
refreshPromise = refreshClient.post('/auth/refresh/', { refresh }).then(({ data }) => {
|
||||||
|
if (localStorage.getItem('refresh') !== refresh) throw new Error('La session a changé.');
|
||||||
|
localStorage.setItem('token', data.access);
|
||||||
|
if (data.refresh) localStorage.setItem('refresh', data.refresh);
|
||||||
|
return data.access;
|
||||||
|
}).catch((error) => {
|
||||||
|
if ([400, 401].includes(error.response?.status) && localStorage.getItem('refresh') === refresh) {
|
||||||
|
expireSession();
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}).finally(() => { refreshPromise = null; });
|
||||||
|
}
|
||||||
|
return refreshPromise;
|
||||||
|
}
|
||||||
|
|
||||||
|
api.interceptors.request.use(async (config) => {
|
||||||
|
const url = config.url || '';
|
||||||
|
const publicRequest = url === '/auth/login/' || url.startsWith('/planning/public/');
|
||||||
|
config._publicRequest = publicRequest;
|
||||||
|
|
||||||
|
if (publicRequest) {
|
||||||
|
delete config.headers.Authorization;
|
||||||
|
return config;
|
||||||
|
}
|
||||||
|
|
||||||
|
let token = localStorage.getItem('token');
|
||||||
|
if (!token && localStorage.getItem('refresh')) token = await refreshAccessToken();
|
||||||
|
if (token) config.headers.Authorization = `Bearer ${token}`;
|
||||||
|
else delete config.headers.Authorization;
|
||||||
|
return config;
|
||||||
|
});
|
||||||
|
|
||||||
|
api.interceptors.response.use(
|
||||||
|
(response) => response,
|
||||||
|
async (error) => {
|
||||||
|
error.message = getApiErrorMessage(error);
|
||||||
|
const request = error.config;
|
||||||
|
if (error.response?.status !== 401 || !request || request._publicRequest) throw error;
|
||||||
|
if (request._retry || !localStorage.getItem('refresh')) {
|
||||||
|
expireSession();
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
request._retry = true;
|
||||||
|
const currentToken = localStorage.getItem('token');
|
||||||
|
const sentToken = request.headers.Authorization;
|
||||||
|
const access = currentToken && sentToken !== `Bearer ${currentToken}`
|
||||||
|
? currentToken : await refreshAccessToken();
|
||||||
|
request.headers.Authorization = `Bearer ${access}`;
|
||||||
|
return api(request);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
export default api;
|
||||||
@ -1,23 +0,0 @@
|
|||||||
import axios from 'axios';
|
|
||||||
const client = axios.create({ baseURL:'/api', timeout:15000 });
|
|
||||||
client.interceptors.request.use((config)=>{
|
|
||||||
const token=localStorage.getItem('planning_access');
|
|
||||||
if(token) config.headers.Authorization=`Bearer ${token}`;
|
|
||||||
return config;
|
|
||||||
});
|
|
||||||
client.interceptors.response.use(r=>r, async(error)=>{
|
|
||||||
const original=error.config;
|
|
||||||
if(error.response?.status===401 && !original?._retry && localStorage.getItem('planning_refresh')){
|
|
||||||
original._retry=true;
|
|
||||||
try{
|
|
||||||
const r=await axios.post('/api/auth/token/refresh/',{refresh:localStorage.getItem('planning_refresh')});
|
|
||||||
localStorage.setItem('planning_access',r.data.access);
|
|
||||||
original.headers.Authorization=`Bearer ${r.data.access}`;
|
|
||||||
return client(original);
|
|
||||||
}catch{
|
|
||||||
localStorage.removeItem('planning_access'); localStorage.removeItem('planning_refresh');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return Promise.reject(error);
|
|
||||||
});
|
|
||||||
export default client;
|
|
||||||
12
frontend/src/api/errors.js
Normal file
12
frontend/src/api/errors.js
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
export function getApiErrorMessage(error, fallback = 'La requête a échoué.') {
|
||||||
|
const flatten = (value, prefix = '') => {
|
||||||
|
if (Array.isArray(value)) return value.flatMap((item) => flatten(item, prefix));
|
||||||
|
if (value && typeof value === 'object') {
|
||||||
|
return Object.entries(value).flatMap(([key, item]) =>
|
||||||
|
flatten(item, ['detail', 'error', 'non_field_errors'].includes(key) ? prefix : key),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return typeof value === 'string' ? [`${prefix ? `${prefix} : ` : ''}${value}`] : [];
|
||||||
|
};
|
||||||
|
return flatten(error.response?.data).join(' ') || error.message || fallback;
|
||||||
|
}
|
||||||
@ -1,19 +0,0 @@
|
|||||||
import client from './client';
|
|
||||||
export const publicApi={
|
|
||||||
schedule: async(start,end)=>(await client.get('/public/schedule/',{params:{start,end}})).data,
|
|
||||||
createRequest: async(payload)=>(await client.post('/public/requests/',payload)).data,
|
|
||||||
requestStatus: async(token)=>(await client.get(`/public/requests/${token}/`)).data,
|
|
||||||
};
|
|
||||||
export const authApi={
|
|
||||||
login: async(username,password)=>{const {data}=await client.post('/auth/token/',{username,password}); localStorage.setItem('planning_access',data.access); localStorage.setItem('planning_refresh',data.refresh); return data;},
|
|
||||||
me: async()=>(await client.get('/admin/me/')).data,
|
|
||||||
logout: ()=>{localStorage.removeItem('planning_access'); localStorage.removeItem('planning_refresh');},
|
|
||||||
};
|
|
||||||
export const adminApi={
|
|
||||||
schedule: async(start,end)=>(await client.get('/admin/schedule/',{params:{start,end}})).data,
|
|
||||||
createSchedule: async(p)=>(await client.post('/admin/schedule/',p)).data,
|
|
||||||
updateSchedule: async(id,p)=>(await client.put(`/admin/schedule/${id}/`,p)).data,
|
|
||||||
deleteSchedule: async(id)=>client.delete(`/admin/schedule/${id}/`),
|
|
||||||
requests: async()=>(await client.get('/admin/requests/')).data,
|
|
||||||
respond: async(id,p)=>(await client.post(`/admin/requests/${id}/respond/`,p)).data,
|
|
||||||
};
|
|
||||||
37
frontend/src/api/planning.js
Normal file
37
frontend/src/api/planning.js
Normal file
@ -0,0 +1,37 @@
|
|||||||
|
import api from './axios';
|
||||||
|
|
||||||
|
export const planningApi = {
|
||||||
|
publicSchedule: async (start, end) => (
|
||||||
|
await api.get('/planning/public/schedule/', { params: { start, end } })
|
||||||
|
).data,
|
||||||
|
|
||||||
|
createAvailabilityRequest: async (payload) => (
|
||||||
|
await api.post('/planning/public/requests/', payload)
|
||||||
|
).data,
|
||||||
|
|
||||||
|
getAvailabilityRequest: async (token) => (
|
||||||
|
await api.get(`/planning/public/requests/${token}/`)
|
||||||
|
).data,
|
||||||
|
|
||||||
|
schedule: async (start, end) => (
|
||||||
|
await api.get('/planning/schedule/', { params: { start, end } })
|
||||||
|
).data,
|
||||||
|
|
||||||
|
createSchedule: async (payload) => (
|
||||||
|
await api.post('/planning/schedule/', payload)
|
||||||
|
).data,
|
||||||
|
|
||||||
|
updateSchedule: async (id, payload) => (
|
||||||
|
await api.put(`/planning/schedule/${id}/`, payload)
|
||||||
|
).data,
|
||||||
|
|
||||||
|
deleteSchedule: async (id) => api.delete(`/planning/schedule/${id}/`),
|
||||||
|
|
||||||
|
requests: async (status) => (
|
||||||
|
await api.get('/planning/requests/', { params: status ? { status } : {} })
|
||||||
|
).data,
|
||||||
|
|
||||||
|
respond: async (id, payload) => (
|
||||||
|
await api.post(`/planning/requests/${id}/respond/`, payload)
|
||||||
|
).data,
|
||||||
|
};
|
||||||
15
frontend/src/components/AdminRoute.jsx
Normal file
15
frontend/src/components/AdminRoute.jsx
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { Navigate, useLocation } from 'react-router-dom';
|
||||||
|
import { useAuth } from '../context/useAuth';
|
||||||
|
import { Skeleton } from '@/components/ui/skeleton';
|
||||||
|
|
||||||
|
const AdminRoute = ({ children }) => {
|
||||||
|
const { user, loading, isAdmin } = useAuth();
|
||||||
|
const location = useLocation();
|
||||||
|
if (loading) return <Skeleton className="h-16 w-full" />;
|
||||||
|
if (!user) return <Navigate to="/login" state={{ from: location }} replace />;
|
||||||
|
if (!isAdmin) return <Navigate to="/" replace />;
|
||||||
|
return children;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default AdminRoute;
|
||||||
36
frontend/src/components/LoadingCard.jsx
Normal file
36
frontend/src/components/LoadingCard.jsx
Normal file
@ -0,0 +1,36 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from '@/components/ui/card';
|
||||||
|
|
||||||
|
const LoadingCard = ({
|
||||||
|
icon: Icon,
|
||||||
|
title,
|
||||||
|
message = 'Chargement...',
|
||||||
|
}) => {
|
||||||
|
return (
|
||||||
|
<Card className="flex h-full min-h-0 w-full flex-col overflow-hidden border-l-0">
|
||||||
|
<CardHeader className="shrink-0 border-b">
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
{Icon && <Icon className="h-5 w-5" />}
|
||||||
|
{title}
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
|
||||||
|
<CardContent className="flex min-h-0 flex-1 items-center justify-center">
|
||||||
|
<div className="flex items-center justify-center">
|
||||||
|
<div className="h-8 w-8 animate-spin rounded-full border-b-2 border-blue-500" />
|
||||||
|
|
||||||
|
<span className="ml-3">
|
||||||
|
{message}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default LoadingCard;
|
||||||
39
frontend/src/components/MainCard.jsx
Normal file
39
frontend/src/components/MainCard.jsx
Normal file
@ -0,0 +1,39 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from '@/components/ui/card';
|
||||||
|
|
||||||
|
const MainCard = ({
|
||||||
|
title,
|
||||||
|
children,
|
||||||
|
icon: Icon,
|
||||||
|
headerContent,
|
||||||
|
className = '',
|
||||||
|
contentClassName = '',
|
||||||
|
}) => {
|
||||||
|
return (
|
||||||
|
<Card
|
||||||
|
className={`flex h-full w-full flex-col border-l-0 ${className}`}
|
||||||
|
>
|
||||||
|
<CardHeader className="flex shrink-0 flex-row items-center justify-between border-b">
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
{Icon && <Icon className="h-5 w-5" />}
|
||||||
|
{title}
|
||||||
|
</CardTitle>
|
||||||
|
|
||||||
|
{headerContent && (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{headerContent}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardHeader>
|
||||||
|
|
||||||
|
{children}
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default MainCard;
|
||||||
143
frontend/src/components/NavBar.jsx
Normal file
143
frontend/src/components/NavBar.jsx
Normal file
@ -0,0 +1,143 @@
|
|||||||
|
import React, { useEffect, useRef } from 'react';
|
||||||
|
import { Link, NavLink } from 'react-router-dom';
|
||||||
|
import { useAuth } from '../context/useAuth';
|
||||||
|
import {
|
||||||
|
Sidebar,
|
||||||
|
SidebarContent,
|
||||||
|
SidebarFooter,
|
||||||
|
SidebarGroup,
|
||||||
|
SidebarGroupContent,
|
||||||
|
SidebarHeader,
|
||||||
|
SidebarMenu,
|
||||||
|
SidebarMenuButton,
|
||||||
|
SidebarMenuItem,
|
||||||
|
useSidebar,
|
||||||
|
} from '@/components/ui/sidebar';
|
||||||
|
import {
|
||||||
|
CalendarDays,
|
||||||
|
ClipboardList,
|
||||||
|
ExternalLink,
|
||||||
|
LogOut,
|
||||||
|
UserRound,
|
||||||
|
} from 'lucide-react';
|
||||||
|
|
||||||
|
const NavBar = () => {
|
||||||
|
const { user, logout } = useAuth();
|
||||||
|
const { isMobile, toggleSidebar, setOpen } = useSidebar();
|
||||||
|
const closeTimeout = useRef(null);
|
||||||
|
|
||||||
|
useEffect(() => () => {
|
||||||
|
if (closeTimeout.current) clearTimeout(closeTimeout.current);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (!user) return null;
|
||||||
|
|
||||||
|
const handleMouseEnter = () => {
|
||||||
|
if (isMobile) return;
|
||||||
|
if (closeTimeout.current) clearTimeout(closeTimeout.current);
|
||||||
|
setOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMouseLeave = () => {
|
||||||
|
if (isMobile) return;
|
||||||
|
closeTimeout.current = setTimeout(() => setOpen(false), 150);
|
||||||
|
};
|
||||||
|
|
||||||
|
const closeMobile = () => {
|
||||||
|
if (isMobile) toggleSidebar();
|
||||||
|
};
|
||||||
|
|
||||||
|
const navigation = [
|
||||||
|
{ title: 'Planning', href: '/planning', icon: CalendarDays },
|
||||||
|
{ title: 'Demandes', href: '/requests', icon: ClipboardList },
|
||||||
|
{ title: 'Vue publique', href: '/', icon: ExternalLink },
|
||||||
|
];
|
||||||
|
|
||||||
|
const renderMenuItems = (items) => items.map((item) => {
|
||||||
|
const Icon = item.icon;
|
||||||
|
return (
|
||||||
|
<SidebarMenuItem key={item.href} className="flex h-12 min-h-12 items-center">
|
||||||
|
<SidebarMenuButton
|
||||||
|
tooltip={item.title}
|
||||||
|
onClick={closeMobile}
|
||||||
|
className="h-8 min-h-8 w-full group-data-[collapsible=icon]:size-8! group-data-[collapsible=icon]:p-2!"
|
||||||
|
render={
|
||||||
|
<NavLink to={item.href} className="flex h-full w-full items-center gap-2 text-base">
|
||||||
|
<Icon className="size-6 shrink-0" />
|
||||||
|
<span className="truncate text-base">{item.title}</span>
|
||||||
|
</NavLink>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</SidebarMenuItem>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Sidebar
|
||||||
|
className="border-0"
|
||||||
|
collapsible="icon"
|
||||||
|
onMouseEnter={handleMouseEnter}
|
||||||
|
onMouseLeave={handleMouseLeave}
|
||||||
|
>
|
||||||
|
<SidebarHeader className="h-16 p-2 pl-0">
|
||||||
|
<SidebarMenu>
|
||||||
|
<SidebarMenuItem className="flex h-12 items-center">
|
||||||
|
<SidebarMenuButton
|
||||||
|
size="lg"
|
||||||
|
className="h-12 w-full group-data-[collapsible=icon]:h-8! group-data-[collapsible=icon]:w-10! group-data-[collapsible=icon]:p-2!"
|
||||||
|
render={
|
||||||
|
<Link to="/planning" className="flex w-full items-center gap-2">
|
||||||
|
<div className="flex aspect-square size-8 shrink-0 items-center justify-center rounded-lg bg-sidebar-primary text-sidebar-primary-foreground">
|
||||||
|
<CalendarDays className="size-4" />
|
||||||
|
</div>
|
||||||
|
<div className="grid min-w-0 flex-1 text-left text-sm leading-tight">
|
||||||
|
<span className="truncate font-semibold">Planning Louis</span>
|
||||||
|
<span className="truncate text-xs">Administration</span>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</SidebarMenuItem>
|
||||||
|
</SidebarMenu>
|
||||||
|
</SidebarHeader>
|
||||||
|
|
||||||
|
<SidebarContent>
|
||||||
|
<SidebarGroup className="p-2">
|
||||||
|
<div className="flex h-8 min-h-8 w-full items-center px-2">
|
||||||
|
<span className="truncate text-lg font-medium group-data-[collapsible=icon]:hidden">Navigation</span>
|
||||||
|
<div className="hidden w-full border-t border-sidebar-border group-data-[collapsible=icon]:block" />
|
||||||
|
</div>
|
||||||
|
<SidebarGroupContent>
|
||||||
|
<SidebarMenu>{renderMenuItems(navigation)}</SidebarMenu>
|
||||||
|
</SidebarGroupContent>
|
||||||
|
</SidebarGroup>
|
||||||
|
</SidebarContent>
|
||||||
|
|
||||||
|
<SidebarFooter className="p-2">
|
||||||
|
<SidebarMenu>
|
||||||
|
<SidebarMenuItem>
|
||||||
|
<SidebarMenuButton
|
||||||
|
tooltip="Déconnexion"
|
||||||
|
onClick={() => {
|
||||||
|
logout();
|
||||||
|
closeMobile();
|
||||||
|
}}
|
||||||
|
className="h-8 w-full group-data-[collapsible=icon]:size-8! group-data-[collapsible=icon]:p-2!"
|
||||||
|
>
|
||||||
|
<LogOut className="size-5" />
|
||||||
|
<span className="truncate">Déconnexion</span>
|
||||||
|
</SidebarMenuButton>
|
||||||
|
</SidebarMenuItem>
|
||||||
|
<SidebarMenuItem>
|
||||||
|
<div className="flex items-center gap-2 px-2 py-1.5 text-xs text-muted-foreground group-data-[collapsible=icon]:hidden">
|
||||||
|
<UserRound className="size-4" />
|
||||||
|
<span className="truncate">{user.first_name || user.username}</span>
|
||||||
|
</div>
|
||||||
|
</SidebarMenuItem>
|
||||||
|
</SidebarMenu>
|
||||||
|
</SidebarFooter>
|
||||||
|
</Sidebar>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default NavBar;
|
||||||
@ -1,44 +1,45 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import clsx from 'clsx';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { Badge, Card } from './ui';
|
import { Card } from '@/components/ui/card';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
import { dateKey } from '../utils';
|
import { dateKey } from '../utils';
|
||||||
|
|
||||||
const meta = {
|
const statusMeta = {
|
||||||
busy: {
|
busy: {
|
||||||
label: 'Occupé',
|
label: 'Occupé',
|
||||||
tone: 'amber',
|
badge: 'bg-amber-500/15 text-amber-800 dark:text-amber-300',
|
||||||
bar: 'bg-amber-500/85 border-amber-600/30 text-slate-950',
|
block: 'border-amber-600/30 bg-amber-500/80 text-stone-950',
|
||||||
},
|
},
|
||||||
free: {
|
free: {
|
||||||
label: 'Disponible',
|
label: 'Disponible',
|
||||||
tone: 'green',
|
badge: 'bg-emerald-500/15 text-emerald-800 dark:text-emerald-300',
|
||||||
bar: 'bg-emerald-500/85 border-emerald-600/30 text-white',
|
block: 'border-emerald-600/30 bg-emerald-500/80 text-white',
|
||||||
},
|
},
|
||||||
unavailable: {
|
unavailable: {
|
||||||
label: 'Indisponible',
|
label: 'Indisponible',
|
||||||
tone: 'red',
|
badge: 'bg-destructive/15 text-destructive',
|
||||||
bar: 'bg-red-500/85 border-red-600/30 text-white',
|
block: 'border-destructive/30 bg-destructive/80 text-white',
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
const mins = (time) => {
|
const timeMinutes = (value) => {
|
||||||
const [h, m] = String(time).split(':').map(Number);
|
const [hours, minutes] = String(value).split(':').map(Number);
|
||||||
return h * 60 + m;
|
return (hours * 60) + minutes;
|
||||||
};
|
};
|
||||||
|
|
||||||
const applies = (entry, day) =>
|
const appliesToDay = (entry, day) => (
|
||||||
entry.start_date <= dateKey(day) && entry.end_date >= dateKey(day);
|
entry.start_date <= dateKey(day) && entry.end_date >= dateKey(day)
|
||||||
|
);
|
||||||
|
|
||||||
const ft = (time) => String(time).slice(0, 5);
|
const shortTime = (value) => String(value || '').slice(0, 5);
|
||||||
|
|
||||||
export default function ScheduleWeek({ days, entries, editable = false, onEntryClick }) {
|
export default function ScheduleWeek({ days, entries, editable = false, onEntryClick }) {
|
||||||
const { startHour, endHour } = React.useMemo(() => {
|
const { startHour, endHour } = React.useMemo(() => {
|
||||||
const visible = entries.filter((entry) => days.some((day) => applies(entry, day)));
|
const visible = entries.filter((entry) => days.some((day) => appliesToDay(entry, day)));
|
||||||
if (!visible.length) return { startHour: 6, endHour: 22 };
|
if (!visible.length) return { startHour: 6, endHour: 22 };
|
||||||
|
|
||||||
const earliest = Math.min(...visible.map((entry) => Math.floor(mins(entry.start_time) / 60)));
|
const earliest = Math.min(...visible.map((entry) => Math.floor(timeMinutes(entry.start_time) / 60)));
|
||||||
const latest = Math.max(...visible.map((entry) => Math.ceil(mins(entry.end_time) / 60)));
|
const latest = Math.max(...visible.map((entry) => Math.ceil(timeMinutes(entry.end_time) / 60)));
|
||||||
|
|
||||||
return {
|
return {
|
||||||
startHour: Math.max(0, Math.min(6, earliest - 1)),
|
startHour: Math.max(0, Math.min(6, earliest - 1)),
|
||||||
endHour: Math.min(24, Math.max(22, latest + 1)),
|
endHour: Math.min(24, Math.max(22, latest + 1)),
|
||||||
@ -47,24 +48,24 @@ export default function ScheduleWeek({ days, entries, editable = false, onEntryC
|
|||||||
|
|
||||||
const hourCount = Math.max(endHour - startHour, 1);
|
const hourCount = Math.max(endHour - startHour, 1);
|
||||||
const totalMinutes = hourCount * 60;
|
const totalMinutes = hourCount * 60;
|
||||||
const desktopHeight = Math.max(hourCount * 45, 540);
|
const desktopHeight = Math.max(hourCount * 42, 520);
|
||||||
|
|
||||||
const position = (entry) => {
|
const position = (entry) => {
|
||||||
const start = Math.max(mins(entry.start_time), startHour * 60);
|
const start = Math.max(timeMinutes(entry.start_time), startHour * 60);
|
||||||
const end = Math.min(mins(entry.end_time), endHour * 60);
|
const end = Math.min(timeMinutes(entry.end_time), endHour * 60);
|
||||||
return {
|
return {
|
||||||
top: `${((start - startHour * 60) / totalMinutes) * 100}%`,
|
top: `${((start - (startHour * 60)) / totalMinutes) * 100}%`,
|
||||||
height: `${Math.max(((end - start) / totalMinutes) * 100, 2.2)}%`,
|
height: `${Math.max(((end - start) / totalMinutes) * 100, 2)}%`,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card className="overflow-hidden">
|
<Card className="gap-0 overflow-hidden py-0">
|
||||||
<div className="hidden min-w-[820px] grid-cols-[64px_repeat(7,minmax(0,1fr))] border-b border-slate-200 dark:border-slate-800 md:grid">
|
<div className="hidden min-w-[820px] grid-cols-[64px_repeat(7,minmax(0,1fr))] border-b md:grid">
|
||||||
<div />
|
<div />
|
||||||
{days.map((day) => (
|
{days.map((day) => (
|
||||||
<div key={dateKey(day)} className="border-l border-slate-200 p-3 text-center dark:border-slate-800">
|
<div key={dateKey(day)} className="border-l p-3 text-center">
|
||||||
<div className="text-xs font-medium uppercase text-slate-400">
|
<div className="text-xs font-medium uppercase text-muted-foreground">
|
||||||
{day.toLocaleDateString('fr-FR', { weekday: 'short' })}
|
{day.toLocaleDateString('fr-FR', { weekday: 'short' })}
|
||||||
</div>
|
</div>
|
||||||
<div className="font-semibold">
|
<div className="font-semibold">
|
||||||
@ -76,13 +77,13 @@ export default function ScheduleWeek({ days, entries, editable = false, onEntryC
|
|||||||
|
|
||||||
<div className="hidden min-w-[820px] grid-cols-[64px_repeat(7,minmax(0,1fr))] md:grid">
|
<div className="hidden min-w-[820px] grid-cols-[64px_repeat(7,minmax(0,1fr))] md:grid">
|
||||||
<div className="relative" style={{ height: `${desktopHeight}px` }}>
|
<div className="relative" style={{ height: `${desktopHeight}px` }}>
|
||||||
{Array.from({ length: hourCount + 1 }, (_, i) => (
|
{Array.from({ length: hourCount + 1 }, (_, index) => (
|
||||||
<div
|
<div
|
||||||
key={i}
|
key={index}
|
||||||
className="absolute right-2 -translate-y-1/2 text-[11px] text-slate-400"
|
className="absolute right-2 -translate-y-1/2 text-[11px] text-muted-foreground"
|
||||||
style={{ top: `${(i / hourCount) * 100}%` }}
|
style={{ top: `${(index / hourCount) * 100}%` }}
|
||||||
>
|
>
|
||||||
{String(startHour + i).padStart(2, '0')}:00
|
{String(startHour + index).padStart(2, '0')}:00
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@ -90,30 +91,31 @@ export default function ScheduleWeek({ days, entries, editable = false, onEntryC
|
|||||||
{days.map((day) => (
|
{days.map((day) => (
|
||||||
<div
|
<div
|
||||||
key={dateKey(day)}
|
key={dateKey(day)}
|
||||||
className="relative border-l border-slate-200 dark:border-slate-800"
|
className="relative border-l"
|
||||||
style={{
|
style={{
|
||||||
height: `${desktopHeight}px`,
|
height: `${desktopHeight}px`,
|
||||||
backgroundImage: 'linear-gradient(to bottom, transparent calc(100% - 1px), rgba(148,163,184,0.16) calc(100% - 1px))',
|
backgroundImage: 'linear-gradient(to bottom, transparent calc(100% - 1px), color-mix(in oklch, var(--border), transparent 35%) calc(100% - 1px))',
|
||||||
backgroundSize: `100% ${100 / hourCount}%`,
|
backgroundSize: `100% ${100 / hourCount}%`,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{entries.filter((entry) => applies(entry, day)).map((entry) => {
|
{entries.filter((entry) => appliesToDay(entry, day)).map((entry) => {
|
||||||
const m = meta[entry.status] || meta.busy;
|
const meta = statusMeta[entry.status] || statusMeta.busy;
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
key={`${entry.id}-${dateKey(day)}`}
|
key={`${entry.id}-${dateKey(day)}`}
|
||||||
|
type="button"
|
||||||
disabled={!editable}
|
disabled={!editable}
|
||||||
onClick={() => editable && onEntryClick?.(entry)}
|
onClick={() => editable && onEntryClick?.(entry)}
|
||||||
className={clsx(
|
className={cn(
|
||||||
'absolute left-1.5 right-1.5 overflow-hidden rounded-lg border px-2 py-1 text-left text-xs shadow-sm',
|
'absolute left-1.5 right-1.5 overflow-hidden rounded-md border px-2 py-1 text-left text-xs shadow-sm',
|
||||||
m.bar,
|
meta.block,
|
||||||
editable && 'cursor-pointer hover:brightness-105',
|
editable && 'cursor-pointer hover:brightness-105',
|
||||||
)}
|
)}
|
||||||
style={position(entry)}
|
style={position(entry)}
|
||||||
title={`${entry.title || m.label} · ${ft(entry.start_time)}–${ft(entry.end_time)}`}
|
title={`${entry.title || meta.label} · ${shortTime(entry.start_time)}–${shortTime(entry.end_time)}`}
|
||||||
>
|
>
|
||||||
<div className="truncate font-semibold">{entry.title || m.label}</div>
|
<div className="truncate font-semibold">{entry.title || meta.label}</div>
|
||||||
<div className="truncate opacity-85">{ft(entry.start_time)}–{ft(entry.end_time)}</div>
|
<div className="truncate opacity-85">{shortTime(entry.start_time)}–{shortTime(entry.end_time)}</div>
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
@ -121,10 +123,10 @@ export default function ScheduleWeek({ days, entries, editable = false, onEntryC
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="divide-y divide-slate-200 dark:divide-slate-800 md:hidden">
|
<div className="divide-y md:hidden">
|
||||||
{days.map((day) => {
|
{days.map((day) => {
|
||||||
const list = entries
|
const list = entries
|
||||||
.filter((entry) => applies(entry, day))
|
.filter((entry) => appliesToDay(entry, day))
|
||||||
.sort((a, b) => a.start_time.localeCompare(b.start_time));
|
.sort((a, b) => a.start_time.localeCompare(b.start_time));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -134,38 +136,39 @@ export default function ScheduleWeek({ days, entries, editable = false, onEntryC
|
|||||||
<div className="font-semibold capitalize">
|
<div className="font-semibold capitalize">
|
||||||
{day.toLocaleDateString('fr-FR', { weekday: 'long' })}
|
{day.toLocaleDateString('fr-FR', { weekday: 'long' })}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-xs text-slate-500">
|
<div className="text-xs text-muted-foreground">
|
||||||
{day.toLocaleDateString('fr-FR', { day: 'numeric', month: 'long' })}
|
{day.toLocaleDateString('fr-FR', { day: 'numeric', month: 'long' })}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Badge>{list.length} plage{list.length > 1 ? 's' : ''}</Badge>
|
<Badge variant="secondary">{list.length} plage{list.length > 1 ? 's' : ''}</Badge>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{!list.length ? (
|
{!list.length ? (
|
||||||
<div className="rounded-xl border border-dashed border-slate-200 p-4 text-sm text-slate-400 dark:border-slate-800">
|
<div className="rounded-lg border border-dashed p-4 text-sm text-muted-foreground">Rien de renseigné.</div>
|
||||||
Rien de renseigné.
|
|
||||||
</div>
|
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{list.map((entry) => {
|
{list.map((entry) => {
|
||||||
const m = meta[entry.status] || meta.busy;
|
const meta = statusMeta[entry.status] || statusMeta.busy;
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
key={entry.id}
|
key={entry.id}
|
||||||
|
type="button"
|
||||||
disabled={!editable}
|
disabled={!editable}
|
||||||
onClick={() => editable && onEntryClick?.(entry)}
|
onClick={() => editable && onEntryClick?.(entry)}
|
||||||
className={clsx(
|
className={cn(
|
||||||
'flex w-full items-center justify-between gap-3 rounded-xl border border-slate-200 p-3 text-left dark:border-slate-800',
|
'flex w-full items-center justify-between gap-3 rounded-lg border p-3 text-left',
|
||||||
editable && 'hover:bg-slate-50 dark:hover:bg-slate-900',
|
editable && 'hover:bg-muted/50',
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<div className="truncate font-medium">{entry.title || m.label}</div>
|
<div className="truncate font-medium">{entry.title || meta.label}</div>
|
||||||
{entry.description && <div className="truncate text-xs text-slate-500">{entry.description}</div>}
|
{entry.description && <div className="truncate text-xs text-muted-foreground">{entry.description}</div>}
|
||||||
</div>
|
</div>
|
||||||
<div className="shrink-0 text-right">
|
<div className="shrink-0 text-right">
|
||||||
<Badge tone={m.tone}>{m.label}</Badge>
|
<Badge variant="secondary" className={meta.badge}>{meta.label}</Badge>
|
||||||
<div className="mt-1 text-xs text-slate-500">{ft(entry.start_time)}–{ft(entry.end_time)}</div>
|
<div className="mt-1 text-xs text-muted-foreground">
|
||||||
|
{shortTime(entry.start_time)}–{shortTime(entry.end_time)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
|
|||||||
13
frontend/src/components/UnloggedRoute.jsx
Normal file
13
frontend/src/components/UnloggedRoute.jsx
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { Navigate } from 'react-router-dom';
|
||||||
|
import { useAuth } from '../context/useAuth';
|
||||||
|
import { Skeleton } from '@/components/ui/skeleton';
|
||||||
|
|
||||||
|
const UnloggedRoute = ({ children }) => {
|
||||||
|
const { user, loading } = useAuth();
|
||||||
|
if (loading) return <Skeleton className="h-16 w-full" />;
|
||||||
|
if (user) return <Navigate to="/planning" replace />;
|
||||||
|
return children;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default UnloggedRoute;
|
||||||
@ -1,16 +0,0 @@
|
|||||||
import React from 'react';
|
|
||||||
import { Link, NavLink, useNavigate } from 'react-router-dom';
|
|
||||||
import { CalendarDays, ClipboardList, LogOut, Menu, Wheat, X } from 'lucide-react';
|
|
||||||
import clsx from 'clsx';
|
|
||||||
import { Button } from './ui';
|
|
||||||
import { authApi } from '../api';
|
|
||||||
|
|
||||||
export function PublicHeader(){return <header className="border-b border-slate-200/70 bg-white/85 backdrop-blur dark:border-slate-800 dark:bg-slate-950/85"><div className="mx-auto flex h-16 max-w-7xl items-center justify-between px-4"><Link to="/" className="flex items-center gap-3"><span className="flex size-10 items-center justify-center rounded-xl bg-amber-500 text-slate-950"><Wheat className="size-5"/></span><div><div className="font-semibold leading-tight">Planning</div><div className="text-xs text-slate-500">Disponibilités</div></div></Link><Link to="/admin" className="rounded-lg border border-slate-200 bg-white px-3 py-2 text-sm font-medium hover:bg-slate-50 dark:border-slate-700 dark:bg-slate-900 dark:hover:bg-slate-800">Administration</Link></div></header>}
|
|
||||||
|
|
||||||
export function AdminLayout({children}){
|
|
||||||
const [open,setOpen]=React.useState(false); const navigate=useNavigate();
|
|
||||||
const logout=()=>{authApi.logout(); navigate('/admin')};
|
|
||||||
const nav=[['/admin/planning','Planning',CalendarDays],['/admin/requests','Demandes',ClipboardList]];
|
|
||||||
const sidebar=<div className="flex h-full flex-col"><div className="flex h-16 items-center gap-3 border-b border-slate-200 px-4 dark:border-slate-800"><span className="flex size-9 items-center justify-center rounded-xl bg-amber-500 text-slate-950"><Wheat className="size-5"/></span><div><div className="font-semibold">Planning</div><div className="text-xs text-slate-500">Administration</div></div></div><nav className="flex-1 space-y-1 p-3">{nav.map(([to,label,Icon])=><NavLink key={to} to={to} onClick={()=>setOpen(false)} className={({isActive})=>clsx('flex h-11 items-center gap-3 rounded-xl px-3 text-sm font-medium transition',isActive?'bg-amber-500 text-slate-950':'text-slate-600 hover:bg-slate-100 dark:text-slate-300 dark:hover:bg-slate-800')}><Icon className="size-5"/>{label}</NavLink>)}</nav><div className="border-t border-slate-200 p-3 dark:border-slate-800"><Link to="/" className="mb-1 flex h-11 items-center gap-3 rounded-xl px-3 text-sm font-medium text-slate-600 hover:bg-slate-100 dark:text-slate-300 dark:hover:bg-slate-800"><Wheat className="size-5"/>Voir le site</Link><button onClick={logout} className="flex h-11 w-full items-center gap-3 rounded-xl px-3 text-sm font-medium text-red-600 hover:bg-red-50 dark:hover:bg-red-950/30"><LogOut className="size-5"/>Déconnexion</button></div></div>;
|
|
||||||
return <div className="min-h-screen md:flex"><aside className="fixed inset-y-0 left-0 z-40 hidden w-64 border-r border-slate-200 bg-white dark:border-slate-800 dark:bg-slate-950 md:block">{sidebar}</aside>{open&&<div className="fixed inset-0 z-50 md:hidden"><button className="absolute inset-0 bg-slate-950/50" onClick={()=>setOpen(false)}/><aside className="relative h-full w-72 bg-white shadow-2xl dark:bg-slate-950"><button onClick={()=>setOpen(false)} className="absolute right-3 top-3 rounded-lg p-2 hover:bg-slate-100 dark:hover:bg-slate-800"><X className="size-5"/></button>{sidebar}</aside></div>}<main className="min-w-0 flex-1 md:ml-64"><div className="sticky top-0 z-30 flex h-14 items-center border-b border-slate-200 bg-white/90 px-4 backdrop-blur dark:border-slate-800 dark:bg-slate-950/90 md:hidden"><Button variant="ghost" size="icon" onClick={()=>setOpen(true)}><Menu className="size-5"/></Button><span className="ml-2 font-semibold">Administration</span></div><div className="mx-auto max-w-7xl p-4 md:p-6">{children}</div></main></div>;
|
|
||||||
}
|
|
||||||
@ -1,23 +0,0 @@
|
|||||||
import React from 'react';
|
|
||||||
import clsx from 'clsx';
|
|
||||||
import { X } from 'lucide-react';
|
|
||||||
|
|
||||||
export function Button({children,className,variant='primary',size='md',type='button',...props}){
|
|
||||||
const v={
|
|
||||||
primary:'bg-amber-500 text-slate-950 hover:bg-amber-400 border-amber-500',
|
|
||||||
secondary:'bg-white text-slate-800 hover:bg-slate-50 border-slate-200 dark:bg-slate-900 dark:text-slate-100 dark:border-slate-700 dark:hover:bg-slate-800',
|
|
||||||
danger:'bg-red-600 text-white hover:bg-red-500 border-red-600',
|
|
||||||
ghost:'bg-transparent text-slate-700 hover:bg-slate-100 border-transparent dark:text-slate-200 dark:hover:bg-slate-800',
|
|
||||||
success:'bg-emerald-600 text-white hover:bg-emerald-500 border-emerald-600',
|
|
||||||
};
|
|
||||||
const s={sm:'h-9 px-3 text-sm',md:'h-10 px-4 text-sm',lg:'h-12 px-5 text-base',icon:'size-10 p-0'};
|
|
||||||
return <button type={type} className={clsx('inline-flex items-center justify-center gap-2 rounded-lg border font-medium transition disabled:pointer-events-none disabled:opacity-50 focus:outline-none focus:ring-2 focus:ring-amber-500/40',v[variant],s[size],className)} {...props}>{children}</button>;
|
|
||||||
}
|
|
||||||
export function Card({children,className}){return <div className={clsx('rounded-2xl border border-slate-200/80 bg-white shadow-sm dark:border-slate-800 dark:bg-slate-950',className)}>{children}</div>}
|
|
||||||
export function Badge({children,tone='slate'}){const t={slate:'bg-slate-100 text-slate-700 dark:bg-slate-800 dark:text-slate-200',amber:'bg-amber-100 text-amber-800 dark:bg-amber-950/60 dark:text-amber-300',green:'bg-emerald-100 text-emerald-800 dark:bg-emerald-950/60 dark:text-emerald-300',red:'bg-red-100 text-red-800 dark:bg-red-950/60 dark:text-red-300'}; return <span className={clsx('inline-flex items-center rounded-full px-2.5 py-1 text-xs font-medium',t[tone])}>{children}</span>}
|
|
||||||
export function Input({className,...props}){return <input className={clsx('h-10 w-full rounded-lg border border-slate-200 bg-white px-3 text-sm outline-none focus:border-amber-500 focus:ring-2 focus:ring-amber-500/15 dark:border-slate-700 dark:bg-slate-900',className)} {...props}/>}
|
|
||||||
export function Textarea({className,...props}){return <textarea className={clsx('min-h-24 w-full resize-y rounded-lg border border-slate-200 bg-white px-3 py-2 text-sm outline-none focus:border-amber-500 focus:ring-2 focus:ring-amber-500/15 dark:border-slate-700 dark:bg-slate-900',className)} {...props}/>}
|
|
||||||
export function Select({className,...props}){return <select className={clsx('h-10 w-full rounded-lg border border-slate-200 bg-white px-3 text-sm outline-none focus:border-amber-500 dark:border-slate-700 dark:bg-slate-900',className)} {...props}/>}
|
|
||||||
export function Field({label,hint,children}){return <label className="grid gap-1.5 text-sm"><span className="font-medium text-slate-700 dark:text-slate-200">{label}</span>{children}{hint&&<span className="text-xs text-slate-500">{hint}</span>}</label>}
|
|
||||||
export function Modal({open,onClose,title,description,children}){if(!open)return null; return <div className="fixed inset-0 z-50 flex items-center justify-center p-4"><button className="absolute inset-0 bg-slate-950/55 backdrop-blur-[2px]" onClick={onClose}/><Card className="relative z-10 max-h-[92vh] w-full max-w-lg overflow-auto shadow-2xl"><div className="flex items-start justify-between gap-4 border-b border-slate-200 p-5 dark:border-slate-800"><div><h2 className="text-lg font-semibold">{title}</h2>{description&&<p className="mt-1 text-sm text-slate-500">{description}</p>}</div><Button variant="ghost" size="icon" onClick={onClose}><X className="size-5"/></Button></div><div className="p-5">{children}</div></Card></div>}
|
|
||||||
export function EmptyState({icon:Icon,title,description}){return <div className="flex min-h-44 flex-col items-center justify-center px-6 text-center">{Icon&&<Icon className="mb-3 size-8 text-slate-400"/>}<p className="font-medium">{title}</p>{description&&<p className="mt-1 max-w-md text-sm text-slate-500">{description}</p>}</div>}
|
|
||||||
77
frontend/src/components/ui/accordion.jsx
Normal file
77
frontend/src/components/ui/accordion.jsx
Normal file
@ -0,0 +1,77 @@
|
|||||||
|
import { Accordion as AccordionPrimitive } from "@base-ui/react/accordion"
|
||||||
|
import { cn } from "cn"
|
||||||
|
import { ChevronDownIcon, ChevronUpIcon } from "lucide-react"
|
||||||
|
|
||||||
|
function Accordion({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<AccordionPrimitive.Root
|
||||||
|
data-slot="accordion"
|
||||||
|
className={cn("flex w-full flex-col", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AccordionItem({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<AccordionPrimitive.Item
|
||||||
|
data-slot="accordion-item"
|
||||||
|
className={cn("not-last:border-b", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AccordionTrigger({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<AccordionPrimitive.Header className="flex">
|
||||||
|
<AccordionPrimitive.Trigger
|
||||||
|
data-slot="accordion-trigger"
|
||||||
|
className={cn(
|
||||||
|
"group/accordion-trigger relative flex flex-1 items-start justify-between rounded-lg border border-transparent py-2.5 text-left text-sm font-medium transition-all outline-none hover:underline focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 focus-visible:after:border-ring aria-disabled:pointer-events-none aria-disabled:opacity-50 **:data-[slot=accordion-trigger-icon]:ml-auto **:data-[slot=accordion-trigger-icon]:size-4 **:data-[slot=accordion-trigger-icon]:text-muted-foreground",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
<ChevronDownIcon data-slot="accordion-trigger-icon" className="pointer-events-none shrink-0 group-aria-expanded/accordion-trigger:hidden" />
|
||||||
|
<ChevronUpIcon data-slot="accordion-trigger-icon" className="pointer-events-none hidden shrink-0 group-aria-expanded/accordion-trigger:inline" />
|
||||||
|
</AccordionPrimitive.Trigger>
|
||||||
|
</AccordionPrimitive.Header>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AccordionContent({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<AccordionPrimitive.Panel
|
||||||
|
data-slot="accordion-content"
|
||||||
|
className="overflow-hidden text-sm data-open:animate-accordion-down data-closed:animate-accordion-up"
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"h-(--accordion-panel-height) pt-0 pb-2.5 data-ending-style:h-0 data-starting-style:h-0 [&_a]:underline [&_a]:underline-offset-3 [&_a]:hover:text-foreground [&_p:not(:last-child)]:mb-4",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</AccordionPrimitive.Panel>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Accordion, AccordionItem, AccordionTrigger, AccordionContent }
|
||||||
188
frontend/src/components/ui/alert-dialog.jsx
Normal file
188
frontend/src/components/ui/alert-dialog.jsx
Normal file
@ -0,0 +1,188 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
import { AlertDialog as AlertDialogPrimitive } from "@base-ui/react/alert-dialog"
|
||||||
|
import { cn } from "cn"
|
||||||
|
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
|
||||||
|
function AlertDialog({
|
||||||
|
...props
|
||||||
|
}) {
|
||||||
|
return <AlertDialogPrimitive.Root data-slot="alert-dialog" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertDialogTrigger({
|
||||||
|
...props
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<AlertDialogPrimitive.Trigger data-slot="alert-dialog-trigger" {...props} />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertDialogPortal({
|
||||||
|
...props
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<AlertDialogPrimitive.Portal data-slot="alert-dialog-portal" {...props} />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertDialogOverlay({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<AlertDialogPrimitive.Backdrop
|
||||||
|
data-slot="alert-dialog-overlay"
|
||||||
|
className={cn(
|
||||||
|
"fixed inset-0 isolate z-50 bg-black/10 duration-100 supports-backdrop-filter:backdrop-blur-xs data-open:animate-in data-open:fade-in-0 data-closed:animate-out data-closed:fade-out-0",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertDialogContent({
|
||||||
|
className,
|
||||||
|
size = "default",
|
||||||
|
...props
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<AlertDialogPortal>
|
||||||
|
<AlertDialogOverlay />
|
||||||
|
<AlertDialogPrimitive.Popup
|
||||||
|
data-slot="alert-dialog-content"
|
||||||
|
data-size={size}
|
||||||
|
className={cn(
|
||||||
|
"group/alert-dialog-content fixed top-1/2 left-1/2 z-50 grid w-full -translate-x-1/2 -translate-y-1/2 gap-4 rounded-xl bg-popover p-4 text-popover-foreground ring-1 ring-foreground/10 duration-100 outline-none data-[size=default]:max-w-xs data-[size=sm]:max-w-xs data-[size=default]:sm:max-w-sm data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</AlertDialogPortal>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertDialogHeader({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="alert-dialog-header"
|
||||||
|
className={cn(
|
||||||
|
"grid grid-rows-[auto_1fr] place-items-center gap-1.5 text-center has-data-[slot=alert-dialog-media]:grid-rows-[auto_auto_1fr] has-data-[slot=alert-dialog-media]:gap-x-4 sm:group-data-[size=default]/alert-dialog-content:place-items-start sm:group-data-[size=default]/alert-dialog-content:text-left sm:group-data-[size=default]/alert-dialog-content:has-data-[slot=alert-dialog-media]:grid-rows-[auto_1fr]",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertDialogFooter({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="alert-dialog-footer"
|
||||||
|
className={cn(
|
||||||
|
"-mx-4 -mb-4 flex flex-col-reverse gap-2 rounded-b-xl border-t bg-muted/50 p-4 group-data-[size=sm]/alert-dialog-content:grid group-data-[size=sm]/alert-dialog-content:grid-cols-2 sm:flex-row sm:justify-end",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertDialogMedia({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="alert-dialog-media"
|
||||||
|
className={cn(
|
||||||
|
"mb-2 inline-flex size-10 items-center justify-center rounded-md bg-muted sm:group-data-[size=default]/alert-dialog-content:row-span-2 *:[svg:not([class*='size-'])]:size-6",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertDialogTitle({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<AlertDialogPrimitive.Title
|
||||||
|
data-slot="alert-dialog-title"
|
||||||
|
className={cn(
|
||||||
|
"font-heading text-base font-medium sm:group-data-[size=default]/alert-dialog-content:group-has-data-[slot=alert-dialog-media]/alert-dialog-content:col-start-2",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertDialogDescription({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<AlertDialogPrimitive.Description
|
||||||
|
data-slot="alert-dialog-description"
|
||||||
|
className={cn(
|
||||||
|
"text-sm text-balance text-muted-foreground md:text-pretty *:[a]:underline *:[a]:underline-offset-3 *:[a]:hover:text-foreground",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertDialogAction({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
data-slot="alert-dialog-action"
|
||||||
|
className={cn(className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertDialogCancel({
|
||||||
|
className,
|
||||||
|
variant = "outline",
|
||||||
|
size = "default",
|
||||||
|
...props
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<AlertDialogPrimitive.Close
|
||||||
|
data-slot="alert-dialog-cancel"
|
||||||
|
className={cn(className)}
|
||||||
|
render={<Button variant={variant} size={size} />}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
AlertDialog,
|
||||||
|
AlertDialogAction,
|
||||||
|
AlertDialogCancel,
|
||||||
|
AlertDialogContent,
|
||||||
|
AlertDialogDescription,
|
||||||
|
AlertDialogFooter,
|
||||||
|
AlertDialogHeader,
|
||||||
|
AlertDialogMedia,
|
||||||
|
AlertDialogOverlay,
|
||||||
|
AlertDialogPortal,
|
||||||
|
AlertDialogTitle,
|
||||||
|
AlertDialogTrigger,
|
||||||
|
}
|
||||||
81
frontend/src/components/ui/alert.jsx
Normal file
81
frontend/src/components/ui/alert.jsx
Normal file
@ -0,0 +1,81 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
import { cva } from "class-variance-authority";
|
||||||
|
import { cn } from "cn"
|
||||||
|
|
||||||
|
const alertVariants = cva(
|
||||||
|
"group/alert relative grid w-full gap-0.5 rounded-lg border px-2.5 py-2 text-left text-sm has-data-[slot=alert-action]:relative has-data-[slot=alert-action]:pr-18 has-[>svg]:grid-cols-[auto_1fr] has-[>svg]:gap-x-2 *:[svg]:row-span-2 *:[svg]:translate-y-0.5 *:[svg]:text-current *:[svg:not([class*='size-'])]:size-4",
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default: "bg-card text-card-foreground",
|
||||||
|
destructive:
|
||||||
|
"bg-card text-destructive *:data-[slot=alert-description]:text-destructive/90 *:[svg]:text-current",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: "default",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
function Alert({
|
||||||
|
className,
|
||||||
|
variant,
|
||||||
|
...props
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="alert"
|
||||||
|
role="alert"
|
||||||
|
className={cn(alertVariants({ variant }), className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertTitle({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="alert-title"
|
||||||
|
className={cn(
|
||||||
|
"font-medium group-has-[>svg]/alert:col-start-2 [&_a]:underline [&_a]:underline-offset-3 [&_a]:hover:text-foreground",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertDescription({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="alert-description"
|
||||||
|
className={cn(
|
||||||
|
"text-sm text-balance text-muted-foreground md:text-pretty [&_a]:underline [&_a]:underline-offset-3 [&_a]:hover:text-foreground [&_p:not(:last-child)]:mb-4",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertAction({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="alert-action"
|
||||||
|
className={cn("absolute top-2 right-2", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Alert, AlertTitle, AlertDescription, AlertAction }
|
||||||
22
frontend/src/components/ui/aspect-ratio.jsx
Normal file
22
frontend/src/components/ui/aspect-ratio.jsx
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
import { cn } from "cn"
|
||||||
|
|
||||||
|
function AspectRatio({
|
||||||
|
ratio,
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="aspect-ratio"
|
||||||
|
style={
|
||||||
|
{
|
||||||
|
"--ratio": ratio
|
||||||
|
}
|
||||||
|
}
|
||||||
|
className={cn("relative aspect-(--ratio)", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export { AspectRatio }
|
||||||
204
frontend/src/components/ui/attachment.jsx
Normal file
204
frontend/src/components/ui/attachment.jsx
Normal file
@ -0,0 +1,204 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
import { mergeProps } from "@base-ui/react/merge-props"
|
||||||
|
import { useRender } from "@base-ui/react/use-render"
|
||||||
|
import { cva } from "class-variance-authority";
|
||||||
|
import { cn } from "cn"
|
||||||
|
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
|
||||||
|
const attachmentVariants = cva(
|
||||||
|
"group/attachment relative flex w-fit max-w-full min-w-0 shrink-0 flex-wrap rounded-xl border bg-card text-card-foreground transition-colors focus-within:ring-1 focus-within:ring-ring/50 has-[>a,>button]:hover:bg-muted/50 data-[state=error]:border-destructive/30 data-[state=idle]:border-dashed",
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
size: {
|
||||||
|
default:
|
||||||
|
"gap-2 text-sm has-data-[slot=attachment-content]:px-2.5 has-data-[slot=attachment-content]:py-2 has-data-[slot=attachment-media]:p-2",
|
||||||
|
sm: "gap-2.5 text-xs has-data-[slot=attachment-content]:px-2 has-data-[slot=attachment-content]:py-1.5 has-data-[slot=attachment-media]:p-1.5",
|
||||||
|
xs: "gap-1.5 rounded-lg text-xs has-data-[slot=attachment-content]:px-1.5 has-data-[slot=attachment-content]:py-1 has-data-[slot=attachment-media]:p-1",
|
||||||
|
},
|
||||||
|
orientation: {
|
||||||
|
horizontal: "min-w-40 items-center",
|
||||||
|
vertical: "w-24 flex-col has-data-[slot=attachment-content]:w-30",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
function Attachment({
|
||||||
|
className,
|
||||||
|
state = "done",
|
||||||
|
size = "default",
|
||||||
|
orientation = "horizontal",
|
||||||
|
...props
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="attachment"
|
||||||
|
data-state={state}
|
||||||
|
data-size={size}
|
||||||
|
data-orientation={orientation}
|
||||||
|
className={cn(attachmentVariants({ size, orientation }), className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const attachmentMediaVariants = cva(
|
||||||
|
"relative flex aspect-square w-10 shrink-0 items-center justify-center overflow-hidden rounded-lg bg-muted text-foreground group-data-[orientation=vertical]/attachment:w-full group-data-[size=sm]/attachment:w-8 group-data-[size=xs]/attachment:w-7 group-data-[size=xs]/attachment:rounded-md group-data-[state=error]/attachment:bg-destructive/10 group-data-[state=error]/attachment:text-destructive group-data-[orientation=vertical]/attachment:*:data-[slot=spinner]:size-6! [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 group-data-[orientation=vertical]/attachment:[&_svg:not([class*='size-'])]:size-6 group-data-[size=xs]/attachment:[&_svg:not([class*='size-'])]:size-3.5",
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
icon: "",
|
||||||
|
image:
|
||||||
|
"opacity-60 group-data-[state=done]/attachment:opacity-100 group-data-[state=idle]/attachment:opacity-100 *:[img]:aspect-square *:[img]:w-full *:[img]:object-cover",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: "icon",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
function AttachmentMedia({
|
||||||
|
className,
|
||||||
|
variant = "icon",
|
||||||
|
...props
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="attachment-media"
|
||||||
|
data-variant={variant}
|
||||||
|
className={cn(attachmentMediaVariants({ variant }), className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AttachmentContent({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="attachment-content"
|
||||||
|
className={cn(
|
||||||
|
"max-w-full min-w-0 flex-1 leading-tight group-data-[orientation=vertical]/attachment:px-1",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AttachmentTitle({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
data-slot="attachment-title"
|
||||||
|
className={cn(
|
||||||
|
"block max-w-full min-w-0 truncate font-medium group-data-[state=processing]/attachment:shimmer group-data-[state=uploading]/attachment:shimmer",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AttachmentDescription({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
data-slot="attachment-description"
|
||||||
|
className={cn(
|
||||||
|
"mt-0.5 block min-w-0 truncate text-xs text-muted-foreground group-data-[state=error]/attachment:text-destructive/80",
|
||||||
|
"max-w-full",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AttachmentActions({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="attachment-actions"
|
||||||
|
className={cn(
|
||||||
|
"relative z-20 flex shrink-0 items-center group-data-[orientation=vertical]/attachment:absolute group-data-[orientation=vertical]/attachment:top-3 group-data-[orientation=vertical]/attachment:right-3 group-data-[orientation=vertical]/attachment:gap-1",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AttachmentAction({
|
||||||
|
className,
|
||||||
|
variant,
|
||||||
|
size = "icon-xs",
|
||||||
|
...props
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
data-slot="attachment-action"
|
||||||
|
variant={variant ?? "ghost"}
|
||||||
|
size={size}
|
||||||
|
className={cn(className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AttachmentTrigger({
|
||||||
|
className,
|
||||||
|
render,
|
||||||
|
type,
|
||||||
|
...props
|
||||||
|
}) {
|
||||||
|
return useRender({
|
||||||
|
defaultTagName: "button",
|
||||||
|
props: mergeProps({
|
||||||
|
type: render ? type : (type ?? "button"),
|
||||||
|
className: cn("absolute inset-0 z-10 outline-none", className),
|
||||||
|
}, props),
|
||||||
|
render,
|
||||||
|
state: {
|
||||||
|
slot: "attachment-trigger",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function AttachmentGroup({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="attachment-group"
|
||||||
|
className={cn(
|
||||||
|
"flex min-w-0 scroll-fade-x snap-x snap-mandatory scroll-px-1 scrollbar-none gap-3 overflow-x-auto overscroll-x-contain py-1 *:data-[slot=attachment]:flex-none *:data-[slot=attachment]:snap-start",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
Attachment,
|
||||||
|
AttachmentGroup,
|
||||||
|
AttachmentMedia,
|
||||||
|
AttachmentContent,
|
||||||
|
AttachmentTitle,
|
||||||
|
AttachmentDescription,
|
||||||
|
AttachmentActions,
|
||||||
|
AttachmentAction,
|
||||||
|
AttachmentTrigger,
|
||||||
|
}
|
||||||
113
frontend/src/components/ui/avatar.jsx
Normal file
113
frontend/src/components/ui/avatar.jsx
Normal file
@ -0,0 +1,113 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
import { Avatar as AvatarPrimitive } from "@base-ui/react/avatar"
|
||||||
|
import { cn } from "cn"
|
||||||
|
|
||||||
|
function Avatar({
|
||||||
|
className,
|
||||||
|
size = "default",
|
||||||
|
...props
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<AvatarPrimitive.Root
|
||||||
|
data-slot="avatar"
|
||||||
|
data-size={size}
|
||||||
|
className={cn(
|
||||||
|
"group/avatar relative flex size-8 shrink-0 rounded-full select-none after:absolute after:inset-0 after:rounded-full after:border after:border-border after:mix-blend-darken data-[size=lg]:size-10 data-[size=sm]:size-6 dark:after:mix-blend-lighten",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AvatarImage({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<AvatarPrimitive.Image
|
||||||
|
data-slot="avatar-image"
|
||||||
|
className={cn(
|
||||||
|
"aspect-square size-full rounded-full object-cover",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AvatarFallback({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<AvatarPrimitive.Fallback
|
||||||
|
data-slot="avatar-fallback"
|
||||||
|
className={cn(
|
||||||
|
"flex size-full items-center justify-center rounded-full bg-muted text-sm text-muted-foreground group-data-[size=sm]/avatar:text-xs",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AvatarBadge({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
data-slot="avatar-badge"
|
||||||
|
className={cn(
|
||||||
|
"absolute right-0 bottom-0 z-10 inline-flex items-center justify-center rounded-full bg-primary text-primary-foreground bg-blend-color ring-2 ring-background select-none",
|
||||||
|
"group-data-[size=sm]/avatar:size-2 group-data-[size=sm]/avatar:[&>svg]:hidden",
|
||||||
|
"group-data-[size=default]/avatar:size-2.5 group-data-[size=default]/avatar:[&>svg]:size-2",
|
||||||
|
"group-data-[size=lg]/avatar:size-3 group-data-[size=lg]/avatar:[&>svg]:size-2",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AvatarGroup({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="avatar-group"
|
||||||
|
className={cn(
|
||||||
|
"group/avatar-group flex -space-x-2 *:data-[slot=avatar]:ring-2 *:data-[slot=avatar]:ring-background",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AvatarGroupCount({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="avatar-group-count"
|
||||||
|
className={cn(
|
||||||
|
"relative flex size-8 shrink-0 items-center justify-center rounded-full bg-muted text-sm text-muted-foreground ring-2 ring-background group-has-data-[size=lg]/avatar-group:size-10 group-has-data-[size=sm]/avatar-group:size-6 [&>svg]:size-4 group-has-data-[size=lg]/avatar-group:[&>svg]:size-5 group-has-data-[size=sm]/avatar-group:[&>svg]:size-3",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
Avatar,
|
||||||
|
AvatarImage,
|
||||||
|
AvatarFallback,
|
||||||
|
AvatarGroup,
|
||||||
|
AvatarGroupCount,
|
||||||
|
AvatarBadge,
|
||||||
|
}
|
||||||
48
frontend/src/components/ui/badge.jsx
Normal file
48
frontend/src/components/ui/badge.jsx
Normal file
@ -0,0 +1,48 @@
|
|||||||
|
import { mergeProps } from "@base-ui/react/merge-props"
|
||||||
|
import { useRender } from "@base-ui/react/use-render"
|
||||||
|
import { cva } from "class-variance-authority";
|
||||||
|
import { cn } from "cn"
|
||||||
|
|
||||||
|
const badgeVariants = cva(
|
||||||
|
"group/badge inline-flex h-5 w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-4xl border border-transparent px-2 py-0.5 text-xs font-medium whitespace-nowrap transition-all focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&>svg]:pointer-events-none [&>svg]:size-3!",
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default: "bg-primary text-primary-foreground [a]:hover:bg-primary/80",
|
||||||
|
secondary:
|
||||||
|
"bg-secondary text-secondary-foreground [a]:hover:bg-secondary/80",
|
||||||
|
destructive:
|
||||||
|
"bg-destructive/10 text-destructive focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:focus-visible:ring-destructive/40 [a]:hover:bg-destructive/20",
|
||||||
|
outline:
|
||||||
|
"border-border text-foreground [a]:hover:bg-muted [a]:hover:text-muted-foreground",
|
||||||
|
ghost:
|
||||||
|
"hover:bg-muted hover:text-muted-foreground dark:hover:bg-muted/50",
|
||||||
|
link: "text-primary underline-offset-4 hover:underline",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: "default",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
function Badge({
|
||||||
|
className,
|
||||||
|
variant = "default",
|
||||||
|
render,
|
||||||
|
...props
|
||||||
|
}) {
|
||||||
|
return useRender({
|
||||||
|
defaultTagName: "span",
|
||||||
|
props: mergeProps({
|
||||||
|
className: cn(badgeVariants({ variant }), className),
|
||||||
|
}, props),
|
||||||
|
render,
|
||||||
|
state: {
|
||||||
|
slot: "badge",
|
||||||
|
variant,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Badge, badgeVariants }
|
||||||
133
frontend/src/components/ui/breadcrumb.jsx
Normal file
133
frontend/src/components/ui/breadcrumb.jsx
Normal file
@ -0,0 +1,133 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
import { mergeProps } from "@base-ui/react/merge-props"
|
||||||
|
import { useRender } from "@base-ui/react/use-render"
|
||||||
|
import { cn } from "cn"
|
||||||
|
import { ChevronRightIcon, MoreHorizontalIcon } from "lucide-react"
|
||||||
|
|
||||||
|
function Breadcrumb({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<nav
|
||||||
|
aria-label="breadcrumb"
|
||||||
|
data-slot="breadcrumb"
|
||||||
|
className={cn(className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function BreadcrumbList({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<ol
|
||||||
|
data-slot="breadcrumb-list"
|
||||||
|
className={cn(
|
||||||
|
"flex flex-wrap items-center gap-1.5 text-sm wrap-break-word text-muted-foreground",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function BreadcrumbItem({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<li
|
||||||
|
data-slot="breadcrumb-item"
|
||||||
|
className={cn("inline-flex items-center gap-1", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function BreadcrumbLink({
|
||||||
|
className,
|
||||||
|
render,
|
||||||
|
...props
|
||||||
|
}) {
|
||||||
|
return useRender({
|
||||||
|
defaultTagName: "a",
|
||||||
|
props: mergeProps({
|
||||||
|
className: cn("transition-colors hover:text-foreground", className),
|
||||||
|
}, props),
|
||||||
|
render,
|
||||||
|
state: {
|
||||||
|
slot: "breadcrumb-link",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function BreadcrumbPage({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
data-slot="breadcrumb-page"
|
||||||
|
role="link"
|
||||||
|
aria-disabled="true"
|
||||||
|
aria-current="page"
|
||||||
|
className={cn("font-normal text-foreground", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function BreadcrumbSeparator({
|
||||||
|
children,
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<li
|
||||||
|
data-slot="breadcrumb-separator"
|
||||||
|
role="presentation"
|
||||||
|
aria-hidden="true"
|
||||||
|
className={cn("[&>svg]:size-3.5", className)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children ?? (
|
||||||
|
<ChevronRightIcon />
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function BreadcrumbEllipsis({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
data-slot="breadcrumb-ellipsis"
|
||||||
|
role="presentation"
|
||||||
|
aria-hidden="true"
|
||||||
|
className={cn(
|
||||||
|
"flex size-5 items-center justify-center [&>svg]:size-4",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<MoreHorizontalIcon
|
||||||
|
/>
|
||||||
|
<span className="sr-only">More</span>
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
Breadcrumb,
|
||||||
|
BreadcrumbList,
|
||||||
|
BreadcrumbItem,
|
||||||
|
BreadcrumbLink,
|
||||||
|
BreadcrumbPage,
|
||||||
|
BreadcrumbSeparator,
|
||||||
|
BreadcrumbEllipsis,
|
||||||
|
}
|
||||||
121
frontend/src/components/ui/bubble.jsx
Normal file
121
frontend/src/components/ui/bubble.jsx
Normal file
@ -0,0 +1,121 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
import { mergeProps } from "@base-ui/react/merge-props"
|
||||||
|
import { useRender } from "@base-ui/react/use-render"
|
||||||
|
import { cva } from "class-variance-authority";
|
||||||
|
import { cn } from "cn"
|
||||||
|
|
||||||
|
function BubbleGroup({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="bubble-group"
|
||||||
|
className={cn("flex min-w-0 flex-col gap-2", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const bubbleVariants = cva(
|
||||||
|
"group/bubble relative flex w-fit max-w-[80%] min-w-0 flex-col gap-1 group-data-[align=end]/message:self-end data-[align=end]:self-end data-[variant=ghost]:max-w-full",
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default:
|
||||||
|
"*:data-[slot=bubble-content]:bg-primary *:data-[slot=bubble-content]:text-primary-foreground [&>[data-slot=bubble-content]:is(button,a):hover]:bg-primary/80",
|
||||||
|
secondary:
|
||||||
|
"*:data-[slot=bubble-content]:bg-secondary *:data-[slot=bubble-content]:text-secondary-foreground [&>[data-slot=bubble-content]:is(button,a):hover]:bg-[color-mix(in_oklch,var(--secondary),var(--foreground)_5%)]",
|
||||||
|
muted:
|
||||||
|
"*:data-[slot=bubble-content]:bg-muted [&>[data-slot=bubble-content]:is(button,a):hover]:bg-[color-mix(in_oklch,var(--muted),var(--foreground)_5%)]",
|
||||||
|
tinted:
|
||||||
|
"*:data-[slot=bubble-content]:bg-[oklch(from_var(--primary)_0.93_calc(c*0.4)_h)] *:data-[slot=bubble-content]:text-foreground dark:*:data-[slot=bubble-content]:bg-[oklch(from_var(--primary)_0.3_calc(c*0.4)_h)] [&>[data-slot=bubble-content]:is(button,a):hover]:bg-[oklch(from_var(--primary)_0.88_calc(c*0.5)_h)] dark:[&>[data-slot=bubble-content]:is(button,a):hover]:bg-[oklch(from_var(--primary)_0.35_calc(c*0.5)_h)]",
|
||||||
|
outline:
|
||||||
|
"*:data-[slot=bubble-content]:border-border *:data-[slot=bubble-content]:bg-background [&>[data-slot=bubble-content]:is(button,a):hover]:bg-muted [&>[data-slot=bubble-content]:is(button,a):hover]:text-foreground dark:[&>[data-slot=bubble-content]:is(button,a):hover]:bg-input/30",
|
||||||
|
ghost:
|
||||||
|
"border-none *:data-[slot=bubble-content]:rounded-none *:data-[slot=bubble-content]:bg-transparent *:data-[slot=bubble-content]:p-0 [&>[data-slot=bubble-content]:is(button,a):hover]:bg-muted [&>[data-slot=bubble-content]:is(button,a):hover]:text-foreground dark:[&>[data-slot=bubble-content]:is(button,a):hover]:bg-muted/50",
|
||||||
|
destructive:
|
||||||
|
"*:data-[slot=bubble-content]:bg-destructive/10 *:data-[slot=bubble-content]:text-destructive dark:*:data-[slot=bubble-content]:bg-destructive/20 [&>[data-slot=bubble-content]:is(button,a):hover]:bg-destructive/20 dark:[&>[data-slot=bubble-content]:is(button,a):hover]:bg-destructive/30",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: "default",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
function Bubble({
|
||||||
|
variant = "default",
|
||||||
|
align = "start",
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="bubble"
|
||||||
|
data-variant={variant}
|
||||||
|
data-align={align}
|
||||||
|
className={cn(bubbleVariants({ variant }), className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function BubbleContent({
|
||||||
|
className,
|
||||||
|
render,
|
||||||
|
...props
|
||||||
|
}) {
|
||||||
|
return useRender({
|
||||||
|
defaultTagName: "div",
|
||||||
|
props: mergeProps({
|
||||||
|
className: cn(
|
||||||
|
"w-fit max-w-full min-w-0 overflow-hidden rounded-xl border border-transparent px-3 py-2 text-sm leading-relaxed wrap-break-word group-data-[align=end]/bubble:self-end [button]:text-left [button,a]:transition-colors [button,a]:outline-none [button,a]:focus-visible:border-ring [button,a]:focus-visible:ring-3 [button,a]:focus-visible:ring-ring/50",
|
||||||
|
className
|
||||||
|
),
|
||||||
|
}, props),
|
||||||
|
render,
|
||||||
|
state: {
|
||||||
|
slot: "bubble-content",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const bubbleReactionsVariants = cva(
|
||||||
|
"absolute z-10 flex w-fit shrink-0 items-center justify-center gap-1 rounded-full bg-muted px-1.5 py-0.5 text-sm ring-3 ring-card has-[button]:p-0",
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
side: {
|
||||||
|
top: "top-0 -translate-y-3/4",
|
||||||
|
bottom: "bottom-0 translate-y-3/4",
|
||||||
|
},
|
||||||
|
align: {
|
||||||
|
start: "left-3",
|
||||||
|
end: "right-3",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
side: "bottom",
|
||||||
|
align: "end",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
function BubbleReactions({
|
||||||
|
side = "bottom",
|
||||||
|
align = "end",
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="bubble-reactions"
|
||||||
|
data-align={align}
|
||||||
|
data-side={side}
|
||||||
|
className={cn(bubbleReactionsVariants({ side, align }), className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { BubbleGroup, Bubble, BubbleContent, BubbleReactions }
|
||||||
84
frontend/src/components/ui/button-group.jsx
Normal file
84
frontend/src/components/ui/button-group.jsx
Normal file
@ -0,0 +1,84 @@
|
|||||||
|
import { mergeProps } from "@base-ui/react/merge-props"
|
||||||
|
import { useRender } from "@base-ui/react/use-render"
|
||||||
|
import { cva } from "class-variance-authority";
|
||||||
|
import { cn } from "cn"
|
||||||
|
|
||||||
|
import { Separator } from "@/components/ui/separator"
|
||||||
|
|
||||||
|
const buttonGroupVariants = cva(
|
||||||
|
"flex w-fit items-stretch *:focus-visible:relative *:focus-visible:z-10 has-[>[data-slot=button-group]]:gap-2 has-[select[aria-hidden=true]:last-child]:[&>[data-slot=select-trigger]:last-of-type]:rounded-r-lg [&>[data-slot=select-trigger]:not([class*='w-'])]:w-fit [&>input]:flex-1",
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
orientation: {
|
||||||
|
horizontal:
|
||||||
|
"*:data-slot:rounded-r-none [&>[data-slot]:not(:has(~[data-slot]))]:rounded-r-lg! [&>[data-slot]~[data-slot]]:rounded-l-none [&>[data-slot]~[data-slot]]:border-l-0",
|
||||||
|
vertical:
|
||||||
|
"flex-col *:data-slot:rounded-b-none [&>[data-slot]:not(:has(~[data-slot]))]:rounded-b-lg! [&>[data-slot]~[data-slot]]:rounded-t-none [&>[data-slot]~[data-slot]]:border-t-0",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
orientation: "horizontal",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
function ButtonGroup({
|
||||||
|
className,
|
||||||
|
orientation,
|
||||||
|
...props
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
role="group"
|
||||||
|
data-slot="button-group"
|
||||||
|
data-orientation={orientation}
|
||||||
|
className={cn(buttonGroupVariants({ orientation }), className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ButtonGroupText({
|
||||||
|
className,
|
||||||
|
render,
|
||||||
|
...props
|
||||||
|
}) {
|
||||||
|
return useRender({
|
||||||
|
defaultTagName: "div",
|
||||||
|
props: mergeProps({
|
||||||
|
className: cn(
|
||||||
|
"flex items-center gap-2 rounded-lg border bg-muted px-2.5 text-sm font-medium [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4",
|
||||||
|
className
|
||||||
|
),
|
||||||
|
}, props),
|
||||||
|
render,
|
||||||
|
state: {
|
||||||
|
slot: "button-group-text",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function ButtonGroupSeparator({
|
||||||
|
className,
|
||||||
|
orientation = "vertical",
|
||||||
|
...props
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<Separator
|
||||||
|
data-slot="button-group-separator"
|
||||||
|
orientation={orientation}
|
||||||
|
className={cn(
|
||||||
|
"relative self-stretch bg-input data-horizontal:mx-px data-horizontal:w-auto data-vertical:my-px data-vertical:h-auto",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
ButtonGroup,
|
||||||
|
ButtonGroupSeparator,
|
||||||
|
ButtonGroupText,
|
||||||
|
buttonGroupVariants,
|
||||||
|
}
|
||||||
57
frontend/src/components/ui/button.jsx
Normal file
57
frontend/src/components/ui/button.jsx
Normal file
@ -0,0 +1,57 @@
|
|||||||
|
import { Button as ButtonPrimitive } from "@base-ui/react/button"
|
||||||
|
import { cva } from "class-variance-authority";
|
||||||
|
import { cn } from "cn"
|
||||||
|
|
||||||
|
const buttonVariants = cva(
|
||||||
|
"group/button inline-flex shrink-0 items-center justify-center rounded-lg border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default: "bg-primary text-primary-foreground hover:bg-primary/80",
|
||||||
|
outline:
|
||||||
|
"border-border bg-background hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50",
|
||||||
|
secondary:
|
||||||
|
"bg-secondary text-secondary-foreground hover:bg-[color-mix(in_oklch,var(--secondary),var(--foreground)_5%)] aria-expanded:bg-secondary aria-expanded:text-secondary-foreground",
|
||||||
|
ghost:
|
||||||
|
"hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50",
|
||||||
|
destructive:
|
||||||
|
"bg-destructive/10 text-destructive hover:bg-destructive/20 focus-visible:border-destructive/40 focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:hover:bg-destructive/30 dark:focus-visible:ring-destructive/40",
|
||||||
|
link: "text-primary underline-offset-4 hover:underline",
|
||||||
|
},
|
||||||
|
size: {
|
||||||
|
default:
|
||||||
|
"h-8 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
|
||||||
|
xs: "h-6 gap-1 rounded-[min(var(--radius-md),10px)] px-2 text-xs in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3",
|
||||||
|
sm: "h-7 gap-1 rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem] in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5",
|
||||||
|
lg: "h-9 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
|
||||||
|
icon: "size-8",
|
||||||
|
"icon-xs":
|
||||||
|
"size-6 rounded-[min(var(--radius-md),10px)] in-data-[slot=button-group]:rounded-lg [&_svg:not([class*='size-'])]:size-3",
|
||||||
|
"icon-sm":
|
||||||
|
"size-7 rounded-[min(var(--radius-md),12px)] in-data-[slot=button-group]:rounded-lg",
|
||||||
|
"icon-lg": "size-9",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: "default",
|
||||||
|
size: "default",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
function Button({
|
||||||
|
className,
|
||||||
|
variant = "default",
|
||||||
|
size = "default",
|
||||||
|
...props
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<ButtonPrimitive
|
||||||
|
data-slot="button"
|
||||||
|
className={cn(buttonVariants({ variant, size, className }))}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Button, buttonVariants }
|
||||||
214
frontend/src/components/ui/calendar.jsx
Normal file
214
frontend/src/components/ui/calendar.jsx
Normal file
@ -0,0 +1,214 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import { cn } from "cn"
|
||||||
|
import { DayPicker, getDefaultClassNames } from "react-day-picker";
|
||||||
|
|
||||||
|
import { Button, buttonVariants } from "@/components/ui/button"
|
||||||
|
import { ChevronLeftIcon, ChevronRightIcon, ChevronDownIcon } from "lucide-react"
|
||||||
|
|
||||||
|
function Calendar({
|
||||||
|
className,
|
||||||
|
classNames,
|
||||||
|
showOutsideDays = true,
|
||||||
|
captionLayout = "label",
|
||||||
|
buttonVariant = "ghost",
|
||||||
|
locale,
|
||||||
|
formatters,
|
||||||
|
components,
|
||||||
|
...props
|
||||||
|
}) {
|
||||||
|
const defaultClassNames = getDefaultClassNames()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DayPicker
|
||||||
|
showOutsideDays={showOutsideDays}
|
||||||
|
className={cn(
|
||||||
|
"group/calendar bg-background p-2 [--cell-radius:var(--radius-md)] [--cell-size:--spacing(7)] in-data-[slot=card-content]:bg-transparent in-data-[slot=popover-content]:bg-transparent",
|
||||||
|
String.raw`rtl:**:[.rdp-button\_next>svg]:rotate-180`,
|
||||||
|
String.raw`rtl:**:[.rdp-button\_previous>svg]:rotate-180`,
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
captionLayout={captionLayout}
|
||||||
|
locale={locale}
|
||||||
|
formatters={{
|
||||||
|
formatMonthDropdown: (date) =>
|
||||||
|
date.toLocaleString(locale?.code, { month: "short" }),
|
||||||
|
...formatters,
|
||||||
|
}}
|
||||||
|
classNames={{
|
||||||
|
root: cn("w-fit", defaultClassNames.root),
|
||||||
|
months: cn(
|
||||||
|
"relative flex flex-col gap-4 md:flex-row",
|
||||||
|
defaultClassNames.months
|
||||||
|
),
|
||||||
|
month: cn("flex w-full flex-col gap-4", defaultClassNames.month),
|
||||||
|
nav: cn(
|
||||||
|
"absolute inset-x-0 top-0 flex w-full items-center justify-between gap-1",
|
||||||
|
defaultClassNames.nav
|
||||||
|
),
|
||||||
|
button_previous: cn(
|
||||||
|
buttonVariants({ variant: buttonVariant }),
|
||||||
|
"size-(--cell-size) p-0 select-none aria-disabled:opacity-50",
|
||||||
|
defaultClassNames.button_previous
|
||||||
|
),
|
||||||
|
button_next: cn(
|
||||||
|
buttonVariants({ variant: buttonVariant }),
|
||||||
|
"size-(--cell-size) p-0 select-none aria-disabled:opacity-50",
|
||||||
|
defaultClassNames.button_next
|
||||||
|
),
|
||||||
|
month_caption: cn(
|
||||||
|
"flex h-(--cell-size) w-full items-center justify-center px-(--cell-size)",
|
||||||
|
defaultClassNames.month_caption
|
||||||
|
),
|
||||||
|
dropdowns: cn(
|
||||||
|
"flex h-(--cell-size) w-full items-center justify-center gap-1.5 text-sm font-medium",
|
||||||
|
defaultClassNames.dropdowns
|
||||||
|
),
|
||||||
|
dropdown_root: cn(
|
||||||
|
"relative rounded-(--cell-radius)",
|
||||||
|
defaultClassNames.dropdown_root
|
||||||
|
),
|
||||||
|
dropdown: cn(
|
||||||
|
"absolute inset-0 bg-popover opacity-0",
|
||||||
|
defaultClassNames.dropdown
|
||||||
|
),
|
||||||
|
caption_label: cn(
|
||||||
|
"font-medium select-none",
|
||||||
|
captionLayout === "label"
|
||||||
|
? "text-sm"
|
||||||
|
: "flex items-center gap-1 rounded-(--cell-radius) text-sm [&>svg]:size-3.5 [&>svg]:text-muted-foreground",
|
||||||
|
defaultClassNames.caption_label
|
||||||
|
),
|
||||||
|
month_grid: cn("w-full border-collapse", defaultClassNames.month_grid),
|
||||||
|
weekdays: cn("flex", defaultClassNames.weekdays),
|
||||||
|
weekday: cn(
|
||||||
|
"flex-1 rounded-(--cell-radius) text-[0.8rem] font-normal text-muted-foreground select-none",
|
||||||
|
defaultClassNames.weekday
|
||||||
|
),
|
||||||
|
week: cn("mt-2 flex w-full", defaultClassNames.week),
|
||||||
|
week_number_header: cn(
|
||||||
|
"w-(--cell-size) select-none",
|
||||||
|
defaultClassNames.week_number_header
|
||||||
|
),
|
||||||
|
week_number: cn(
|
||||||
|
"text-[0.8rem] text-muted-foreground select-none",
|
||||||
|
defaultClassNames.week_number
|
||||||
|
),
|
||||||
|
day: cn(
|
||||||
|
"group/day relative aspect-square h-full w-full rounded-(--cell-radius) p-0 text-center select-none [&:last-child[data-selected=true]_button]:rounded-r-(--cell-radius)",
|
||||||
|
props.showWeekNumber
|
||||||
|
? "[&:nth-child(2)[data-selected=true]_button]:rounded-l-(--cell-radius)"
|
||||||
|
: "[&:first-child[data-selected=true]_button]:rounded-l-(--cell-radius)",
|
||||||
|
defaultClassNames.day
|
||||||
|
),
|
||||||
|
range_start: cn(
|
||||||
|
"relative isolate z-0 rounded-l-(--cell-radius) bg-muted after:absolute after:inset-y-0 after:right-0 after:w-4 after:bg-muted",
|
||||||
|
defaultClassNames.range_start
|
||||||
|
),
|
||||||
|
range_middle: cn("rounded-none", defaultClassNames.range_middle),
|
||||||
|
range_end: cn(
|
||||||
|
"relative isolate z-0 rounded-r-(--cell-radius) bg-muted after:absolute after:inset-y-0 after:left-0 after:w-4 after:bg-muted",
|
||||||
|
defaultClassNames.range_end
|
||||||
|
),
|
||||||
|
today: cn(
|
||||||
|
"rounded-(--cell-radius) bg-muted text-foreground data-[selected=true]:rounded-none",
|
||||||
|
defaultClassNames.today
|
||||||
|
),
|
||||||
|
outside: cn(
|
||||||
|
"text-muted-foreground aria-selected:text-muted-foreground",
|
||||||
|
defaultClassNames.outside
|
||||||
|
),
|
||||||
|
disabled: cn(
|
||||||
|
"text-muted-foreground opacity-50",
|
||||||
|
defaultClassNames.disabled
|
||||||
|
),
|
||||||
|
hidden: cn("invisible", defaultClassNames.hidden),
|
||||||
|
...classNames,
|
||||||
|
}}
|
||||||
|
components={{
|
||||||
|
Root: ({ className, rootRef, ...props }) => {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="calendar"
|
||||||
|
ref={rootRef}
|
||||||
|
className={cn(className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
Chevron: ({ className, orientation, ...props }) => {
|
||||||
|
if (orientation === "left") {
|
||||||
|
return (
|
||||||
|
<ChevronLeftIcon className={cn("size-4", className)} {...props} />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (orientation === "right") {
|
||||||
|
return (
|
||||||
|
<ChevronRightIcon className={cn("size-4", className)} {...props} />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ChevronDownIcon className={cn("size-4", className)} {...props} />
|
||||||
|
)
|
||||||
|
},
|
||||||
|
DayButton: ({ ...props }) => (
|
||||||
|
<CalendarDayButton locale={locale} {...props} />
|
||||||
|
),
|
||||||
|
WeekNumber: ({ children, ...props }) => {
|
||||||
|
return (
|
||||||
|
<td {...props}>
|
||||||
|
<div className="flex size-(--cell-size) items-center justify-center text-center">
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
...components,
|
||||||
|
}}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CalendarDayButton({
|
||||||
|
className,
|
||||||
|
day,
|
||||||
|
modifiers,
|
||||||
|
locale,
|
||||||
|
...props
|
||||||
|
}) {
|
||||||
|
const defaultClassNames = getDefaultClassNames()
|
||||||
|
|
||||||
|
const ref = React.useRef(null)
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (modifiers.focused) ref.current?.focus()
|
||||||
|
}, [modifiers.focused])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
data-day={day.date.toLocaleDateString(locale?.code)}
|
||||||
|
data-selected-single={
|
||||||
|
modifiers.selected &&
|
||||||
|
!modifiers.range_start &&
|
||||||
|
!modifiers.range_end &&
|
||||||
|
!modifiers.range_middle
|
||||||
|
}
|
||||||
|
data-range-start={modifiers.range_start}
|
||||||
|
data-range-end={modifiers.range_end}
|
||||||
|
data-range-middle={modifiers.range_middle}
|
||||||
|
className={cn(
|
||||||
|
"relative isolate z-10 flex aspect-square size-auto w-full min-w-(--cell-size) flex-col gap-1 border-0 leading-none font-normal group-data-[focused=true]/day:relative group-data-[focused=true]/day:z-10 group-data-[focused=true]/day:border-ring group-data-[focused=true]/day:ring-[3px] group-data-[focused=true]/day:ring-ring/50 data-[range-end=true]:rounded-(--cell-radius) data-[range-end=true]:rounded-r-(--cell-radius) data-[range-end=true]:bg-primary data-[range-end=true]:text-primary-foreground data-[range-middle=true]:rounded-none data-[range-middle=true]:bg-muted data-[range-middle=true]:text-foreground data-[range-start=true]:rounded-(--cell-radius) data-[range-start=true]:rounded-l-(--cell-radius) data-[range-start=true]:bg-primary data-[range-start=true]:text-primary-foreground data-[selected-single=true]:bg-primary data-[selected-single=true]:text-primary-foreground dark:hover:text-foreground [&>span]:text-xs [&>span]:opacity-70",
|
||||||
|
defaultClassNames.day,
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Calendar, CalendarDayButton }
|
||||||
120
frontend/src/components/ui/card.jsx
Normal file
120
frontend/src/components/ui/card.jsx
Normal file
@ -0,0 +1,120 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
import { cn } from "cn"
|
||||||
|
|
||||||
|
function Card({
|
||||||
|
className,
|
||||||
|
size = "default",
|
||||||
|
...props
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="card"
|
||||||
|
data-size={size}
|
||||||
|
className={cn(
|
||||||
|
"group/card flex flex-col gap-(--card-spacing) overflow-hidden rounded-xl border border-border bg-card py-(--card-spacing) text-sm text-card-foreground [--card-spacing:--spacing(4)] has-data-[slot=card-footer]:pb-0 has-[>img:first-child]:pt-0 data-[size=sm]:[--card-spacing:--spacing(3)] data-[size=sm]:has-data-[slot=card-footer]:pb-0 *:[img:first-child]:rounded-t-xl *:[img:last-child]:rounded-b-xl",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CardHeader({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="card-header"
|
||||||
|
className={cn(
|
||||||
|
"group/card-header @container/card-header grid auto-rows-min items-start gap-1 rounded-t-xl px-(--card-spacing) has-data-[slot=card-action]:grid-cols-[1fr_auto] has-data-[slot=card-description]:grid-rows-[auto_auto] [.border-b]:pb-(--card-spacing)",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CardTitle({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="card-title"
|
||||||
|
className={cn(
|
||||||
|
"font-heading text-base leading-snug font-medium group-data-[size=sm]/card:text-sm",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CardDescription({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="card-description"
|
||||||
|
className={cn("text-sm text-muted-foreground", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CardAction({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="card-action"
|
||||||
|
className={cn(
|
||||||
|
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CardContent({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="card-content"
|
||||||
|
className={cn("px-(--card-spacing)", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CardFooter({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="card-footer"
|
||||||
|
className={cn(
|
||||||
|
"flex items-center rounded-b-xl border-t bg-muted/50 p-(--card-spacing)",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
Card,
|
||||||
|
CardHeader,
|
||||||
|
CardFooter,
|
||||||
|
CardTitle,
|
||||||
|
CardAction,
|
||||||
|
CardDescription,
|
||||||
|
CardContent,
|
||||||
|
}
|
||||||
215
frontend/src/components/ui/carousel.jsx
Normal file
215
frontend/src/components/ui/carousel.jsx
Normal file
@ -0,0 +1,215 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
import { cn } from "cn"
|
||||||
|
import useEmblaCarousel from "embla-carousel-react";
|
||||||
|
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import { ChevronLeftIcon, ChevronRightIcon } from "lucide-react"
|
||||||
|
|
||||||
|
const CarouselContext = React.createContext(null)
|
||||||
|
|
||||||
|
function useCarousel() {
|
||||||
|
const context = React.useContext(CarouselContext)
|
||||||
|
|
||||||
|
if (!context) {
|
||||||
|
throw new Error("useCarousel must be used within a <Carousel />")
|
||||||
|
}
|
||||||
|
|
||||||
|
return context
|
||||||
|
}
|
||||||
|
|
||||||
|
function Carousel({
|
||||||
|
orientation = "horizontal",
|
||||||
|
opts,
|
||||||
|
setApi,
|
||||||
|
plugins,
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}) {
|
||||||
|
const [carouselRef, api] = useEmblaCarousel(
|
||||||
|
{
|
||||||
|
...opts,
|
||||||
|
axis: orientation === "horizontal" ? "x" : "y",
|
||||||
|
},
|
||||||
|
plugins
|
||||||
|
)
|
||||||
|
const [canScrollPrev, setCanScrollPrev] = React.useState(false)
|
||||||
|
const [canScrollNext, setCanScrollNext] = React.useState(false)
|
||||||
|
|
||||||
|
const onSelect = React.useCallback((api) => {
|
||||||
|
if (!api) return
|
||||||
|
setCanScrollPrev(api.canScrollPrev())
|
||||||
|
setCanScrollNext(api.canScrollNext())
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const scrollPrev = React.useCallback(() => {
|
||||||
|
api?.scrollPrev()
|
||||||
|
}, [api])
|
||||||
|
|
||||||
|
const scrollNext = React.useCallback(() => {
|
||||||
|
api?.scrollNext()
|
||||||
|
}, [api])
|
||||||
|
|
||||||
|
const handleKeyDown = React.useCallback(
|
||||||
|
(event) => {
|
||||||
|
if (event.key === "ArrowLeft") {
|
||||||
|
event.preventDefault()
|
||||||
|
scrollPrev()
|
||||||
|
} else if (event.key === "ArrowRight") {
|
||||||
|
event.preventDefault()
|
||||||
|
scrollNext()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[scrollPrev, scrollNext]
|
||||||
|
)
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (!api || !setApi) return
|
||||||
|
setApi(api)
|
||||||
|
}, [api, setApi])
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (!api) return
|
||||||
|
onSelect(api)
|
||||||
|
api.on("reInit", onSelect)
|
||||||
|
api.on("select", onSelect)
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
api?.off("select", onSelect)
|
||||||
|
}
|
||||||
|
}, [api, onSelect])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<CarouselContext.Provider
|
||||||
|
value={{
|
||||||
|
carouselRef,
|
||||||
|
api: api,
|
||||||
|
opts,
|
||||||
|
orientation:
|
||||||
|
orientation || (opts?.axis === "y" ? "vertical" : "horizontal"),
|
||||||
|
scrollPrev,
|
||||||
|
scrollNext,
|
||||||
|
canScrollPrev,
|
||||||
|
canScrollNext,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
onKeyDownCapture={handleKeyDown}
|
||||||
|
className={cn("relative", className)}
|
||||||
|
role="region"
|
||||||
|
aria-roledescription="carousel"
|
||||||
|
data-slot="carousel"
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</CarouselContext.Provider>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CarouselContent({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}) {
|
||||||
|
const { carouselRef, orientation } = useCarousel()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={carouselRef}
|
||||||
|
className="overflow-hidden"
|
||||||
|
data-slot="carousel-content"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"flex",
|
||||||
|
orientation === "horizontal" ? "-ml-4" : "-mt-4 flex-col",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CarouselItem({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}) {
|
||||||
|
const { orientation } = useCarousel()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
role="group"
|
||||||
|
aria-roledescription="slide"
|
||||||
|
data-slot="carousel-item"
|
||||||
|
className={cn(
|
||||||
|
"min-w-0 shrink-0 grow-0 basis-full",
|
||||||
|
orientation === "horizontal" ? "pl-4" : "pt-4",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CarouselPrevious({
|
||||||
|
className,
|
||||||
|
variant = "outline",
|
||||||
|
size = "icon-sm",
|
||||||
|
...props
|
||||||
|
}) {
|
||||||
|
const { orientation, scrollPrev, canScrollPrev } = useCarousel()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
data-slot="carousel-previous"
|
||||||
|
variant={variant}
|
||||||
|
size={size}
|
||||||
|
className={cn(
|
||||||
|
"absolute touch-manipulation rounded-full",
|
||||||
|
orientation === "horizontal"
|
||||||
|
? "inset-y-0 -left-12 my-auto"
|
||||||
|
: "-top-12 left-1/2 -translate-x-1/2 rotate-90",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
disabled={!canScrollPrev}
|
||||||
|
onClick={scrollPrev}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<ChevronLeftIcon />
|
||||||
|
<span className="sr-only">Previous slide</span>
|
||||||
|
</Button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CarouselNext({
|
||||||
|
className,
|
||||||
|
variant = "outline",
|
||||||
|
size = "icon-sm",
|
||||||
|
...props
|
||||||
|
}) {
|
||||||
|
const { orientation, scrollNext, canScrollNext } = useCarousel()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
data-slot="carousel-next"
|
||||||
|
variant={variant}
|
||||||
|
size={size}
|
||||||
|
className={cn(
|
||||||
|
"absolute touch-manipulation rounded-full",
|
||||||
|
orientation === "horizontal"
|
||||||
|
? "inset-y-0 -right-12 my-auto"
|
||||||
|
: "-bottom-12 left-1/2 -translate-x-1/2 rotate-90",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
disabled={!canScrollNext}
|
||||||
|
onClick={scrollNext}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<ChevronRightIcon />
|
||||||
|
<span className="sr-only">Next slide</span>
|
||||||
|
</Button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Carousel, CarouselContent, CarouselItem, CarouselPrevious, CarouselNext, useCarousel };
|
||||||
335
frontend/src/components/ui/chart.jsx
Normal file
335
frontend/src/components/ui/chart.jsx
Normal file
@ -0,0 +1,335 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
import { cn } from "cn"
|
||||||
|
import * as RechartsPrimitive from "recharts"
|
||||||
|
|
||||||
|
// Format: { THEME_NAME: CSS_SELECTOR }
|
||||||
|
const THEMES = {
|
||||||
|
light: "",
|
||||||
|
dark: ".dark"
|
||||||
|
}
|
||||||
|
|
||||||
|
const INITIAL_DIMENSION = {
|
||||||
|
width: 320,
|
||||||
|
height: 200
|
||||||
|
}
|
||||||
|
|
||||||
|
const ChartContext = React.createContext(null)
|
||||||
|
|
||||||
|
function useChart() {
|
||||||
|
const context = React.useContext(ChartContext)
|
||||||
|
|
||||||
|
if (!context) {
|
||||||
|
throw new Error("useChart must be used within a <ChartContainer />")
|
||||||
|
}
|
||||||
|
|
||||||
|
return context
|
||||||
|
}
|
||||||
|
|
||||||
|
function ChartContainer({
|
||||||
|
id,
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
config,
|
||||||
|
initialDimension = INITIAL_DIMENSION,
|
||||||
|
...props
|
||||||
|
}) {
|
||||||
|
const uniqueId = React.useId()
|
||||||
|
const chartId = `chart-${id ?? uniqueId.replace(/:/g, "")}`
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ChartContext.Provider value={{ config }}>
|
||||||
|
<div
|
||||||
|
data-slot="chart"
|
||||||
|
data-chart={chartId}
|
||||||
|
className={cn(
|
||||||
|
"flex aspect-video justify-center text-xs [&_.recharts-cartesian-axis-tick_text]:fill-muted-foreground [&_.recharts-cartesian-grid_line[stroke='#ccc']]:stroke-border/50 [&_.recharts-curve.recharts-tooltip-cursor]:stroke-border [&_.recharts-dot[stroke='#fff']]:stroke-transparent [&_.recharts-layer]:outline-hidden [&_.recharts-polar-grid_[stroke='#ccc']]:stroke-border [&_.recharts-radial-bar-background-sector]:fill-muted [&_.recharts-rectangle.recharts-tooltip-cursor]:fill-muted [&_.recharts-reference-line_[stroke='#ccc']]:stroke-border [&_.recharts-sector]:outline-hidden [&_.recharts-sector[stroke='#fff']]:stroke-transparent [&_.recharts-surface]:outline-hidden",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<ChartStyle id={chartId} config={config} />
|
||||||
|
<RechartsPrimitive.ResponsiveContainer
|
||||||
|
initialDimension={initialDimension}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</RechartsPrimitive.ResponsiveContainer>
|
||||||
|
</div>
|
||||||
|
</ChartContext.Provider>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const ChartStyle = ({
|
||||||
|
id,
|
||||||
|
config
|
||||||
|
}) => {
|
||||||
|
const colorConfig = Object.entries(config).filter(
|
||||||
|
([, config]) => config.theme ?? config.color
|
||||||
|
)
|
||||||
|
|
||||||
|
if (!colorConfig.length) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<style
|
||||||
|
dangerouslySetInnerHTML={{
|
||||||
|
__html: Object.entries(THEMES)
|
||||||
|
.map(
|
||||||
|
([theme, prefix]) => `
|
||||||
|
${prefix} [data-chart=${id}] {
|
||||||
|
${colorConfig
|
||||||
|
.map(([key, itemConfig]) => {
|
||||||
|
const color =
|
||||||
|
itemConfig.theme?.[theme] ??
|
||||||
|
itemConfig.color
|
||||||
|
return color ? ` --color-${key}: ${color};` : null
|
||||||
|
})
|
||||||
|
.join("\n")}
|
||||||
|
}
|
||||||
|
`
|
||||||
|
)
|
||||||
|
.join("\n"),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const ChartTooltip = RechartsPrimitive.Tooltip
|
||||||
|
|
||||||
|
function ChartTooltipContent({
|
||||||
|
active,
|
||||||
|
payload,
|
||||||
|
className,
|
||||||
|
indicator = "dot",
|
||||||
|
hideLabel = false,
|
||||||
|
hideIndicator = false,
|
||||||
|
label,
|
||||||
|
labelFormatter,
|
||||||
|
labelClassName,
|
||||||
|
formatter,
|
||||||
|
color,
|
||||||
|
nameKey,
|
||||||
|
labelKey
|
||||||
|
}) {
|
||||||
|
const { config } = useChart()
|
||||||
|
|
||||||
|
const tooltipLabel = React.useMemo(() => {
|
||||||
|
if (hideLabel || !payload?.length) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const [item] = payload
|
||||||
|
const key = `${labelKey ?? item?.dataKey ?? item?.name ?? "value"}`
|
||||||
|
const itemConfig = getPayloadConfigFromPayload(config, item, key)
|
||||||
|
const value =
|
||||||
|
!labelKey && typeof label === "string"
|
||||||
|
? (config[label]?.label ?? label)
|
||||||
|
: itemConfig?.label
|
||||||
|
|
||||||
|
if (labelFormatter) {
|
||||||
|
return (
|
||||||
|
<div className={cn("font-medium", labelClassName)}>
|
||||||
|
{labelFormatter(value, payload)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!value) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return <div className={cn("font-medium", labelClassName)}>{value}</div>
|
||||||
|
}, [
|
||||||
|
label,
|
||||||
|
labelFormatter,
|
||||||
|
payload,
|
||||||
|
hideLabel,
|
||||||
|
labelClassName,
|
||||||
|
config,
|
||||||
|
labelKey,
|
||||||
|
])
|
||||||
|
|
||||||
|
if (!active || !payload?.length) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const nestLabel = payload.length === 1 && indicator !== "dot"
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"grid min-w-32 items-start gap-1.5 rounded-lg border border-border/50 bg-background px-2.5 py-1.5 text-xs shadow-xl",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{!nestLabel ? tooltipLabel : null}
|
||||||
|
<div className="grid gap-1.5">
|
||||||
|
{payload
|
||||||
|
.filter((item) => item.type !== "none")
|
||||||
|
.map((item, index) => {
|
||||||
|
const key = `${nameKey ?? item.name ?? item.dataKey ?? "value"}`
|
||||||
|
const itemConfig = getPayloadConfigFromPayload(config, item, key)
|
||||||
|
const indicatorColor = color ?? item.payload?.fill ?? item.color
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={index}
|
||||||
|
className={cn(
|
||||||
|
"flex w-full flex-wrap items-stretch gap-2 [&>svg]:h-2.5 [&>svg]:w-2.5 [&>svg]:text-muted-foreground",
|
||||||
|
indicator === "dot" && "items-center"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{formatter && item?.value !== undefined && item.name ? (
|
||||||
|
formatter(item.value, item.name, item, index, item.payload)
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{itemConfig?.icon ? (
|
||||||
|
<itemConfig.icon />
|
||||||
|
) : (
|
||||||
|
!hideIndicator && (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"shrink-0 rounded-[2px] border-(--color-border) bg-(--color-bg)",
|
||||||
|
{
|
||||||
|
"h-2.5 w-2.5": indicator === "dot",
|
||||||
|
"w-1": indicator === "line",
|
||||||
|
"w-0 border-[1.5px] border-dashed bg-transparent":
|
||||||
|
indicator === "dashed",
|
||||||
|
"my-0.5": nestLabel && indicator === "dashed",
|
||||||
|
}
|
||||||
|
)}
|
||||||
|
style={
|
||||||
|
{
|
||||||
|
"--color-bg": indicatorColor,
|
||||||
|
"--color-border": indicatorColor
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"flex flex-1 justify-between leading-none",
|
||||||
|
nestLabel ? "items-end" : "items-center"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="grid gap-1.5">
|
||||||
|
{nestLabel ? tooltipLabel : null}
|
||||||
|
<span className="text-muted-foreground">
|
||||||
|
{itemConfig?.label ?? item.name}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{item.value != null && (
|
||||||
|
<span className="font-mono font-medium text-foreground tabular-nums">
|
||||||
|
{typeof item.value === "number"
|
||||||
|
? item.value.toLocaleString()
|
||||||
|
: String(item.value)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const ChartLegend = RechartsPrimitive.Legend
|
||||||
|
|
||||||
|
function ChartLegendContent({
|
||||||
|
className,
|
||||||
|
hideIcon = false,
|
||||||
|
payload,
|
||||||
|
verticalAlign = "bottom",
|
||||||
|
nameKey
|
||||||
|
}) {
|
||||||
|
const { config } = useChart()
|
||||||
|
|
||||||
|
if (!payload?.length) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"flex items-center justify-center gap-4",
|
||||||
|
verticalAlign === "top" ? "pb-3" : "pt-3",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{payload
|
||||||
|
.filter((item) => item.type !== "none")
|
||||||
|
.map((item, index) => {
|
||||||
|
const key = `${nameKey ?? item.dataKey ?? "value"}`
|
||||||
|
const itemConfig = getPayloadConfigFromPayload(config, item, key)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={index}
|
||||||
|
className={cn(
|
||||||
|
"flex items-center gap-1.5 [&>svg]:h-3 [&>svg]:w-3 [&>svg]:text-muted-foreground"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{itemConfig?.icon && !hideIcon ? (
|
||||||
|
<itemConfig.icon />
|
||||||
|
) : (
|
||||||
|
<div
|
||||||
|
className="h-2 w-2 shrink-0 rounded-[2px]"
|
||||||
|
style={{
|
||||||
|
backgroundColor: item.color,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{itemConfig?.label}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function getPayloadConfigFromPayload(
|
||||||
|
config,
|
||||||
|
payload,
|
||||||
|
key
|
||||||
|
) {
|
||||||
|
if (typeof payload !== "object" || payload === null) {
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
const payloadPayload =
|
||||||
|
"payload" in payload &&
|
||||||
|
typeof payload.payload === "object" &&
|
||||||
|
payload.payload !== null
|
||||||
|
? payload.payload
|
||||||
|
: undefined
|
||||||
|
|
||||||
|
let configLabelKey = key
|
||||||
|
|
||||||
|
if (
|
||||||
|
key in payload &&
|
||||||
|
typeof payload[key] === "string"
|
||||||
|
) {
|
||||||
|
configLabelKey = payload[key]
|
||||||
|
} else if (
|
||||||
|
payloadPayload &&
|
||||||
|
key in payloadPayload &&
|
||||||
|
typeof payloadPayload[key] === "string"
|
||||||
|
) {
|
||||||
|
configLabelKey = payloadPayload[key]
|
||||||
|
}
|
||||||
|
|
||||||
|
return configLabelKey in config ? config[configLabelKey] : config[key]
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
ChartContainer,
|
||||||
|
ChartTooltip,
|
||||||
|
ChartTooltipContent,
|
||||||
|
ChartLegend,
|
||||||
|
ChartLegendContent,
|
||||||
|
ChartStyle,
|
||||||
|
}
|
||||||
31
frontend/src/components/ui/checkbox.jsx
Normal file
31
frontend/src/components/ui/checkbox.jsx
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { Checkbox as CheckboxPrimitive } from "@base-ui/react/checkbox"
|
||||||
|
import { cn } from "cn"
|
||||||
|
import { CheckIcon } from "lucide-react"
|
||||||
|
|
||||||
|
function Checkbox({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<CheckboxPrimitive.Root
|
||||||
|
data-slot="checkbox"
|
||||||
|
className={cn(
|
||||||
|
"peer relative flex size-4 shrink-0 items-center justify-center rounded-[4px] border border-input transition-colors outline-none group-has-disabled/field:opacity-50 group-has-[:focus-visible]/field-label:ring-0 group-has-[:focus-visible]/field-label:not-data-checked:border-input after:absolute after:-inset-x-3 after:-inset-y-2 focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 aria-invalid:aria-checked:border-primary dark:bg-input/30 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 data-checked:border-primary data-checked:bg-primary data-checked:text-primary-foreground group-has-[:focus-visible]/field-label:data-checked:border-primary dark:data-checked:bg-primary",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<CheckboxPrimitive.Indicator
|
||||||
|
data-slot="checkbox-indicator"
|
||||||
|
className="grid place-content-center text-current transition-none [&>svg]:size-3.5"
|
||||||
|
>
|
||||||
|
<CheckIcon
|
||||||
|
/>
|
||||||
|
</CheckboxPrimitive.Indicator>
|
||||||
|
</CheckboxPrimitive.Root>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Checkbox }
|
||||||
25
frontend/src/components/ui/collapsible.jsx
Normal file
25
frontend/src/components/ui/collapsible.jsx
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
import { Collapsible as CollapsiblePrimitive } from "@base-ui/react/collapsible"
|
||||||
|
|
||||||
|
function Collapsible({
|
||||||
|
...props
|
||||||
|
}) {
|
||||||
|
return <CollapsiblePrimitive.Root data-slot="collapsible" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function CollapsibleTrigger({
|
||||||
|
...props
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<CollapsiblePrimitive.Trigger data-slot="collapsible-trigger" {...props} />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CollapsibleContent({
|
||||||
|
...props
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<CollapsiblePrimitive.Panel data-slot="collapsible-content" {...props} />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Collapsible, CollapsibleTrigger, CollapsibleContent }
|
||||||
301
frontend/src/components/ui/combobox.jsx
Normal file
301
frontend/src/components/ui/combobox.jsx
Normal file
@ -0,0 +1,301 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
import { Combobox as ComboboxPrimitive } from "@base-ui/react"
|
||||||
|
import { cn } from "cn"
|
||||||
|
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import {
|
||||||
|
InputGroup,
|
||||||
|
InputGroupAddon,
|
||||||
|
InputGroupButton,
|
||||||
|
InputGroupInput,
|
||||||
|
} from "@/components/ui/input-group"
|
||||||
|
import { ChevronDownIcon, XIcon, CheckIcon } from "lucide-react"
|
||||||
|
|
||||||
|
const Combobox = ComboboxPrimitive.Root
|
||||||
|
|
||||||
|
function ComboboxValue({
|
||||||
|
...props
|
||||||
|
}) {
|
||||||
|
return <ComboboxPrimitive.Value data-slot="combobox-value" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function ComboboxTrigger({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<ComboboxPrimitive.Trigger
|
||||||
|
data-slot="combobox-trigger"
|
||||||
|
className={cn("[&_svg:not([class*='size-'])]:size-4", className)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
<ChevronDownIcon className="pointer-events-none size-4 text-muted-foreground" />
|
||||||
|
</ComboboxPrimitive.Trigger>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ComboboxClear({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<ComboboxPrimitive.Clear
|
||||||
|
data-slot="combobox-clear"
|
||||||
|
render={<InputGroupButton variant="ghost" size="icon-xs" />}
|
||||||
|
className={cn(className)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<XIcon className="pointer-events-none" />
|
||||||
|
</ComboboxPrimitive.Clear>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ComboboxInput({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
disabled = false,
|
||||||
|
showTrigger = true,
|
||||||
|
showClear = false,
|
||||||
|
...props
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<InputGroup className={cn("w-auto", className)}>
|
||||||
|
<ComboboxPrimitive.Input
|
||||||
|
render={<InputGroupInput disabled={disabled} />}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
<InputGroupAddon align="inline-end">
|
||||||
|
{showTrigger && (
|
||||||
|
<InputGroupButton
|
||||||
|
size="icon-xs"
|
||||||
|
variant="ghost"
|
||||||
|
render={<ComboboxTrigger />}
|
||||||
|
data-slot="input-group-button"
|
||||||
|
className="group-has-data-[slot=combobox-clear]/input-group:hidden data-pressed:bg-transparent"
|
||||||
|
disabled={disabled}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{showClear && <ComboboxClear disabled={disabled} />}
|
||||||
|
</InputGroupAddon>
|
||||||
|
{children}
|
||||||
|
</InputGroup>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ComboboxContent({
|
||||||
|
className,
|
||||||
|
side = "bottom",
|
||||||
|
sideOffset = 6,
|
||||||
|
align = "start",
|
||||||
|
alignOffset = 0,
|
||||||
|
anchor,
|
||||||
|
...props
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<ComboboxPrimitive.Portal>
|
||||||
|
<ComboboxPrimitive.Positioner
|
||||||
|
side={side}
|
||||||
|
sideOffset={sideOffset}
|
||||||
|
align={align}
|
||||||
|
alignOffset={alignOffset}
|
||||||
|
anchor={anchor}
|
||||||
|
className="isolate z-50"
|
||||||
|
>
|
||||||
|
<ComboboxPrimitive.Popup
|
||||||
|
data-slot="combobox-content"
|
||||||
|
data-chips={!!anchor}
|
||||||
|
className={cn("group/combobox-content relative max-h-(--available-height) w-(--anchor-width) max-w-(--available-width) min-w-[calc(var(--anchor-width)+--spacing(7))] origin-(--transform-origin) overflow-hidden rounded-lg bg-popover text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 data-[chips=true]:min-w-(--anchor-width) data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-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 *:data-[slot=input-group]:m-1 *:data-[slot=input-group]:mb-0 *:data-[slot=input-group]:h-8 *:data-[slot=input-group]:border-input/30 *:data-[slot=input-group]:bg-input/30 *:data-[slot=input-group]:shadow-none data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95", className )}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</ComboboxPrimitive.Positioner>
|
||||||
|
</ComboboxPrimitive.Portal>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ComboboxList({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<ComboboxPrimitive.List
|
||||||
|
data-slot="combobox-list"
|
||||||
|
className={cn(
|
||||||
|
"no-scrollbar max-h-[min(calc(--spacing(72)---spacing(9)),calc(var(--available-height)---spacing(9)))] scroll-py-1 overflow-y-auto overscroll-contain p-1 data-empty:p-0",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ComboboxItem({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<ComboboxPrimitive.Item
|
||||||
|
data-slot="combobox-item"
|
||||||
|
className={cn(
|
||||||
|
"relative flex w-full cursor-default items-center gap-2 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none data-highlighted:bg-accent data-highlighted:text-accent-foreground not-data-[variant=destructive]:data-highlighted:**:text-accent-foreground data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
<ComboboxPrimitive.ItemIndicator
|
||||||
|
render={
|
||||||
|
<span className="pointer-events-none absolute right-2 flex size-4 items-center justify-center" />
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<CheckIcon className="pointer-events-none" />
|
||||||
|
</ComboboxPrimitive.ItemIndicator>
|
||||||
|
</ComboboxPrimitive.Item>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ComboboxGroup({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<ComboboxPrimitive.Group
|
||||||
|
data-slot="combobox-group"
|
||||||
|
className={cn(className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ComboboxLabel({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<ComboboxPrimitive.GroupLabel
|
||||||
|
data-slot="combobox-label"
|
||||||
|
className={cn("px-2 py-1.5 text-xs text-muted-foreground", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ComboboxCollection({
|
||||||
|
...props
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<ComboboxPrimitive.Collection data-slot="combobox-collection" {...props} />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ComboboxEmpty({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<ComboboxPrimitive.Empty
|
||||||
|
data-slot="combobox-empty"
|
||||||
|
className={cn(
|
||||||
|
"hidden w-full justify-center py-2 text-center text-sm text-muted-foreground group-data-empty/combobox-content:flex",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ComboboxSeparator({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<ComboboxPrimitive.Separator
|
||||||
|
data-slot="combobox-separator"
|
||||||
|
className={cn("-mx-1 my-1 h-px bg-border", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ComboboxChips({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<ComboboxPrimitive.Chips
|
||||||
|
data-slot="combobox-chips"
|
||||||
|
className={cn(
|
||||||
|
"flex min-h-8 flex-wrap items-center gap-1 rounded-lg border border-input bg-transparent bg-clip-padding px-2.5 py-1 text-sm transition-colors focus-within:border-ring focus-within:ring-3 focus-within:ring-ring/50 has-aria-invalid:border-destructive has-aria-invalid:ring-3 has-aria-invalid:ring-destructive/20 has-data-[slot=combobox-chip]:px-1 dark:bg-input/30 dark:has-aria-invalid:border-destructive/50 dark:has-aria-invalid:ring-destructive/40",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ComboboxChip({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
showRemove = true,
|
||||||
|
...props
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<ComboboxPrimitive.Chip
|
||||||
|
data-slot="combobox-chip"
|
||||||
|
className={cn(
|
||||||
|
"flex h-[calc(--spacing(5.25))] w-fit items-center justify-center gap-1 rounded-sm bg-muted px-1.5 text-xs font-medium whitespace-nowrap text-foreground has-disabled:pointer-events-none has-disabled:cursor-not-allowed has-disabled:opacity-50 has-data-[slot=combobox-chip-remove]:pr-0",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
{showRemove && (
|
||||||
|
<ComboboxPrimitive.ChipRemove
|
||||||
|
render={<Button variant="ghost" size="icon-xs" />}
|
||||||
|
className="-ml-1 opacity-50 hover:opacity-100"
|
||||||
|
data-slot="combobox-chip-remove"
|
||||||
|
>
|
||||||
|
<XIcon className="pointer-events-none" />
|
||||||
|
</ComboboxPrimitive.ChipRemove>
|
||||||
|
)}
|
||||||
|
</ComboboxPrimitive.Chip>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ComboboxChipsInput({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<ComboboxPrimitive.Input
|
||||||
|
data-slot="combobox-chip-input"
|
||||||
|
className={cn("min-w-16 flex-1 outline-none", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function useComboboxAnchor() {
|
||||||
|
return React.useRef(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
Combobox,
|
||||||
|
ComboboxInput,
|
||||||
|
ComboboxContent,
|
||||||
|
ComboboxList,
|
||||||
|
ComboboxItem,
|
||||||
|
ComboboxGroup,
|
||||||
|
ComboboxLabel,
|
||||||
|
ComboboxCollection,
|
||||||
|
ComboboxEmpty,
|
||||||
|
ComboboxSeparator,
|
||||||
|
ComboboxChips,
|
||||||
|
ComboboxChip,
|
||||||
|
ComboboxChipsInput,
|
||||||
|
ComboboxTrigger,
|
||||||
|
ComboboxValue,
|
||||||
|
useComboboxAnchor,
|
||||||
|
}
|
||||||
190
frontend/src/components/ui/command.jsx
Normal file
190
frontend/src/components/ui/command.jsx
Normal file
@ -0,0 +1,190 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import { Command as CommandPrimitive } from "cmdk"
|
||||||
|
import { cn } from "cn"
|
||||||
|
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "@/components/ui/dialog"
|
||||||
|
import {
|
||||||
|
InputGroup,
|
||||||
|
InputGroupAddon,
|
||||||
|
} from "@/components/ui/input-group"
|
||||||
|
import { SearchIcon, CheckIcon } from "lucide-react"
|
||||||
|
|
||||||
|
function Command({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<CommandPrimitive
|
||||||
|
data-slot="command"
|
||||||
|
className={cn(
|
||||||
|
"flex size-full flex-col overflow-hidden rounded-xl! bg-popover p-1 text-popover-foreground",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CommandDialog({
|
||||||
|
title = "Command Palette",
|
||||||
|
description = "Search for a command to run...",
|
||||||
|
children,
|
||||||
|
className,
|
||||||
|
showCloseButton = false,
|
||||||
|
...props
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<Dialog {...props}>
|
||||||
|
<DialogHeader className="sr-only">
|
||||||
|
<DialogTitle>{title}</DialogTitle>
|
||||||
|
<DialogDescription>{description}</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<DialogContent
|
||||||
|
className={cn(
|
||||||
|
"top-1/3 translate-y-0 overflow-hidden rounded-xl! p-0",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
showCloseButton={showCloseButton}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CommandInput({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div data-slot="command-input-wrapper" className="p-1 pb-0">
|
||||||
|
<InputGroup className="h-8! rounded-lg! border-input/30 bg-input/30 shadow-none! *:data-[slot=input-group-addon]:pl-2!">
|
||||||
|
<CommandPrimitive.Input
|
||||||
|
data-slot="command-input"
|
||||||
|
className={cn(
|
||||||
|
"w-full text-sm outline-hidden disabled:cursor-not-allowed disabled:opacity-50",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
<InputGroupAddon>
|
||||||
|
<SearchIcon className="size-4 shrink-0 opacity-50" />
|
||||||
|
</InputGroupAddon>
|
||||||
|
</InputGroup>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CommandList({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<CommandPrimitive.List
|
||||||
|
data-slot="command-list"
|
||||||
|
className={cn(
|
||||||
|
"no-scrollbar max-h-72 scroll-py-1 overflow-x-hidden overflow-y-auto outline-none",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CommandEmpty({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<CommandPrimitive.Empty
|
||||||
|
data-slot="command-empty"
|
||||||
|
className={cn("py-6 text-center text-sm", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CommandGroup({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<CommandPrimitive.Group
|
||||||
|
data-slot="command-group"
|
||||||
|
className={cn(
|
||||||
|
"overflow-hidden p-1 text-foreground **:[[cmdk-group-heading]]:px-2 **:[[cmdk-group-heading]]:py-1.5 **:[[cmdk-group-heading]]:text-xs **:[[cmdk-group-heading]]:font-medium **:[[cmdk-group-heading]]:text-muted-foreground",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CommandSeparator({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<CommandPrimitive.Separator
|
||||||
|
data-slot="command-separator"
|
||||||
|
className={cn("-mx-1 h-px bg-border", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CommandItem({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<CommandPrimitive.Item
|
||||||
|
data-slot="command-item"
|
||||||
|
className={cn(
|
||||||
|
"group/command-item relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none in-data-[slot=dialog-content]:rounded-lg! data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 data-selected:bg-muted data-selected:text-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 data-selected:*:[svg]:text-foreground",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
<CheckIcon className="ml-auto opacity-0 group-has-data-[slot=command-shortcut]/command-item:hidden group-data-[checked=true]/command-item:opacity-100" />
|
||||||
|
</CommandPrimitive.Item>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CommandShortcut({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
data-slot="command-shortcut"
|
||||||
|
className={cn(
|
||||||
|
"ml-auto text-xs tracking-widest text-muted-foreground group-data-selected/command-item:text-foreground",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
Command,
|
||||||
|
CommandDialog,
|
||||||
|
CommandInput,
|
||||||
|
CommandList,
|
||||||
|
CommandEmpty,
|
||||||
|
CommandGroup,
|
||||||
|
CommandItem,
|
||||||
|
CommandShortcut,
|
||||||
|
CommandSeparator,
|
||||||
|
}
|
||||||
263
frontend/src/components/ui/context-menu.jsx
Normal file
263
frontend/src/components/ui/context-menu.jsx
Normal file
@ -0,0 +1,263 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import { ContextMenu as ContextMenuPrimitive } from "@base-ui/react/context-menu"
|
||||||
|
import { cn } from "cn"
|
||||||
|
import { ChevronRightIcon, CheckIcon } from "lucide-react"
|
||||||
|
|
||||||
|
function ContextMenu({
|
||||||
|
...props
|
||||||
|
}) {
|
||||||
|
return <ContextMenuPrimitive.Root data-slot="context-menu" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function ContextMenuPortal({
|
||||||
|
...props
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<ContextMenuPrimitive.Portal data-slot="context-menu-portal" {...props} />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ContextMenuTrigger({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<ContextMenuPrimitive.Trigger
|
||||||
|
data-slot="context-menu-trigger"
|
||||||
|
className={cn("select-none", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ContextMenuContent({
|
||||||
|
className,
|
||||||
|
align = "start",
|
||||||
|
alignOffset = 4,
|
||||||
|
side = "right",
|
||||||
|
sideOffset = 0,
|
||||||
|
...props
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<ContextMenuPrimitive.Portal>
|
||||||
|
<ContextMenuPrimitive.Positioner
|
||||||
|
className="isolate z-50 outline-none"
|
||||||
|
align={align}
|
||||||
|
alignOffset={alignOffset}
|
||||||
|
side={side}
|
||||||
|
sideOffset={sideOffset}
|
||||||
|
>
|
||||||
|
<ContextMenuPrimitive.Popup
|
||||||
|
data-slot="context-menu-content"
|
||||||
|
className={cn("z-50 max-h-(--available-height) min-w-36 origin-(--transform-origin) overflow-x-hidden overflow-y-auto rounded-lg bg-popover p-1 text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 outline-none data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-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 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95", className )}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</ContextMenuPrimitive.Positioner>
|
||||||
|
</ContextMenuPrimitive.Portal>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ContextMenuGroup({
|
||||||
|
...props
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<ContextMenuPrimitive.Group data-slot="context-menu-group" {...props} />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ContextMenuLabel({
|
||||||
|
className,
|
||||||
|
inset,
|
||||||
|
...props
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<ContextMenuPrimitive.GroupLabel
|
||||||
|
data-slot="context-menu-label"
|
||||||
|
data-inset={inset}
|
||||||
|
className={cn(
|
||||||
|
"px-1.5 py-1 text-xs font-medium text-muted-foreground data-inset:pl-7",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ContextMenuItem({
|
||||||
|
className,
|
||||||
|
inset,
|
||||||
|
variant = "default",
|
||||||
|
...props
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<ContextMenuPrimitive.Item
|
||||||
|
data-slot="context-menu-item"
|
||||||
|
data-inset={inset}
|
||||||
|
data-variant={variant}
|
||||||
|
className={cn(
|
||||||
|
"group/context-menu-item relative flex cursor-default items-center gap-1.5 rounded-md px-1.5 py-1 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-inset:pl-7 data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 data-[variant=destructive]:focus:text-destructive dark:data-[variant=destructive]:focus:bg-destructive/20 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 focus:*:[svg]:text-accent-foreground data-[variant=destructive]:*:[svg]:text-destructive",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ContextMenuSub({
|
||||||
|
...props
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<ContextMenuPrimitive.SubmenuRoot data-slot="context-menu-sub" {...props} />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ContextMenuSubTrigger({
|
||||||
|
className,
|
||||||
|
inset,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<ContextMenuPrimitive.SubmenuTrigger
|
||||||
|
data-slot="context-menu-sub-trigger"
|
||||||
|
data-inset={inset}
|
||||||
|
className={cn(
|
||||||
|
"flex cursor-default items-center gap-1.5 rounded-md px-1.5 py-1 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-inset:pl-7 data-open:bg-accent data-open:text-accent-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
<ChevronRightIcon className="ml-auto" />
|
||||||
|
</ContextMenuPrimitive.SubmenuTrigger>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ContextMenuSubContent({
|
||||||
|
...props
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<ContextMenuContent
|
||||||
|
data-slot="context-menu-sub-content"
|
||||||
|
className="shadow-lg"
|
||||||
|
side="right"
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ContextMenuCheckboxItem({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
checked,
|
||||||
|
inset,
|
||||||
|
...props
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<ContextMenuPrimitive.CheckboxItem
|
||||||
|
data-slot="context-menu-checkbox-item"
|
||||||
|
data-inset={inset}
|
||||||
|
className={cn(
|
||||||
|
"relative flex cursor-default items-center gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-inset:pl-7 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
checked={checked}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<span className="pointer-events-none absolute right-2">
|
||||||
|
<ContextMenuPrimitive.CheckboxItemIndicator>
|
||||||
|
<CheckIcon
|
||||||
|
/>
|
||||||
|
</ContextMenuPrimitive.CheckboxItemIndicator>
|
||||||
|
</span>
|
||||||
|
{children}
|
||||||
|
</ContextMenuPrimitive.CheckboxItem>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ContextMenuRadioGroup({
|
||||||
|
...props
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<ContextMenuPrimitive.RadioGroup
|
||||||
|
data-slot="context-menu-radio-group"
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ContextMenuRadioItem({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
inset,
|
||||||
|
...props
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<ContextMenuPrimitive.RadioItem
|
||||||
|
data-slot="context-menu-radio-item"
|
||||||
|
data-inset={inset}
|
||||||
|
className={cn(
|
||||||
|
"relative flex cursor-default items-center gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-inset:pl-7 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<span className="pointer-events-none absolute right-2">
|
||||||
|
<ContextMenuPrimitive.RadioItemIndicator>
|
||||||
|
<CheckIcon
|
||||||
|
/>
|
||||||
|
</ContextMenuPrimitive.RadioItemIndicator>
|
||||||
|
</span>
|
||||||
|
{children}
|
||||||
|
</ContextMenuPrimitive.RadioItem>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ContextMenuSeparator({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<ContextMenuPrimitive.Separator
|
||||||
|
data-slot="context-menu-separator"
|
||||||
|
className={cn("-mx-1 my-1 h-px bg-border", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ContextMenuShortcut({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
data-slot="context-menu-shortcut"
|
||||||
|
className={cn(
|
||||||
|
"ml-auto text-xs tracking-widest text-muted-foreground group-focus/context-menu-item:text-accent-foreground",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
ContextMenu,
|
||||||
|
ContextMenuTrigger,
|
||||||
|
ContextMenuContent,
|
||||||
|
ContextMenuItem,
|
||||||
|
ContextMenuCheckboxItem,
|
||||||
|
ContextMenuRadioItem,
|
||||||
|
ContextMenuLabel,
|
||||||
|
ContextMenuSeparator,
|
||||||
|
ContextMenuShortcut,
|
||||||
|
ContextMenuGroup,
|
||||||
|
ContextMenuPortal,
|
||||||
|
ContextMenuSub,
|
||||||
|
ContextMenuSubContent,
|
||||||
|
ContextMenuSubTrigger,
|
||||||
|
ContextMenuRadioGroup,
|
||||||
|
}
|
||||||
170
frontend/src/components/ui/dialog.jsx
Normal file
170
frontend/src/components/ui/dialog.jsx
Normal file
@ -0,0 +1,170 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import { Dialog as DialogPrimitive } from "@base-ui/react/dialog"
|
||||||
|
import { cn } from "cn"
|
||||||
|
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import { XIcon } from "lucide-react"
|
||||||
|
|
||||||
|
function Dialog({
|
||||||
|
...props
|
||||||
|
}) {
|
||||||
|
return <DialogPrimitive.Root data-slot="dialog" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogTrigger({
|
||||||
|
...props
|
||||||
|
}) {
|
||||||
|
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogPortal({
|
||||||
|
...props
|
||||||
|
}) {
|
||||||
|
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogClose({
|
||||||
|
...props
|
||||||
|
}) {
|
||||||
|
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogOverlay({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<DialogPrimitive.Backdrop
|
||||||
|
data-slot="dialog-overlay"
|
||||||
|
className={cn(
|
||||||
|
"fixed inset-0 isolate z-50 bg-black/10 duration-100 supports-backdrop-filter:backdrop-blur-xs data-open:animate-in data-open:fade-in-0 data-closed:animate-out data-closed:fade-out-0",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogContent({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
showCloseButton = true,
|
||||||
|
...props
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<DialogPortal>
|
||||||
|
<DialogOverlay />
|
||||||
|
<DialogPrimitive.Popup
|
||||||
|
data-slot="dialog-content"
|
||||||
|
className={cn(
|
||||||
|
"fixed top-1/2 left-1/2 z-50 grid w-full max-w-[calc(100%-2rem)] -translate-x-1/2 -translate-y-1/2 gap-4 rounded-xl bg-popover p-4 text-sm text-popover-foreground ring-1 ring-foreground/10 duration-100 outline-none sm:max-w-sm data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
{showCloseButton && (
|
||||||
|
<DialogPrimitive.Close
|
||||||
|
data-slot="dialog-close"
|
||||||
|
render={
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
className="absolute top-2 right-2"
|
||||||
|
size="icon-sm"
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<XIcon
|
||||||
|
/>
|
||||||
|
<span className="sr-only">Close</span>
|
||||||
|
</DialogPrimitive.Close>
|
||||||
|
)}
|
||||||
|
</DialogPrimitive.Popup>
|
||||||
|
</DialogPortal>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogHeader({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="dialog-header"
|
||||||
|
className={cn("flex flex-col gap-2", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogFooter({
|
||||||
|
className,
|
||||||
|
showCloseButton = false,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="dialog-footer"
|
||||||
|
className={cn(
|
||||||
|
"-mx-4 -mb-4 flex flex-col-reverse gap-2 rounded-b-xl border-t bg-muted/50 p-4 sm:flex-row sm:justify-end",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
{showCloseButton && (
|
||||||
|
<DialogPrimitive.Close render={<Button variant="outline" />}>
|
||||||
|
Close
|
||||||
|
</DialogPrimitive.Close>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogTitle({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<DialogPrimitive.Title
|
||||||
|
data-slot="dialog-title"
|
||||||
|
className={cn(
|
||||||
|
"font-heading text-base leading-none font-medium",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogDescription({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<DialogPrimitive.Description
|
||||||
|
data-slot="dialog-description"
|
||||||
|
className={cn(
|
||||||
|
"text-sm text-muted-foreground *:[a]:underline *:[a]:underline-offset-3 *:[a]:hover:text-foreground",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
Dialog,
|
||||||
|
DialogClose,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogOverlay,
|
||||||
|
DialogPortal,
|
||||||
|
DialogTitle,
|
||||||
|
DialogTrigger,
|
||||||
|
}
|
||||||
4
frontend/src/components/ui/direction.jsx
Normal file
4
frontend/src/components/ui/direction.jsx
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
export {
|
||||||
|
DirectionProvider,
|
||||||
|
useDirection,
|
||||||
|
} from "@base-ui/react/direction-provider"
|
||||||
232
frontend/src/components/ui/drawer.jsx
Normal file
232
frontend/src/components/ui/drawer.jsx
Normal file
@ -0,0 +1,232 @@
|
|||||||
|
"use client";
|
||||||
|
import * as React from "react"
|
||||||
|
import { Drawer as DrawerPrimitive } from "@base-ui/react/drawer"
|
||||||
|
import { cn } from "cn"
|
||||||
|
|
||||||
|
const DrawerContext = React.createContext(null)
|
||||||
|
|
||||||
|
function useDrawer() {
|
||||||
|
const context = React.useContext(DrawerContext)
|
||||||
|
|
||||||
|
if (!context) {
|
||||||
|
throw new Error("useDrawer must be used within a Drawer.")
|
||||||
|
}
|
||||||
|
|
||||||
|
return context
|
||||||
|
}
|
||||||
|
|
||||||
|
function Drawer({
|
||||||
|
modal = true,
|
||||||
|
showSwipeHandle = false,
|
||||||
|
snapPoints,
|
||||||
|
swipeDirection = "down",
|
||||||
|
...props
|
||||||
|
}) {
|
||||||
|
const hasSnapPoints = snapPoints != null && snapPoints.length > 0
|
||||||
|
const contextValue = React.useMemo(
|
||||||
|
() => ({ hasSnapPoints, modal, showSwipeHandle, swipeDirection }),
|
||||||
|
[hasSnapPoints, modal, showSwipeHandle, swipeDirection]
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DrawerContext.Provider value={contextValue}>
|
||||||
|
<DrawerPrimitive.Root
|
||||||
|
data-slot="drawer"
|
||||||
|
modal={modal}
|
||||||
|
snapPoints={snapPoints}
|
||||||
|
swipeDirection={swipeDirection}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</DrawerContext.Provider>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DrawerTrigger({
|
||||||
|
...props
|
||||||
|
}) {
|
||||||
|
return <DrawerPrimitive.Trigger data-slot="drawer-trigger" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function DrawerPortal({
|
||||||
|
...props
|
||||||
|
}) {
|
||||||
|
return <DrawerPrimitive.Portal data-slot="drawer-portal" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function DrawerClose({
|
||||||
|
...props
|
||||||
|
}) {
|
||||||
|
return <DrawerPrimitive.Close data-slot="drawer-close" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function DrawerOverlay({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<DrawerPrimitive.Backdrop
|
||||||
|
data-slot="drawer-overlay"
|
||||||
|
className={cn(
|
||||||
|
"fixed inset-0 z-50 min-h-dvh bg-black/10 opacity-[max(var(--drawer-overlay-min-opacity,0),calc(1-var(--drawer-swipe-progress)))] transition-opacity duration-450 ease-[cubic-bezier(0.32,0.72,0,1)] select-none data-ending-style:pointer-events-none data-ending-style:opacity-0 data-ending-style:duration-[calc(var(--drawer-swipe-strength)*400ms)] data-snap-points:[--drawer-overlay-min-opacity:0.5] data-starting-style:opacity-0 data-swiping:duration-0 supports-backdrop-filter:backdrop-blur-xs supports-[-webkit-touch-callout:none]:absolute",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DrawerSwipeHandle({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="drawer-swipe-handle"
|
||||||
|
aria-hidden="true"
|
||||||
|
className={cn(
|
||||||
|
"relative z-10 flex shrink-0 cursor-grab transition-opacity duration-200 group-data-nested-drawer-open/drawer-popup:opacity-0 group-data-nested-drawer-swiping/drawer-popup:opacity-100 group-data-[swipe-axis=x]/drawer-popup:h-full group-data-[swipe-axis=x]/drawer-popup:w-3 group-data-[swipe-axis=x]/drawer-popup:items-center group-data-[swipe-axis=y]/drawer-popup:h-3 group-data-[swipe-axis=y]/drawer-popup:w-full group-data-[swipe-axis=y]/drawer-popup:justify-center group-data-[swipe-direction=down]/drawer-popup:items-end group-data-[swipe-direction=left]/drawer-popup:order-last group-data-[swipe-direction=left]/drawer-popup:justify-start group-data-[swipe-direction=right]/drawer-popup:justify-end group-data-[swipe-direction=up]/drawer-popup:order-last group-data-[swipe-direction=up]/drawer-popup:items-start after:block after:shrink-0 after:rounded-full after:bg-muted group-data-[swipe-axis=x]/drawer-popup:after:h-24 group-data-[swipe-axis=x]/drawer-popup:after:w-1 group-data-[swipe-axis=y]/drawer-popup:after:h-1 group-data-[swipe-axis=y]/drawer-popup:after:w-24 active:cursor-grabbing",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DrawerContent({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}) {
|
||||||
|
const { hasSnapPoints, modal, showSwipeHandle, swipeDirection } = useDrawer()
|
||||||
|
const swipeAxis =
|
||||||
|
swipeDirection === "down" || swipeDirection === "up" ? "y" : "x"
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DrawerPortal data-slot="drawer-portal">
|
||||||
|
{modal === true && (
|
||||||
|
<DrawerOverlay data-snap-points={hasSnapPoints ? "" : undefined} />
|
||||||
|
)}
|
||||||
|
<DrawerPrimitive.Viewport
|
||||||
|
data-slot="drawer-viewport"
|
||||||
|
data-modal={modal}
|
||||||
|
className="pointer-events-none fixed inset-0 z-50 select-none data-[modal=true]:pointer-events-auto"
|
||||||
|
>
|
||||||
|
<DrawerPrimitive.Popup
|
||||||
|
data-slot="drawer-popup"
|
||||||
|
data-swipe-axis={swipeAxis}
|
||||||
|
data-snap-points={hasSnapPoints ? "" : undefined}
|
||||||
|
className={cn(
|
||||||
|
// Base.
|
||||||
|
"group/drawer-popup pointer-events-auto fixed z-50 m-(--drawer-inset,0px) flex h-(--drawer-content-height) max-h-(--drawer-content-max-height,none) min-h-0 w-(--drawer-content-width,auto) transform-[translate3d(var(--translate-x,0px),var(--translate-y,0px),0)_scale(var(--stack-scale))] flex-col bg-popover text-sm text-popover-foreground transition-[transform,height,opacity,filter] duration-450 ease-[cubic-bezier(0.22,1,0.36,1)] will-change-transform outline-none select-none [interpolate-size:allow-keywords] data-[swipe-direction=down]:rounded-t-xl data-[swipe-direction=down]:border-t data-[swipe-direction=left]:rounded-r-xl data-[swipe-direction=left]:border-r data-[swipe-direction=right]:rounded-l-xl data-[swipe-direction=right]:border-l data-[swipe-direction=up]:rounded-b-xl data-[swipe-direction=up]:border-b",
|
||||||
|
// Nested.
|
||||||
|
"data-nested-drawer-open:overflow-hidden data-nested-drawer-open:brightness-95",
|
||||||
|
// Bleed.
|
||||||
|
"after:pointer-events-none after:absolute after:bg-(--drawer-bleed-background,var(--color-popover)) data-[swipe-axis=x]:after:inset-y-0 data-[swipe-axis=x]:after:w-(--bleed) data-[swipe-axis=y]:after:inset-x-0 data-[swipe-axis=y]:after:h-(--bleed) data-[swipe-direction=down]:after:top-full data-[swipe-direction=left]:after:right-full data-[swipe-direction=right]:after:left-full data-[swipe-direction=up]:after:bottom-full",
|
||||||
|
// Sizing.
|
||||||
|
"[--drawer-content-height:var(--drawer-height,auto)] data-[swipe-axis=x]:[--drawer-content-width:75%] data-[swipe-axis=y]:[--drawer-content-max-height:calc(100dvh-6rem)] data-[swipe-axis=y]:data-snap-points:[--drawer-content-height:100dvh] data-[swipe-axis=x]:sm:[--drawer-content-width:24rem]",
|
||||||
|
// Stack.
|
||||||
|
"[--bleed:3rem] [--peek:1rem] [--stack-height:var(--drawer-frontmost-height,var(--drawer-height,0px))] [--stack-peek-offset:max(0px,calc((var(--nested-drawers)-var(--stack-progress))*var(--peek)))] [--stack-progress:clamp(0,var(--drawer-swipe-progress),1)] [--stack-scale-base:max(0,calc(1-(var(--nested-drawers)*var(--stack-step))))] [--stack-scale:clamp(0,calc(var(--stack-scale-base)+(var(--stack-step)*var(--stack-progress))),1)] [--stack-shrink:calc(1-var(--stack-scale))] [--stack-step:0.05]",
|
||||||
|
// Transitions.
|
||||||
|
"data-ending-style:transform-(--closed-transform) data-ending-style:opacity-[0.9999] data-ending-style:duration-[calc(var(--drawer-swipe-strength)*400ms)] data-nested-drawer-swiping:duration-0 data-ending-style:data-nested-drawer-swiping:duration-[calc(var(--drawer-swipe-strength)*400ms)] data-starting-style:transform-(--closed-transform) data-swiping:duration-0 data-ending-style:data-swiping:duration-[calc(var(--drawer-swipe-strength)*400ms)]",
|
||||||
|
// Axis: y.
|
||||||
|
"data-[swipe-axis=y]:inset-x-0 data-[swipe-axis=y]:data-nested-drawer-open:h-(--stack-height)",
|
||||||
|
// Axis: x.
|
||||||
|
"data-[swipe-axis=x]:inset-y-0 data-[swipe-axis=x]:flex-row",
|
||||||
|
// Direction: down.
|
||||||
|
"data-[swipe-direction=down]:bottom-0 data-[swipe-direction=down]:origin-bottom data-[swipe-direction=down]:[--closed-transform:translate3d(0,calc(100%+var(--drawer-inset,0px)+2px),0)] data-[swipe-direction=down]:[--translate-y:calc(var(--drawer-snap-point-offset,0px)+var(--drawer-swipe-movement-y)-var(--stack-peek-offset)-(var(--stack-shrink)*var(--stack-height)))]",
|
||||||
|
// Direction: up.
|
||||||
|
"data-[swipe-direction=up]:top-0 data-[swipe-direction=up]:origin-top data-[swipe-direction=up]:[--closed-transform:translate3d(0,calc(-100%-var(--drawer-inset,0px)-2px),0)] data-[swipe-direction=up]:[--translate-y:calc(var(--drawer-snap-point-offset,0px)+var(--drawer-swipe-movement-y)+var(--stack-peek-offset)+(var(--stack-shrink)*var(--stack-height)))]",
|
||||||
|
// Direction: left.
|
||||||
|
"data-[swipe-direction=left]:left-0 data-[swipe-direction=left]:origin-left data-[swipe-direction=left]:[--closed-transform:translate3d(calc(-100%-var(--drawer-inset,0px)-2px),0,0)] data-[swipe-direction=left]:[--translate-x:calc(var(--drawer-swipe-movement-x)+var(--stack-peek-offset)+(var(--stack-shrink)*100%))]",
|
||||||
|
// Direction: right.
|
||||||
|
"data-[swipe-direction=right]:right-0 data-[swipe-direction=right]:origin-right data-[swipe-direction=right]:[--closed-transform:translate3d(calc(100%+var(--drawer-inset,0px)+2px),0,0)] data-[swipe-direction=right]:[--translate-x:calc(var(--drawer-swipe-movement-x)-var(--stack-peek-offset)-(var(--stack-shrink)*100%))]",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{showSwipeHandle && <DrawerSwipeHandle />}
|
||||||
|
<DrawerPrimitive.Content
|
||||||
|
data-slot="drawer-content"
|
||||||
|
className={cn(
|
||||||
|
"flex min-h-0 flex-1 flex-col overflow-hidden overscroll-contain rounded-[inherit] transition-opacity duration-300 ease-[cubic-bezier(0.45,1.005,0,1.005)] select-text group-data-nested-drawer-open/drawer-popup:opacity-0 group-data-nested-drawer-swiping/drawer-popup:opacity-100 group-data-swiping/drawer-popup:select-none"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</DrawerPrimitive.Content>
|
||||||
|
</DrawerPrimitive.Popup>
|
||||||
|
</DrawerPrimitive.Viewport>
|
||||||
|
</DrawerPortal>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DrawerHeader({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="drawer-header"
|
||||||
|
className={cn(
|
||||||
|
"flex shrink-0 flex-col gap-0.5 p-4 pb-0 group-data-[swipe-axis=y]/drawer-popup:text-center md:gap-0.5 md:text-left",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DrawerFooter({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="drawer-footer"
|
||||||
|
className={cn("mt-auto flex shrink-0 flex-col gap-2 p-4 pt-0", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DrawerTitle({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<DrawerPrimitive.Title
|
||||||
|
data-slot="drawer-title"
|
||||||
|
className={cn(
|
||||||
|
"font-heading text-base font-medium text-foreground",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DrawerDescription({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<DrawerPrimitive.Description
|
||||||
|
data-slot="drawer-description"
|
||||||
|
className={cn("text-sm text-balance text-muted-foreground", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
Drawer,
|
||||||
|
DrawerPortal,
|
||||||
|
DrawerOverlay,
|
||||||
|
DrawerSwipeHandle,
|
||||||
|
DrawerTrigger,
|
||||||
|
DrawerClose,
|
||||||
|
DrawerContent,
|
||||||
|
DrawerHeader,
|
||||||
|
DrawerFooter,
|
||||||
|
DrawerTitle,
|
||||||
|
DrawerDescription,
|
||||||
|
}
|
||||||
262
frontend/src/components/ui/dropdown-menu.jsx
Normal file
262
frontend/src/components/ui/dropdown-menu.jsx
Normal file
@ -0,0 +1,262 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
import { Menu as MenuPrimitive } from "@base-ui/react/menu"
|
||||||
|
import { cn } from "cn"
|
||||||
|
import { ChevronRightIcon, CheckIcon } from "lucide-react"
|
||||||
|
|
||||||
|
function DropdownMenu({
|
||||||
|
...props
|
||||||
|
}) {
|
||||||
|
return <MenuPrimitive.Root data-slot="dropdown-menu" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuPortal({
|
||||||
|
...props
|
||||||
|
}) {
|
||||||
|
return <MenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuTrigger({
|
||||||
|
...props
|
||||||
|
}) {
|
||||||
|
return <MenuPrimitive.Trigger data-slot="dropdown-menu-trigger" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuContent({
|
||||||
|
align = "start",
|
||||||
|
alignOffset = 0,
|
||||||
|
side = "bottom",
|
||||||
|
sideOffset = 4,
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<MenuPrimitive.Portal>
|
||||||
|
<MenuPrimitive.Positioner
|
||||||
|
className="isolate z-50 outline-none"
|
||||||
|
align={align}
|
||||||
|
alignOffset={alignOffset}
|
||||||
|
side={side}
|
||||||
|
sideOffset={sideOffset}
|
||||||
|
>
|
||||||
|
<MenuPrimitive.Popup
|
||||||
|
data-slot="dropdown-menu-content"
|
||||||
|
className={cn("z-50 max-h-(--available-height) w-(--anchor-width) min-w-32 origin-(--transform-origin) overflow-x-hidden overflow-y-auto rounded-lg bg-popover p-1 text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 outline-none data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-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 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:overflow-hidden data-closed:fade-out-0 data-closed:zoom-out-95", className )}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</MenuPrimitive.Positioner>
|
||||||
|
</MenuPrimitive.Portal>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuGroup({
|
||||||
|
...props
|
||||||
|
}) {
|
||||||
|
return <MenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuLabel({
|
||||||
|
className,
|
||||||
|
inset,
|
||||||
|
...props
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<MenuPrimitive.GroupLabel
|
||||||
|
data-slot="dropdown-menu-label"
|
||||||
|
data-inset={inset}
|
||||||
|
className={cn(
|
||||||
|
"px-1.5 py-1 text-xs font-medium text-muted-foreground data-inset:pl-7",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuItem({
|
||||||
|
className,
|
||||||
|
inset,
|
||||||
|
variant = "default",
|
||||||
|
...props
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<MenuPrimitive.Item
|
||||||
|
data-slot="dropdown-menu-item"
|
||||||
|
data-inset={inset}
|
||||||
|
data-variant={variant}
|
||||||
|
className={cn(
|
||||||
|
"group/dropdown-menu-item relative flex cursor-default items-center gap-1.5 rounded-md px-1.5 py-1 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-inset:pl-7 data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 data-[variant=destructive]:focus:text-destructive dark:data-[variant=destructive]:focus:bg-destructive/20 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 data-[variant=destructive]:*:[svg]:text-destructive",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuSub({
|
||||||
|
...props
|
||||||
|
}) {
|
||||||
|
return <MenuPrimitive.SubmenuRoot data-slot="dropdown-menu-sub" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuSubTrigger({
|
||||||
|
className,
|
||||||
|
inset,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<MenuPrimitive.SubmenuTrigger
|
||||||
|
data-slot="dropdown-menu-sub-trigger"
|
||||||
|
data-inset={inset}
|
||||||
|
className={cn(
|
||||||
|
"flex cursor-default items-center gap-1.5 rounded-md px-1.5 py-1 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-inset:pl-7 data-popup-open:bg-accent data-popup-open:text-accent-foreground data-open:bg-accent data-open:text-accent-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
<ChevronRightIcon className="ml-auto" />
|
||||||
|
</MenuPrimitive.SubmenuTrigger>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuSubContent({
|
||||||
|
align = "start",
|
||||||
|
alignOffset = -3,
|
||||||
|
side = "right",
|
||||||
|
sideOffset = 0,
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<DropdownMenuContent
|
||||||
|
data-slot="dropdown-menu-sub-content"
|
||||||
|
className={cn("w-auto min-w-[96px] rounded-lg bg-popover p-1 text-popover-foreground shadow-lg ring-1 ring-foreground/10 duration-100 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 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95", className )}
|
||||||
|
align={align}
|
||||||
|
alignOffset={alignOffset}
|
||||||
|
side={side}
|
||||||
|
sideOffset={sideOffset}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuCheckboxItem({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
checked,
|
||||||
|
inset,
|
||||||
|
...props
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<MenuPrimitive.CheckboxItem
|
||||||
|
data-slot="dropdown-menu-checkbox-item"
|
||||||
|
data-inset={inset}
|
||||||
|
className={cn(
|
||||||
|
"relative flex cursor-default items-center gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground focus:**:text-accent-foreground data-inset:pl-7 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
checked={checked}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className="pointer-events-none absolute right-2 flex items-center justify-center"
|
||||||
|
data-slot="dropdown-menu-checkbox-item-indicator"
|
||||||
|
>
|
||||||
|
<MenuPrimitive.CheckboxItemIndicator>
|
||||||
|
<CheckIcon
|
||||||
|
/>
|
||||||
|
</MenuPrimitive.CheckboxItemIndicator>
|
||||||
|
</span>
|
||||||
|
{children}
|
||||||
|
</MenuPrimitive.CheckboxItem>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuRadioGroup({
|
||||||
|
...props
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<MenuPrimitive.RadioGroup
|
||||||
|
data-slot="dropdown-menu-radio-group"
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuRadioItem({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
inset,
|
||||||
|
...props
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<MenuPrimitive.RadioItem
|
||||||
|
data-slot="dropdown-menu-radio-item"
|
||||||
|
data-inset={inset}
|
||||||
|
className={cn(
|
||||||
|
"relative flex cursor-default items-center gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground focus:**:text-accent-foreground data-inset:pl-7 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className="pointer-events-none absolute right-2 flex items-center justify-center"
|
||||||
|
data-slot="dropdown-menu-radio-item-indicator"
|
||||||
|
>
|
||||||
|
<MenuPrimitive.RadioItemIndicator>
|
||||||
|
<CheckIcon
|
||||||
|
/>
|
||||||
|
</MenuPrimitive.RadioItemIndicator>
|
||||||
|
</span>
|
||||||
|
{children}
|
||||||
|
</MenuPrimitive.RadioItem>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuSeparator({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<MenuPrimitive.Separator
|
||||||
|
data-slot="dropdown-menu-separator"
|
||||||
|
className={cn("-mx-1 my-1 h-px bg-border", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuShortcut({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
data-slot="dropdown-menu-shortcut"
|
||||||
|
className={cn(
|
||||||
|
"ml-auto text-xs tracking-widest text-muted-foreground group-focus/dropdown-menu-item:text-accent-foreground",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuPortal,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuGroup,
|
||||||
|
DropdownMenuLabel,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuCheckboxItem,
|
||||||
|
DropdownMenuRadioGroup,
|
||||||
|
DropdownMenuRadioItem,
|
||||||
|
DropdownMenuSeparator,
|
||||||
|
DropdownMenuShortcut,
|
||||||
|
DropdownMenuSub,
|
||||||
|
DropdownMenuSubTrigger,
|
||||||
|
DropdownMenuSubContent,
|
||||||
|
}
|
||||||
118
frontend/src/components/ui/empty.jsx
Normal file
118
frontend/src/components/ui/empty.jsx
Normal file
@ -0,0 +1,118 @@
|
|||||||
|
import { cva } from "class-variance-authority";
|
||||||
|
import { cn } from "cn"
|
||||||
|
|
||||||
|
function Empty({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="empty"
|
||||||
|
className={cn(
|
||||||
|
"flex w-full min-w-0 flex-1 flex-col items-center justify-center gap-4 rounded-xl border-dashed p-6 text-center text-balance",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function EmptyHeader({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="empty-header"
|
||||||
|
className={cn("flex max-w-sm flex-col items-center gap-2", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const emptyMediaVariants = cva(
|
||||||
|
"mb-2 flex shrink-0 items-center justify-center [&_svg]:pointer-events-none [&_svg]:shrink-0",
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default: "bg-transparent",
|
||||||
|
icon: "flex size-8 shrink-0 items-center justify-center rounded-lg bg-muted text-foreground [&_svg:not([class*='size-'])]:size-4",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: "default",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
function EmptyMedia({
|
||||||
|
className,
|
||||||
|
variant = "default",
|
||||||
|
...props
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="empty-icon"
|
||||||
|
data-variant={variant}
|
||||||
|
className={cn(emptyMediaVariants({ variant, className }))}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function EmptyTitle({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="empty-title"
|
||||||
|
className={cn(
|
||||||
|
"font-heading text-sm font-medium tracking-tight",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function EmptyDescription({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="empty-description"
|
||||||
|
className={cn(
|
||||||
|
"text-sm/relaxed text-muted-foreground [&>a]:underline [&>a]:underline-offset-4 [&>a:hover]:text-primary",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function EmptyContent({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="empty-content"
|
||||||
|
className={cn(
|
||||||
|
"flex w-full max-w-sm min-w-0 flex-col items-center gap-2.5 text-sm text-balance",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
Empty,
|
||||||
|
EmptyHeader,
|
||||||
|
EmptyTitle,
|
||||||
|
EmptyDescription,
|
||||||
|
EmptyContent,
|
||||||
|
EmptyMedia,
|
||||||
|
}
|
||||||
247
frontend/src/components/ui/field.jsx
Normal file
247
frontend/src/components/ui/field.jsx
Normal file
@ -0,0 +1,247 @@
|
|||||||
|
import { useMemo } from "react"
|
||||||
|
import { cva } from "class-variance-authority";
|
||||||
|
import { cn } from "cn"
|
||||||
|
|
||||||
|
import { Label } from "@/components/ui/label"
|
||||||
|
import { Separator } from "@/components/ui/separator"
|
||||||
|
|
||||||
|
function FieldSet({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<fieldset
|
||||||
|
data-slot="field-set"
|
||||||
|
className={cn(
|
||||||
|
"flex flex-col gap-4 has-[>[data-slot=checkbox-group]]:gap-3 has-[>[data-slot=radio-group]]:gap-3",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function FieldLegend({
|
||||||
|
className,
|
||||||
|
variant = "legend",
|
||||||
|
...props
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<legend
|
||||||
|
data-slot="field-legend"
|
||||||
|
data-variant={variant}
|
||||||
|
className={cn(
|
||||||
|
"mb-1.5 font-medium data-[variant=label]:text-sm data-[variant=legend]:text-base",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function FieldGroup({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="field-group"
|
||||||
|
className={cn(
|
||||||
|
"group/field-group @container/field-group flex w-full flex-col gap-5 data-[slot=checkbox-group]:gap-3 *:data-[slot=field-group]:gap-4",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const fieldVariants = cva(
|
||||||
|
"group/field flex w-full gap-2 data-[invalid=true]:text-destructive",
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
orientation: {
|
||||||
|
vertical: "flex-col *:w-full [&>.sr-only]:w-auto",
|
||||||
|
horizontal:
|
||||||
|
"flex-row items-center has-[>[data-slot=field-content]]:items-start *:data-[slot=field-label]:flex-auto has-[>[data-slot=field-content]]:[&>[role=checkbox],[role=radio]]:mt-px",
|
||||||
|
responsive:
|
||||||
|
"flex-col *:w-full @md/field-group:flex-row @md/field-group:items-center @md/field-group:*:w-auto @md/field-group:has-[>[data-slot=field-content]]:items-start @md/field-group:*:data-[slot=field-label]:flex-auto [&>.sr-only]:w-auto @md/field-group:has-[>[data-slot=field-content]]:[&>[role=checkbox],[role=radio]]:mt-px",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
orientation: "vertical",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
function Field({
|
||||||
|
className,
|
||||||
|
orientation = "vertical",
|
||||||
|
...props
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
role="group"
|
||||||
|
data-slot="field"
|
||||||
|
data-orientation={orientation}
|
||||||
|
className={cn(fieldVariants({ orientation }), className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function FieldContent({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="field-content"
|
||||||
|
className={cn(
|
||||||
|
"group/field-content flex flex-1 flex-col gap-0.5 leading-snug",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function FieldLabel({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<Label
|
||||||
|
data-slot="field-label"
|
||||||
|
className={cn(
|
||||||
|
"group/field-label peer/field-label flex w-fit gap-2 leading-snug group-data-[disabled=true]/field:opacity-50 has-data-checked:border-primary/30 has-data-checked:bg-primary/5 has-[>[data-slot=field]]:rounded-lg has-[>[data-slot=field]]:border has-[>[data-slot=field]]:not-has-[:disabled,[data-disabled]]:hover:bg-muted/50 has-[>[data-slot=field]]:has-[:focus-visible]:border-ring has-[>[data-slot=field]]:has-[:focus-visible]:ring-3 has-[>[data-slot=field]]:has-[:focus-visible]:ring-ring/50 *:data-[slot=field]:p-2.5 dark:has-data-checked:border-primary/20 dark:has-data-checked:bg-primary/10",
|
||||||
|
"has-[>[data-slot=field]]:w-full has-[>[data-slot=field]]:flex-col",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function FieldTitle({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="field-label"
|
||||||
|
className={cn(
|
||||||
|
"flex w-fit items-center gap-2 text-sm font-medium group-data-[disabled=true]/field:opacity-50",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function FieldDescription({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<p
|
||||||
|
data-slot="field-description"
|
||||||
|
className={cn(
|
||||||
|
"text-left text-sm leading-normal font-normal text-muted-foreground group-has-data-horizontal/field:text-balance [[data-variant=legend]+&]:-mt-1.5",
|
||||||
|
"last:mt-0 nth-last-2:-mt-1",
|
||||||
|
"[&>a]:underline [&>a]:underline-offset-4 [&>a:hover]:text-primary",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function FieldSeparator({
|
||||||
|
children,
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="field-separator"
|
||||||
|
data-content={!!children}
|
||||||
|
className={cn(
|
||||||
|
"relative -my-2 h-5 text-sm group-data-[variant=outline]/field-group:-mb-2",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<Separator className="absolute inset-0 top-1/2" />
|
||||||
|
{children && (
|
||||||
|
<span
|
||||||
|
className="relative mx-auto block w-fit bg-background px-2 text-muted-foreground"
|
||||||
|
data-slot="field-separator-content"
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function FieldError({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
errors,
|
||||||
|
...props
|
||||||
|
}) {
|
||||||
|
const content = useMemo(() => {
|
||||||
|
if (children) {
|
||||||
|
return children
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!errors?.length) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const uniqueErrors = [
|
||||||
|
...new Map(errors.map((error) => [error?.message, error])).values(),
|
||||||
|
]
|
||||||
|
|
||||||
|
if (uniqueErrors?.length == 1) {
|
||||||
|
return uniqueErrors[0]?.message
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ul className="ml-4 flex list-disc flex-col gap-1">
|
||||||
|
{uniqueErrors.map(
|
||||||
|
(error, index) =>
|
||||||
|
error?.message && <li key={index}>{error.message}</li>
|
||||||
|
)}
|
||||||
|
</ul>
|
||||||
|
)
|
||||||
|
}, [children, errors])
|
||||||
|
|
||||||
|
if (!content) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
role="alert"
|
||||||
|
data-slot="field-error"
|
||||||
|
className={cn("text-sm font-normal text-destructive", className)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{content}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
Field,
|
||||||
|
FieldLabel,
|
||||||
|
FieldDescription,
|
||||||
|
FieldError,
|
||||||
|
FieldGroup,
|
||||||
|
FieldLegend,
|
||||||
|
FieldSeparator,
|
||||||
|
FieldSet,
|
||||||
|
FieldContent,
|
||||||
|
FieldTitle,
|
||||||
|
}
|
||||||
50
frontend/src/components/ui/hover-card.jsx
Normal file
50
frontend/src/components/ui/hover-card.jsx
Normal file
@ -0,0 +1,50 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { PreviewCard as PreviewCardPrimitive } from "@base-ui/react/preview-card"
|
||||||
|
import { cn } from "cn"
|
||||||
|
|
||||||
|
function HoverCard({
|
||||||
|
...props
|
||||||
|
}) {
|
||||||
|
return <PreviewCardPrimitive.Root data-slot="hover-card" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function HoverCardTrigger({
|
||||||
|
...props
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<PreviewCardPrimitive.Trigger data-slot="hover-card-trigger" {...props} />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function HoverCardContent({
|
||||||
|
className,
|
||||||
|
side = "bottom",
|
||||||
|
sideOffset = 4,
|
||||||
|
align = "center",
|
||||||
|
alignOffset = 4,
|
||||||
|
...props
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<PreviewCardPrimitive.Portal data-slot="hover-card-portal">
|
||||||
|
<PreviewCardPrimitive.Positioner
|
||||||
|
align={align}
|
||||||
|
alignOffset={alignOffset}
|
||||||
|
side={side}
|
||||||
|
sideOffset={sideOffset}
|
||||||
|
className="isolate z-50"
|
||||||
|
>
|
||||||
|
<PreviewCardPrimitive.Popup
|
||||||
|
data-slot="hover-card-content"
|
||||||
|
className={cn(
|
||||||
|
"z-50 w-64 origin-(--transform-origin) rounded-lg bg-popover p-2.5 text-sm text-popover-foreground shadow-md ring-1 ring-foreground/10 outline-hidden duration-100 data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-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 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</PreviewCardPrimitive.Positioner>
|
||||||
|
</PreviewCardPrimitive.Portal>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { HoverCard, HoverCardTrigger, HoverCardContent }
|
||||||
161
frontend/src/components/ui/input-group.jsx
Normal file
161
frontend/src/components/ui/input-group.jsx
Normal file
@ -0,0 +1,161 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import { cva } from "class-variance-authority";
|
||||||
|
import { cn } from "cn"
|
||||||
|
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import { Input } from "@/components/ui/input"
|
||||||
|
import { Textarea } from "@/components/ui/textarea"
|
||||||
|
|
||||||
|
function InputGroup({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="input-group"
|
||||||
|
role="group"
|
||||||
|
className={cn(
|
||||||
|
"group/input-group relative flex h-8 w-full min-w-0 items-center rounded-lg border border-input transition-colors outline-none in-data-[slot=combobox-content]:focus-within:border-inherit in-data-[slot=combobox-content]:focus-within:ring-0 has-disabled:bg-input/50 has-disabled:opacity-50 has-[[data-slot=input-group-control]:focus-visible]:border-ring has-[[data-slot=input-group-control]:focus-visible]:ring-3 has-[[data-slot=input-group-control]:focus-visible]:ring-ring/50 has-[[data-slot][aria-invalid=true]]:border-destructive has-[[data-slot][aria-invalid=true]]:ring-3 has-[[data-slot][aria-invalid=true]]:ring-destructive/20 has-[>[data-align=block-end]]:h-auto has-[>[data-align=block-end]]:flex-col has-[>[data-align=block-start]]:h-auto has-[>[data-align=block-start]]:flex-col has-[>textarea]:h-auto dark:bg-input/30 dark:has-disabled:bg-input/80 dark:has-[[data-slot][aria-invalid=true]]:ring-destructive/40 has-[>[data-align=block-end]]:[&>input]:pt-3 has-[>[data-align=block-start]]:[&>input]:pb-3 has-[>[data-align=inline-end]]:[&>input]:pr-1.5 has-[>[data-align=inline-start]]:[&>input]:pl-1.5",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const inputGroupAddonVariants = cva(
|
||||||
|
"flex h-auto cursor-text items-center justify-center gap-2 py-1.5 text-sm font-medium text-muted-foreground select-none group-data-[disabled=true]/input-group:opacity-50 [&>kbd]:rounded-[calc(var(--radius)-5px)] [&>svg:not([class*='size-'])]:size-4",
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
align: {
|
||||||
|
"inline-start":
|
||||||
|
"order-first pl-2 has-[>button]:ml-[-0.3rem] has-[>kbd]:ml-[-0.15rem]",
|
||||||
|
"inline-end":
|
||||||
|
"order-last pr-2 has-[>button]:mr-[-0.3rem] has-[>kbd]:mr-[-0.15rem]",
|
||||||
|
"block-start":
|
||||||
|
"order-first w-full justify-start px-2.5 pt-2 group-has-[>input]/input-group:pt-2 [.border-b]:pb-2",
|
||||||
|
"block-end":
|
||||||
|
"order-last w-full justify-start px-2.5 pb-2 group-has-[>input]/input-group:pb-2 [.border-t]:pt-2",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
align: "inline-start",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
function InputGroupAddon({
|
||||||
|
className,
|
||||||
|
align = "inline-start",
|
||||||
|
...props
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
role="group"
|
||||||
|
data-slot="input-group-addon"
|
||||||
|
data-align={align}
|
||||||
|
className={cn(inputGroupAddonVariants({ align }), className)}
|
||||||
|
onClick={(e) => {
|
||||||
|
if ((e.target).closest("button")) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
e.currentTarget.parentElement?.querySelector("input")?.focus()
|
||||||
|
}}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const inputGroupButtonVariants = cva(
|
||||||
|
"flex items-center gap-2 text-sm shadow-none",
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
size: {
|
||||||
|
xs: "h-6 gap-1 rounded-[calc(var(--radius)-3px)] px-1.5 [&>svg:not([class*='size-'])]:size-3.5",
|
||||||
|
sm: "",
|
||||||
|
"icon-xs":
|
||||||
|
"size-6 rounded-[calc(var(--radius)-3px)] p-0 has-[>svg]:p-0",
|
||||||
|
"icon-sm": "size-8 p-0 has-[>svg]:p-0",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
size: "xs",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
function InputGroupButton({
|
||||||
|
className,
|
||||||
|
type = "button",
|
||||||
|
variant = "ghost",
|
||||||
|
size = "xs",
|
||||||
|
...props
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
type={type}
|
||||||
|
data-size={size}
|
||||||
|
variant={variant}
|
||||||
|
className={cn(inputGroupButtonVariants({ size }), className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function InputGroupText({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"flex items-center gap-2 text-sm text-muted-foreground [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function InputGroupInput({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<Input
|
||||||
|
data-slot="input-group-control"
|
||||||
|
className={cn(
|
||||||
|
"flex-1 rounded-none border-0 bg-transparent shadow-none ring-0 focus-visible:ring-0 disabled:bg-transparent aria-invalid:ring-0 dark:bg-transparent dark:disabled:bg-transparent",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function InputGroupTextarea({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<Textarea
|
||||||
|
data-slot="input-group-control"
|
||||||
|
className={cn(
|
||||||
|
"flex-1 resize-none rounded-none border-0 bg-transparent py-2 shadow-none ring-0 focus-visible:ring-0 disabled:bg-transparent aria-invalid:ring-0 dark:bg-transparent dark:disabled:bg-transparent",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
InputGroup,
|
||||||
|
InputGroupAddon,
|
||||||
|
InputGroupButton,
|
||||||
|
InputGroupText,
|
||||||
|
InputGroupInput,
|
||||||
|
InputGroupTextarea,
|
||||||
|
}
|
||||||
85
frontend/src/components/ui/input-otp.jsx
Normal file
85
frontend/src/components/ui/input-otp.jsx
Normal file
@ -0,0 +1,85 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
import { cn } from "cn"
|
||||||
|
import { OTPInput, OTPInputContext } from "input-otp"
|
||||||
|
import { MinusIcon } from "lucide-react"
|
||||||
|
|
||||||
|
function InputOTP({
|
||||||
|
className,
|
||||||
|
containerClassName,
|
||||||
|
...props
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<OTPInput
|
||||||
|
data-slot="input-otp"
|
||||||
|
containerClassName={cn(
|
||||||
|
"cn-input-otp flex items-center has-disabled:opacity-50",
|
||||||
|
containerClassName
|
||||||
|
)}
|
||||||
|
spellCheck={false}
|
||||||
|
className={cn("disabled:cursor-not-allowed", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function InputOTPGroup({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="input-otp-group"
|
||||||
|
className={cn(
|
||||||
|
"flex items-center rounded-lg has-aria-invalid:border-destructive has-aria-invalid:ring-3 has-aria-invalid:ring-destructive/20 dark:has-aria-invalid:ring-destructive/40",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function InputOTPSlot({
|
||||||
|
index,
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}) {
|
||||||
|
const inputOTPContext = React.useContext(OTPInputContext)
|
||||||
|
const { char, hasFakeCaret, isActive } = inputOTPContext?.slots[index] ?? {}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="input-otp-slot"
|
||||||
|
data-active={isActive}
|
||||||
|
className={cn(
|
||||||
|
"relative flex size-8 items-center justify-center border-y border-r border-input text-sm transition-all outline-none first:rounded-l-lg first:border-l last:rounded-r-lg aria-invalid:border-destructive data-[active=true]:z-10 data-[active=true]:border-ring data-[active=true]:ring-3 data-[active=true]:ring-ring/50 data-[active=true]:aria-invalid:border-destructive data-[active=true]:aria-invalid:ring-destructive/20 dark:bg-input/30 dark:data-[active=true]:aria-invalid:ring-destructive/40",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{char}
|
||||||
|
{hasFakeCaret && (
|
||||||
|
<div className="pointer-events-none absolute inset-0 flex items-center justify-center">
|
||||||
|
<div className="h-4 w-px animate-caret-blink bg-foreground duration-1000" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function InputOTPSeparator({
|
||||||
|
...props
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="input-otp-separator"
|
||||||
|
className="flex items-center [&_svg:not([class*='size-'])]:size-4"
|
||||||
|
role="separator"
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<MinusIcon
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { InputOTP, InputOTPGroup, InputOTPSlot, InputOTPSeparator }
|
||||||
23
frontend/src/components/ui/input.jsx
Normal file
23
frontend/src/components/ui/input.jsx
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
import { Input as InputPrimitive } from "@base-ui/react/input"
|
||||||
|
import { cn } from "cn"
|
||||||
|
|
||||||
|
function Input({
|
||||||
|
className,
|
||||||
|
type,
|
||||||
|
...props
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<InputPrimitive
|
||||||
|
type={type}
|
||||||
|
data-slot="input"
|
||||||
|
className={cn(
|
||||||
|
"h-8 w-full min-w-0 rounded-lg border border-input bg-transparent px-2.5 py-1 text-base transition-colors outline-none file:inline-flex file:h-6 file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:pointer-events-none disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Input }
|
||||||
219
frontend/src/components/ui/item.jsx
Normal file
219
frontend/src/components/ui/item.jsx
Normal file
@ -0,0 +1,219 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
import { mergeProps } from "@base-ui/react/merge-props"
|
||||||
|
import { useRender } from "@base-ui/react/use-render"
|
||||||
|
import { cva } from "class-variance-authority";
|
||||||
|
import { cn } from "cn"
|
||||||
|
|
||||||
|
import { Separator } from "@/components/ui/separator"
|
||||||
|
|
||||||
|
function ItemGroup({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
role="list"
|
||||||
|
data-slot="item-group"
|
||||||
|
className={cn(
|
||||||
|
"group/item-group flex w-full flex-col gap-4 has-data-[size=sm]:gap-2.5 has-data-[size=xs]:gap-2",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ItemSeparator({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<Separator
|
||||||
|
data-slot="item-separator"
|
||||||
|
orientation="horizontal"
|
||||||
|
className={cn("my-2", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const itemVariants = cva(
|
||||||
|
"group/item flex w-full flex-wrap items-center rounded-lg border text-sm transition-colors duration-100 outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 [a]:transition-colors [a]:hover:bg-muted",
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default: "border-transparent",
|
||||||
|
outline: "border-border",
|
||||||
|
muted: "border-transparent bg-muted/50",
|
||||||
|
},
|
||||||
|
size: {
|
||||||
|
default: "gap-2.5 px-3 py-2.5",
|
||||||
|
sm: "gap-2.5 px-3 py-2.5",
|
||||||
|
xs: "gap-2 px-2.5 py-2 in-data-[slot=dropdown-menu-content]:p-0",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: "default",
|
||||||
|
size: "default",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
function Item({
|
||||||
|
className,
|
||||||
|
variant = "default",
|
||||||
|
size = "default",
|
||||||
|
render,
|
||||||
|
...props
|
||||||
|
}) {
|
||||||
|
return useRender({
|
||||||
|
defaultTagName: "div",
|
||||||
|
props: mergeProps({
|
||||||
|
className: cn(itemVariants({ variant, size, className })),
|
||||||
|
}, props),
|
||||||
|
render,
|
||||||
|
state: {
|
||||||
|
slot: "item",
|
||||||
|
variant,
|
||||||
|
size,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const itemMediaVariants = cva(
|
||||||
|
"flex shrink-0 items-center justify-center gap-2 group-has-data-[slot=item-description]/item:translate-y-0.5 group-has-data-[slot=item-description]/item:self-start [&_svg]:pointer-events-none",
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default: "bg-transparent",
|
||||||
|
icon: "[&_svg:not([class*='size-'])]:size-4",
|
||||||
|
image:
|
||||||
|
"size-10 overflow-hidden rounded-sm group-data-[size=sm]/item:size-8 group-data-[size=xs]/item:size-6 [&_img]:size-full [&_img]:object-cover",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: "default",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
function ItemMedia({
|
||||||
|
className,
|
||||||
|
variant = "default",
|
||||||
|
...props
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="item-media"
|
||||||
|
data-variant={variant}
|
||||||
|
className={cn(itemMediaVariants({ variant, className }))}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ItemContent({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="item-content"
|
||||||
|
className={cn(
|
||||||
|
"flex flex-1 flex-col gap-1 group-data-[size=xs]/item:gap-0 [&+[data-slot=item-content]]:flex-none",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ItemTitle({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="item-title"
|
||||||
|
className={cn(
|
||||||
|
"line-clamp-1 flex w-fit items-center gap-2 text-sm leading-snug font-medium underline-offset-4",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ItemDescription({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<p
|
||||||
|
data-slot="item-description"
|
||||||
|
className={cn(
|
||||||
|
"line-clamp-2 text-left text-sm leading-normal font-normal text-muted-foreground group-data-[size=xs]/item:text-xs [&>a]:underline [&>a]:underline-offset-4 [&>a:hover]:text-primary",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ItemActions({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="item-actions"
|
||||||
|
className={cn("flex items-center gap-2", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ItemHeader({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="item-header"
|
||||||
|
className={cn(
|
||||||
|
"flex basis-full items-center justify-between gap-2",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ItemFooter({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="item-footer"
|
||||||
|
className={cn(
|
||||||
|
"flex basis-full items-center justify-between gap-2",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
Item,
|
||||||
|
ItemMedia,
|
||||||
|
ItemContent,
|
||||||
|
ItemActions,
|
||||||
|
ItemGroup,
|
||||||
|
ItemSeparator,
|
||||||
|
ItemTitle,
|
||||||
|
ItemDescription,
|
||||||
|
ItemHeader,
|
||||||
|
ItemFooter,
|
||||||
|
}
|
||||||
32
frontend/src/components/ui/kbd.jsx
Normal file
32
frontend/src/components/ui/kbd.jsx
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
import { cn } from "cn"
|
||||||
|
|
||||||
|
function Kbd({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<kbd
|
||||||
|
data-slot="kbd"
|
||||||
|
className={cn(
|
||||||
|
"pointer-events-none inline-flex h-5 w-fit min-w-5 items-center justify-center gap-1 rounded-sm bg-muted px-1 font-sans text-xs font-medium text-muted-foreground select-none in-data-[slot=tooltip-content]:bg-background/20 in-data-[slot=tooltip-content]:text-background dark:in-data-[slot=tooltip-content]:bg-background/10 [&_svg:not([class*='size-'])]:size-3",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function KbdGroup({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<kbd
|
||||||
|
data-slot="kbd-group"
|
||||||
|
className={cn("inline-flex items-center gap-1", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Kbd, KbdGroup }
|
||||||
22
frontend/src/components/ui/label.jsx
Normal file
22
frontend/src/components/ui/label.jsx
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import { cn } from "cn"
|
||||||
|
|
||||||
|
function Label({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<label
|
||||||
|
data-slot="label"
|
||||||
|
className={cn(
|
||||||
|
"flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Label }
|
||||||
73
frontend/src/components/ui/marker.jsx
Normal file
73
frontend/src/components/ui/marker.jsx
Normal file
@ -0,0 +1,73 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
import { mergeProps } from "@base-ui/react/merge-props"
|
||||||
|
import { useRender } from "@base-ui/react/use-render"
|
||||||
|
import { cva } from "class-variance-authority";
|
||||||
|
import { cn } from "cn"
|
||||||
|
|
||||||
|
const markerVariants = cva(
|
||||||
|
"group/marker relative flex min-h-4 w-full items-center gap-2 text-left text-sm text-muted-foreground [&_svg:not([class*='size-'])]:size-4 [a]:underline [a]:underline-offset-3 [a]:hover:text-foreground",
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default: "",
|
||||||
|
separator:
|
||||||
|
"before:mr-1 before:h-px before:min-w-0 before:flex-1 before:bg-border after:ml-1 after:h-px after:min-w-0 after:flex-1 after:bg-border",
|
||||||
|
border: "border-b border-border pb-2",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
function Marker({
|
||||||
|
className,
|
||||||
|
variant = "default",
|
||||||
|
render,
|
||||||
|
...props
|
||||||
|
}) {
|
||||||
|
return useRender({
|
||||||
|
defaultTagName: "div",
|
||||||
|
props: mergeProps({
|
||||||
|
className: cn(markerVariants({ variant, className })),
|
||||||
|
}, props),
|
||||||
|
render,
|
||||||
|
state: {
|
||||||
|
slot: "marker",
|
||||||
|
variant,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function MarkerIcon({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
data-slot="marker-icon"
|
||||||
|
aria-hidden="true"
|
||||||
|
className={cn(
|
||||||
|
"size-4 shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function MarkerContent({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
data-slot="marker-content"
|
||||||
|
className={cn(
|
||||||
|
"min-w-0 wrap-break-word group-data-[variant=separator]/marker:flex-none group-data-[variant=separator]/marker:text-center *:[a]:underline *:[a]:underline-offset-3 *:[a]:hover:text-foreground",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Marker, MarkerIcon, MarkerContent, markerVariants }
|
||||||
277
frontend/src/components/ui/menubar.jsx
Normal file
277
frontend/src/components/ui/menubar.jsx
Normal file
@ -0,0 +1,277 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import { Menu as MenuPrimitive } from "@base-ui/react/menu"
|
||||||
|
import { Menubar as MenubarPrimitive } from "@base-ui/react/menubar"
|
||||||
|
import { cn } from "cn"
|
||||||
|
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuGroup,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuLabel,
|
||||||
|
DropdownMenuPortal,
|
||||||
|
DropdownMenuRadioGroup,
|
||||||
|
DropdownMenuSeparator,
|
||||||
|
DropdownMenuShortcut,
|
||||||
|
DropdownMenuSub,
|
||||||
|
DropdownMenuSubContent,
|
||||||
|
DropdownMenuSubTrigger,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from "@/components/ui/dropdown-menu"
|
||||||
|
import { CheckIcon } from "lucide-react"
|
||||||
|
|
||||||
|
function Menubar({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<MenubarPrimitive
|
||||||
|
data-slot="menubar"
|
||||||
|
className={cn(
|
||||||
|
"flex h-8 items-center gap-0.5 rounded-lg border p-[3px]",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function MenubarMenu({
|
||||||
|
...props
|
||||||
|
}) {
|
||||||
|
return <DropdownMenu data-slot="menubar-menu" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function MenubarGroup({
|
||||||
|
...props
|
||||||
|
}) {
|
||||||
|
return <DropdownMenuGroup data-slot="menubar-group" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function MenubarPortal({
|
||||||
|
...props
|
||||||
|
}) {
|
||||||
|
return <DropdownMenuPortal data-slot="menubar-portal" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function MenubarTrigger({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<DropdownMenuTrigger
|
||||||
|
data-slot="menubar-trigger"
|
||||||
|
className={cn(
|
||||||
|
"flex items-center rounded-sm px-1.5 py-[2px] text-sm font-medium outline-hidden select-none hover:bg-muted aria-expanded:bg-muted",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function MenubarContent({
|
||||||
|
className,
|
||||||
|
align = "start",
|
||||||
|
alignOffset = -4,
|
||||||
|
sideOffset = 8,
|
||||||
|
...props
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<DropdownMenuContent
|
||||||
|
data-slot="menubar-content"
|
||||||
|
align={align}
|
||||||
|
alignOffset={alignOffset}
|
||||||
|
sideOffset={sideOffset}
|
||||||
|
className={cn("min-w-36 rounded-lg bg-popover p-1 text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-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 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95", className )}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function MenubarItem({
|
||||||
|
className,
|
||||||
|
inset,
|
||||||
|
variant = "default",
|
||||||
|
...props
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<DropdownMenuItem
|
||||||
|
data-slot="menubar-item"
|
||||||
|
data-inset={inset}
|
||||||
|
data-variant={variant}
|
||||||
|
className={cn(
|
||||||
|
"group/menubar-item gap-1.5 rounded-md px-1.5 py-1 text-sm focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-inset:pl-7 data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 data-[variant=destructive]:focus:text-destructive dark:data-[variant=destructive]:focus:bg-destructive/20 data-disabled:opacity-50 [&_svg:not([class*='size-'])]:size-4 data-[variant=destructive]:*:[svg]:text-destructive!",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function MenubarCheckboxItem({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
checked,
|
||||||
|
inset,
|
||||||
|
...props
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<MenuPrimitive.CheckboxItem
|
||||||
|
data-slot="menubar-checkbox-item"
|
||||||
|
data-inset={inset}
|
||||||
|
className={cn(
|
||||||
|
"relative flex cursor-default items-center gap-1.5 rounded-md py-1 pr-1.5 pl-7 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground focus:**:text-accent-foreground data-inset:pl-7 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
checked={checked}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<span className="pointer-events-none absolute left-1.5 flex size-4 items-center justify-center [&_svg:not([class*='size-'])]:size-4">
|
||||||
|
<MenuPrimitive.CheckboxItemIndicator>
|
||||||
|
<CheckIcon
|
||||||
|
/>
|
||||||
|
</MenuPrimitive.CheckboxItemIndicator>
|
||||||
|
</span>
|
||||||
|
{children}
|
||||||
|
</MenuPrimitive.CheckboxItem>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function MenubarRadioGroup({
|
||||||
|
...props
|
||||||
|
}) {
|
||||||
|
return <DropdownMenuRadioGroup data-slot="menubar-radio-group" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function MenubarRadioItem({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
inset,
|
||||||
|
...props
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<MenuPrimitive.RadioItem
|
||||||
|
data-slot="menubar-radio-item"
|
||||||
|
data-inset={inset}
|
||||||
|
className={cn(
|
||||||
|
"relative flex cursor-default items-center gap-1.5 rounded-md py-1 pr-1.5 pl-7 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground focus:**:text-accent-foreground data-inset:pl-7 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<span className="pointer-events-none absolute left-1.5 flex size-4 items-center justify-center [&_svg:not([class*='size-'])]:size-4">
|
||||||
|
<MenuPrimitive.RadioItemIndicator>
|
||||||
|
<CheckIcon
|
||||||
|
/>
|
||||||
|
</MenuPrimitive.RadioItemIndicator>
|
||||||
|
</span>
|
||||||
|
{children}
|
||||||
|
</MenuPrimitive.RadioItem>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function MenubarLabel({
|
||||||
|
className,
|
||||||
|
inset,
|
||||||
|
...props
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<DropdownMenuLabel
|
||||||
|
data-slot="menubar-label"
|
||||||
|
data-inset={inset}
|
||||||
|
className={cn(
|
||||||
|
"px-1.5 py-1 text-sm font-medium data-inset:pl-7",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function MenubarSeparator({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<DropdownMenuSeparator
|
||||||
|
data-slot="menubar-separator"
|
||||||
|
className={cn("-mx-1 my-1 h-px bg-border", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function MenubarShortcut({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<DropdownMenuShortcut
|
||||||
|
data-slot="menubar-shortcut"
|
||||||
|
className={cn(
|
||||||
|
"ml-auto text-xs tracking-widest text-muted-foreground group-focus/menubar-item:text-accent-foreground",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function MenubarSub({
|
||||||
|
...props
|
||||||
|
}) {
|
||||||
|
return <DropdownMenuSub data-slot="menubar-sub" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function MenubarSubTrigger({
|
||||||
|
className,
|
||||||
|
inset,
|
||||||
|
...props
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<DropdownMenuSubTrigger
|
||||||
|
data-slot="menubar-sub-trigger"
|
||||||
|
data-inset={inset}
|
||||||
|
className={cn(
|
||||||
|
"gap-1.5 rounded-md px-1.5 py-1 text-sm focus:bg-accent focus:text-accent-foreground data-inset:pl-7 data-open:bg-accent data-open:text-accent-foreground [&_svg:not([class*='size-'])]:size-4",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function MenubarSubContent({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<DropdownMenuSubContent
|
||||||
|
data-slot="menubar-sub-content"
|
||||||
|
className={cn("min-w-32 rounded-lg bg-popover p-1 text-popover-foreground shadow-lg ring-1 ring-foreground/10 duration-100 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 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95", className )}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
Menubar,
|
||||||
|
MenubarPortal,
|
||||||
|
MenubarMenu,
|
||||||
|
MenubarTrigger,
|
||||||
|
MenubarContent,
|
||||||
|
MenubarGroup,
|
||||||
|
MenubarSeparator,
|
||||||
|
MenubarLabel,
|
||||||
|
MenubarItem,
|
||||||
|
MenubarShortcut,
|
||||||
|
MenubarCheckboxItem,
|
||||||
|
MenubarRadioGroup,
|
||||||
|
MenubarRadioItem,
|
||||||
|
MenubarSub,
|
||||||
|
MenubarSubTrigger,
|
||||||
|
MenubarSubContent,
|
||||||
|
}
|
||||||
128
frontend/src/components/ui/message-scroller.jsx
Normal file
128
frontend/src/components/ui/message-scroller.jsx
Normal file
@ -0,0 +1,128 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
import {
|
||||||
|
MessageScroller as MessageScrollerPrimitive,
|
||||||
|
useMessageScroller,
|
||||||
|
useMessageScrollerScrollable,
|
||||||
|
useMessageScrollerVisibility,
|
||||||
|
} from "@shadcn/react/message-scroller"
|
||||||
|
import { cn } from "cn"
|
||||||
|
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import { ArrowDownIcon } from "lucide-react"
|
||||||
|
|
||||||
|
function MessageScrollerProvider(
|
||||||
|
props
|
||||||
|
) {
|
||||||
|
return <MessageScrollerPrimitive.Provider {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function MessageScroller({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<MessageScrollerPrimitive.Root
|
||||||
|
data-slot="message-scroller"
|
||||||
|
className={cn(
|
||||||
|
"group/message-scroller relative flex size-full min-h-0 flex-col overflow-hidden",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function MessageScrollerViewport({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<MessageScrollerPrimitive.Viewport
|
||||||
|
data-slot="message-scroller-viewport"
|
||||||
|
className={cn(
|
||||||
|
"size-full min-h-0 min-w-0 scroll-fade-b scrollbar-thin scrollbar-gutter-stable overflow-y-auto overscroll-contain contain-content data-autoscrolling:scrollbar-thumb-transparent data-autoscrolling:scrollbar-track-transparent data-pending-scroll:invisible",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function MessageScrollerContent({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<MessageScrollerPrimitive.Content
|
||||||
|
data-slot="message-scroller-content"
|
||||||
|
className={cn("flex h-max min-h-full flex-col gap-6", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function MessageScrollerItem({
|
||||||
|
className,
|
||||||
|
scrollAnchor = false,
|
||||||
|
...props
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<MessageScrollerPrimitive.Item
|
||||||
|
data-slot="message-scroller-item"
|
||||||
|
scrollAnchor={scrollAnchor}
|
||||||
|
className={cn(
|
||||||
|
"min-w-0 shrink-0 [contain-intrinsic-size:auto_10rem] [content-visibility:auto]",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function MessageScrollerButton({
|
||||||
|
direction = "end",
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
render,
|
||||||
|
variant = "secondary",
|
||||||
|
size = "icon-sm",
|
||||||
|
...props
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<MessageScrollerPrimitive.Button
|
||||||
|
data-slot="message-scroller-button"
|
||||||
|
data-direction={direction}
|
||||||
|
data-variant={variant}
|
||||||
|
data-size={size}
|
||||||
|
direction={direction}
|
||||||
|
className={cn(
|
||||||
|
"absolute inset-s-1/2 -translate-x-1/2 border-border bg-background text-foreground transition-[translate,scale,opacity] duration-200 hover:bg-muted hover:text-foreground data-[active=false]:pointer-events-none data-[active=false]:scale-95 data-[active=false]:opacity-0 data-[active=false]:duration-400 data-[active=false]:ease-[cubic-bezier(0.7,0,0.84,0)] data-[active=true]:translate-y-0 data-[active=true]:scale-100 data-[active=true]:opacity-100 data-[active=true]:ease-[cubic-bezier(0.23,1,0.32,1)] data-[direction=end]:bottom-4 data-[direction=end]:data-[active=false]:translate-y-full data-[direction=start]:top-4 data-[direction=start]:data-[active=false]:-translate-y-full rtl:translate-x-1/2 data-[direction=start]:[&_svg]:rotate-180",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
render={render ?? <Button variant={variant} size={size} />}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children ?? (
|
||||||
|
<>
|
||||||
|
<ArrowDownIcon
|
||||||
|
/>
|
||||||
|
<span className="sr-only">
|
||||||
|
{direction === "end" ? "Scroll to end" : "Scroll to start"}
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</MessageScrollerPrimitive.Button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
MessageScrollerProvider,
|
||||||
|
MessageScroller,
|
||||||
|
MessageScrollerViewport,
|
||||||
|
MessageScrollerContent,
|
||||||
|
MessageScrollerItem,
|
||||||
|
MessageScrollerButton,
|
||||||
|
useMessageScroller,
|
||||||
|
useMessageScrollerScrollable,
|
||||||
|
useMessageScrollerVisibility,
|
||||||
|
}
|
||||||
106
frontend/src/components/ui/message.jsx
Normal file
106
frontend/src/components/ui/message.jsx
Normal file
@ -0,0 +1,106 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
import { cn } from "cn"
|
||||||
|
|
||||||
|
function MessageGroup({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="message-group"
|
||||||
|
className={cn("flex min-w-0 flex-col gap-2", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Message({
|
||||||
|
className,
|
||||||
|
align = "start",
|
||||||
|
...props
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="message"
|
||||||
|
data-align={align}
|
||||||
|
className={cn(
|
||||||
|
"group/message relative flex w-full min-w-0 gap-2 text-sm data-[align=end]:flex-row-reverse",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function MessageAvatar({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="message-avatar"
|
||||||
|
className={cn(
|
||||||
|
"flex w-fit min-w-8 shrink-0 items-center justify-center self-end overflow-hidden rounded-full bg-muted group-has-data-[slot=message-footer]/message:-translate-y-8",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function MessageContent({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="message-content"
|
||||||
|
className={cn(
|
||||||
|
"flex w-full min-w-0 flex-col gap-2.5 wrap-break-word group-data-[align=end]/message:*:data-slot:self-end",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function MessageHeader({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="message-header"
|
||||||
|
className={cn(
|
||||||
|
"flex max-w-full min-w-0 items-center px-3 text-xs font-medium text-muted-foreground group-has-data-[variant=ghost]/message:px-0",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function MessageFooter({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="message-footer"
|
||||||
|
className={cn(
|
||||||
|
"flex max-w-full min-w-0 items-center px-3 text-xs font-medium text-muted-foreground group-has-data-[variant=ghost]/message:px-0 group-data-[align=end]/message:justify-end",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
MessageGroup,
|
||||||
|
Message,
|
||||||
|
MessageAvatar,
|
||||||
|
MessageContent,
|
||||||
|
MessageFooter,
|
||||||
|
MessageHeader,
|
||||||
|
}
|
||||||
56
frontend/src/components/ui/native-select.jsx
Normal file
56
frontend/src/components/ui/native-select.jsx
Normal file
@ -0,0 +1,56 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
import { cn } from "cn"
|
||||||
|
import { ChevronDownIcon } from "lucide-react"
|
||||||
|
|
||||||
|
function NativeSelect({
|
||||||
|
className,
|
||||||
|
size = "default",
|
||||||
|
...props
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"group/native-select relative w-fit has-[select:disabled]:opacity-50",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
data-slot="native-select-wrapper"
|
||||||
|
data-size={size}
|
||||||
|
>
|
||||||
|
<select
|
||||||
|
data-slot="native-select"
|
||||||
|
data-size={size}
|
||||||
|
className="h-8 w-full min-w-0 appearance-none rounded-lg border border-input bg-transparent py-1 pr-8 pl-2.5 text-sm transition-colors outline-none select-none selection:bg-primary selection:text-primary-foreground placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:pointer-events-none disabled:cursor-not-allowed aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 data-[size=sm]:h-7 data-[size=sm]:rounded-[min(var(--radius-md),10px)] data-[size=sm]:py-0.5 dark:bg-input/30 dark:hover:bg-input/50 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40"
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
<ChevronDownIcon className="pointer-events-none absolute top-1/2 right-2.5 size-4 -translate-y-1/2 text-muted-foreground select-none" aria-hidden="true" data-slot="native-select-icon" />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function NativeSelectOption({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<option
|
||||||
|
data-slot="native-select-option"
|
||||||
|
className={cn("bg-[Canvas] text-[CanvasText]", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function NativeSelectOptGroup({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<optgroup
|
||||||
|
data-slot="native-select-optgroup"
|
||||||
|
className={cn("bg-[Canvas] text-[CanvasText]", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { NativeSelect, NativeSelectOptGroup, NativeSelectOption }
|
||||||
166
frontend/src/components/ui/navigation-menu.jsx
Normal file
166
frontend/src/components/ui/navigation-menu.jsx
Normal file
@ -0,0 +1,166 @@
|
|||||||
|
import { NavigationMenu as NavigationMenuPrimitive } from "@base-ui/react/navigation-menu"
|
||||||
|
import { cva } from "class-variance-authority"
|
||||||
|
import { cn } from "cn"
|
||||||
|
import { ChevronDownIcon } from "lucide-react"
|
||||||
|
|
||||||
|
function NavigationMenu({
|
||||||
|
align = "start",
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<NavigationMenuPrimitive.Root
|
||||||
|
data-slot="navigation-menu"
|
||||||
|
className={cn(
|
||||||
|
"group/navigation-menu relative flex max-w-max flex-1 items-center justify-center",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
<NavigationMenuPositioner align={align} />
|
||||||
|
</NavigationMenuPrimitive.Root>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function NavigationMenuList({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<NavigationMenuPrimitive.List
|
||||||
|
data-slot="navigation-menu-list"
|
||||||
|
className={cn(
|
||||||
|
"group flex flex-1 list-none items-center justify-center gap-0",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function NavigationMenuItem({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<NavigationMenuPrimitive.Item
|
||||||
|
data-slot="navigation-menu-item"
|
||||||
|
className={cn("relative", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const navigationMenuTriggerStyle = cva(
|
||||||
|
"group/navigation-menu-trigger inline-flex h-9 w-max items-center justify-center rounded-lg px-2.5 py-1.5 text-sm font-medium transition-all outline-none hover:bg-muted focus:bg-muted focus-visible:ring-3 focus-visible:ring-ring/50 focus-visible:outline-1 disabled:pointer-events-none disabled:opacity-50 data-popup-open:bg-muted/50 data-popup-open:hover:bg-muted data-open:bg-muted/50 data-open:hover:bg-muted data-open:focus:bg-muted"
|
||||||
|
)
|
||||||
|
|
||||||
|
function NavigationMenuTrigger({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<NavigationMenuPrimitive.Trigger
|
||||||
|
data-slot="navigation-menu-trigger"
|
||||||
|
className={cn(navigationMenuTriggerStyle(), "group", className)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}{" "}
|
||||||
|
<ChevronDownIcon className="relative top-px ml-1 size-3 transition duration-300 group-data-popup-open/navigation-menu-trigger:rotate-180 group-data-open/navigation-menu-trigger:rotate-180" aria-hidden="true" />
|
||||||
|
</NavigationMenuPrimitive.Trigger>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function NavigationMenuContent({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<NavigationMenuPrimitive.Content
|
||||||
|
data-slot="navigation-menu-content"
|
||||||
|
className={cn(
|
||||||
|
"data-ending-style:data-activation-direction=left:translate-x-[50%] data-ending-style:data-activation-direction=right:translate-x-[-50%] data-starting-style:data-activation-direction=left:translate-x-[-50%] data-starting-style:data-activation-direction=right:translate-x-[50%] h-full w-auto p-1 transition-[opacity,transform,translate] duration-[0.35s] ease-[cubic-bezier(0.22,1,0.36,1)] group-data-[viewport=false]/navigation-menu:rounded-lg group-data-[viewport=false]/navigation-menu:bg-popover group-data-[viewport=false]/navigation-menu:text-popover-foreground group-data-[viewport=false]/navigation-menu:shadow group-data-[viewport=false]/navigation-menu:ring-1 group-data-[viewport=false]/navigation-menu:ring-foreground/10 group-data-[viewport=false]/navigation-menu:duration-300 data-ending-style:opacity-0 data-starting-style:opacity-0 data-[motion=from-end]:slide-in-from-right-52 data-[motion=from-start]:slide-in-from-left-52 data-[motion=to-end]:slide-out-to-right-52 data-[motion=to-start]:slide-out-to-left-52 data-[motion^=from-]:animate-in data-[motion^=from-]:fade-in data-[motion^=to-]:animate-out data-[motion^=to-]:fade-out **:data-[slot=navigation-menu-link]:focus:ring-0 **:data-[slot=navigation-menu-link]:focus:outline-none group-data-[viewport=false]/navigation-menu:data-open:animate-in group-data-[viewport=false]/navigation-menu:data-open:fade-in-0 group-data-[viewport=false]/navigation-menu:data-open:zoom-in-95 group-data-[viewport=false]/navigation-menu:data-closed:animate-out group-data-[viewport=false]/navigation-menu:data-closed:fade-out-0 group-data-[viewport=false]/navigation-menu:data-closed:zoom-out-95",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function NavigationMenuPositioner({
|
||||||
|
className,
|
||||||
|
side = "bottom",
|
||||||
|
sideOffset = 8,
|
||||||
|
align = "start",
|
||||||
|
alignOffset = 0,
|
||||||
|
...props
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<NavigationMenuPrimitive.Portal>
|
||||||
|
<NavigationMenuPrimitive.Positioner
|
||||||
|
side={side}
|
||||||
|
sideOffset={sideOffset}
|
||||||
|
align={align}
|
||||||
|
alignOffset={alignOffset}
|
||||||
|
className={cn(
|
||||||
|
"isolate z-50 h-(--positioner-height) w-(--positioner-width) max-w-(--available-width) transition-[top,left,right,bottom] duration-[0.35s] ease-[cubic-bezier(0.22,1,0.36,1)] data-instant:transition-none data-[side=bottom]:before:top-[-10px] data-[side=bottom]:before:right-0 data-[side=bottom]:before:left-0",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<NavigationMenuPrimitive.Popup className="data-[ending-style]:easing-[ease] xs:w-(--popup-width) relative h-(--popup-height) w-(--popup-width) origin-(--transform-origin) rounded-lg bg-popover text-popover-foreground shadow ring-1 ring-foreground/10 transition-[opacity,transform,width,height,scale,translate] duration-[0.35s] ease-[cubic-bezier(0.22,1,0.36,1)] outline-none data-ending-style:scale-90 data-ending-style:opacity-0 data-ending-style:duration-150 data-starting-style:scale-90 data-starting-style:opacity-0">
|
||||||
|
<NavigationMenuPrimitive.Viewport className="relative size-full overflow-hidden" />
|
||||||
|
</NavigationMenuPrimitive.Popup>
|
||||||
|
</NavigationMenuPrimitive.Positioner>
|
||||||
|
</NavigationMenuPrimitive.Portal>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function NavigationMenuLink({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<NavigationMenuPrimitive.Link
|
||||||
|
data-slot="navigation-menu-link"
|
||||||
|
className={cn(
|
||||||
|
"flex items-center gap-2 rounded-lg p-2 text-sm transition-all outline-none hover:bg-muted focus:bg-muted focus-visible:ring-3 focus-visible:ring-ring/50 focus-visible:outline-1 in-data-[slot=navigation-menu-content]:rounded-md data-active:bg-muted/50 data-active:hover:bg-muted data-active:focus:bg-muted [&_svg:not([class*='size-'])]:size-4",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function NavigationMenuIndicator({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<NavigationMenuPrimitive.Icon
|
||||||
|
data-slot="navigation-menu-indicator"
|
||||||
|
className={cn(
|
||||||
|
"top-full z-1 flex h-1.5 items-end justify-center overflow-hidden data-[state=hidden]:animate-out data-[state=hidden]:fade-out data-[state=visible]:animate-in data-[state=visible]:fade-in",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<div className="relative top-[60%] h-2 w-2 rotate-45 rounded-tl-sm bg-border shadow-md" />
|
||||||
|
</NavigationMenuPrimitive.Icon>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
NavigationMenu,
|
||||||
|
NavigationMenuContent,
|
||||||
|
NavigationMenuIndicator,
|
||||||
|
NavigationMenuItem,
|
||||||
|
NavigationMenuLink,
|
||||||
|
NavigationMenuList,
|
||||||
|
NavigationMenuTrigger,
|
||||||
|
navigationMenuTriggerStyle,
|
||||||
|
NavigationMenuPositioner,
|
||||||
|
}
|
||||||
130
frontend/src/components/ui/pagination.jsx
Normal file
130
frontend/src/components/ui/pagination.jsx
Normal file
@ -0,0 +1,130 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
import { cn } from "cn"
|
||||||
|
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import { ChevronLeftIcon, ChevronRightIcon, MoreHorizontalIcon } from "lucide-react"
|
||||||
|
|
||||||
|
function Pagination({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<nav
|
||||||
|
role="navigation"
|
||||||
|
aria-label="pagination"
|
||||||
|
data-slot="pagination"
|
||||||
|
className={cn("mx-auto flex w-full justify-center", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function PaginationContent({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<ul
|
||||||
|
data-slot="pagination-content"
|
||||||
|
className={cn("flex items-center gap-0.5", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function PaginationItem({
|
||||||
|
...props
|
||||||
|
}) {
|
||||||
|
return <li data-slot="pagination-item" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function PaginationLink({
|
||||||
|
className,
|
||||||
|
isActive,
|
||||||
|
size = "icon",
|
||||||
|
...props
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
variant={isActive ? "outline" : "ghost"}
|
||||||
|
size={size}
|
||||||
|
className={cn(className)}
|
||||||
|
nativeButton={false}
|
||||||
|
render={
|
||||||
|
<a
|
||||||
|
aria-current={isActive ? "page" : undefined}
|
||||||
|
data-slot="pagination-link"
|
||||||
|
data-active={isActive}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function PaginationPrevious({
|
||||||
|
className,
|
||||||
|
text = "Previous",
|
||||||
|
...props
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<PaginationLink
|
||||||
|
aria-label="Go to previous page"
|
||||||
|
size="default"
|
||||||
|
className={cn("pl-1.5!", className)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<ChevronLeftIcon data-icon="inline-start" />
|
||||||
|
<span className="hidden sm:block">{text}</span>
|
||||||
|
</PaginationLink>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function PaginationNext({
|
||||||
|
className,
|
||||||
|
text = "Next",
|
||||||
|
...props
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<PaginationLink
|
||||||
|
aria-label="Go to next page"
|
||||||
|
size="default"
|
||||||
|
className={cn("pr-1.5!", className)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<span className="hidden sm:block">{text}</span>
|
||||||
|
<ChevronRightIcon data-icon="inline-end" />
|
||||||
|
</PaginationLink>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function PaginationEllipsis({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
aria-hidden
|
||||||
|
data-slot="pagination-ellipsis"
|
||||||
|
className={cn(
|
||||||
|
"flex size-8 items-center justify-center [&_svg:not([class*='size-'])]:size-4",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<MoreHorizontalIcon
|
||||||
|
/>
|
||||||
|
<span className="sr-only">More pages</span>
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
Pagination,
|
||||||
|
PaginationContent,
|
||||||
|
PaginationEllipsis,
|
||||||
|
PaginationItem,
|
||||||
|
PaginationLink,
|
||||||
|
PaginationNext,
|
||||||
|
PaginationPrevious,
|
||||||
|
}
|
||||||
93
frontend/src/components/ui/popover.jsx
Normal file
93
frontend/src/components/ui/popover.jsx
Normal file
@ -0,0 +1,93 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
import { Popover as PopoverPrimitive } from "@base-ui/react/popover"
|
||||||
|
import { cn } from "cn"
|
||||||
|
|
||||||
|
function Popover({
|
||||||
|
...props
|
||||||
|
}) {
|
||||||
|
return <PopoverPrimitive.Root data-slot="popover" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function PopoverTrigger({
|
||||||
|
...props
|
||||||
|
}) {
|
||||||
|
return <PopoverPrimitive.Trigger data-slot="popover-trigger" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function PopoverContent({
|
||||||
|
className,
|
||||||
|
align = "center",
|
||||||
|
alignOffset = 0,
|
||||||
|
side = "bottom",
|
||||||
|
sideOffset = 4,
|
||||||
|
...props
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<PopoverPrimitive.Portal>
|
||||||
|
<PopoverPrimitive.Positioner
|
||||||
|
align={align}
|
||||||
|
alignOffset={alignOffset}
|
||||||
|
side={side}
|
||||||
|
sideOffset={sideOffset}
|
||||||
|
className="isolate z-50"
|
||||||
|
>
|
||||||
|
<PopoverPrimitive.Popup
|
||||||
|
data-slot="popover-content"
|
||||||
|
className={cn(
|
||||||
|
"z-50 flex w-72 origin-(--transform-origin) flex-col gap-2.5 rounded-lg bg-popover p-2.5 text-sm text-popover-foreground shadow-md ring-1 ring-foreground/10 outline-hidden duration-100 data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-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 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</PopoverPrimitive.Positioner>
|
||||||
|
</PopoverPrimitive.Portal>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function PopoverHeader({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="popover-header"
|
||||||
|
className={cn("flex flex-col gap-0.5 text-sm", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function PopoverTitle({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<PopoverPrimitive.Title
|
||||||
|
data-slot="popover-title"
|
||||||
|
className={cn("font-medium", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function PopoverDescription({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<PopoverPrimitive.Description
|
||||||
|
data-slot="popover-description"
|
||||||
|
className={cn("text-muted-foreground", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
Popover,
|
||||||
|
PopoverContent,
|
||||||
|
PopoverDescription,
|
||||||
|
PopoverHeader,
|
||||||
|
PopoverTitle,
|
||||||
|
PopoverTrigger,
|
||||||
|
}
|
||||||
91
frontend/src/components/ui/progress.jsx
Normal file
91
frontend/src/components/ui/progress.jsx
Normal file
@ -0,0 +1,91 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { Progress as ProgressPrimitive } from "@base-ui/react/progress"
|
||||||
|
import { cn } from "cn"
|
||||||
|
|
||||||
|
function Progress({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
value,
|
||||||
|
...props
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<ProgressPrimitive.Root
|
||||||
|
value={value}
|
||||||
|
data-slot="progress"
|
||||||
|
className={cn("flex flex-wrap gap-3", className)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
<ProgressTrack>
|
||||||
|
<ProgressIndicator />
|
||||||
|
</ProgressTrack>
|
||||||
|
</ProgressPrimitive.Root>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ProgressTrack({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<ProgressPrimitive.Track
|
||||||
|
className={cn(
|
||||||
|
"relative flex h-1 w-full items-center overflow-x-hidden rounded-full bg-muted",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
data-slot="progress-track"
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ProgressIndicator({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<ProgressPrimitive.Indicator
|
||||||
|
data-slot="progress-indicator"
|
||||||
|
className={cn("h-full bg-primary transition-all", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ProgressLabel({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<ProgressPrimitive.Label
|
||||||
|
className={cn("text-sm font-medium", className)}
|
||||||
|
data-slot="progress-label"
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ProgressValue({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<ProgressPrimitive.Value
|
||||||
|
className={cn(
|
||||||
|
"ml-auto text-sm text-muted-foreground tabular-nums",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
data-slot="progress-value"
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
Progress,
|
||||||
|
ProgressTrack,
|
||||||
|
ProgressIndicator,
|
||||||
|
ProgressLabel,
|
||||||
|
ProgressValue,
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user