This commit is contained in:
Louis Mazin 2026-10-02 17:10:37 +02:00
parent a9477ec881
commit 6a18a3caff
137 changed files with 20408 additions and 1169 deletions

44
.gitignore vendored
View File

@ -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
View File

@ -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
View 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.

View File

@ -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
View 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()

View File

@ -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}"))

View File

@ -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")

View File

@ -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")),
]

View File

@ -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

View File

@ -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
View 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')

View File

@ -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)),
] ]

View File

@ -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
View 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()

View File

@ -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
from django.core.management import execute_from_command_line
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
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()

View File

@ -1,2 +0,0 @@
import pymysql
pymysql.install_as_MySQLdb()

6
backend/planning/apps.py Normal file
View File

@ -0,0 +1,6 @@
from django.apps import AppConfig
class PlanningConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'planning'

View 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'),
),
]

View File

@ -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')]

View 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

View 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)

View 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

View File

@ -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
View 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)

View File

@ -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
View 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)

View File

@ -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()

View File

@ -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
View File

@ -0,0 +1,6 @@
from django.apps import AppConfig
class UsersConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'users'

View 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}.'))

View File

6
backend/users/urls.py Normal file
View 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
View 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
View 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
View 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": {}
}

View File

@ -1,13 +1,13 @@
<!doctype html> <!doctype html>
<html lang="fr"> <html lang="en">
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <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="theme-color" content="#0f172a" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Planning</title> <title>Planning de Louis</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
View File

@ -0,0 +1,8 @@
{
"compilerOptions": {
"paths": {
"@/*": ["./src/*"]
}
},
"include": ["src"]
}

View File

@ -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

File diff suppressed because it is too large Load Diff

View File

@ -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"
} }
} }

View File

@ -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 (
<div className="flex min-h-screen items-center justify-center bg-slate-50 dark:bg-slate-950">
<div className="flex items-center gap-3 text-sm text-slate-500">
<span className="size-5 animate-spin rounded-full border-2 border-slate-300 border-t-amber-500" />
Chargement…
</div>
</div>
);
}
return state.ok ? children : <Navigate to="/admin" replace state={{ from: location.pathname }} />;
}
function AdminRoute({ children }) {
return ( return (
<RequireAdmin> <div className="flex shrink-0 items-center justify-end gap-2 px-2 py-2">
<AdminLayout>{children}</AdminLayout> <button
</RequireAdmin> type="button"
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>
); );
} }
export default function App() { function App() {
return ( return (
<Routes> <SidebarProvider>
<Route path="/" element={<PublicPage />} /> <NavBar />
<Route path="/request/:token" element={<PublicPage />} /> <SidebarInset className="flex h-svh flex-col overflow-hidden">
<Route path="/admin" element={<AdminLoginPage />} /> <MobileSidebarTrigger />
<Route path="/admin/planning" element={<AdminRoute><AdminPlanningPage /></AdminRoute>} /> <main className="min-h-0 flex-1 overflow-auto">
<Route path="/admin/requests" element={<AdminRoute><AdminRequestsPage /></AdminRoute>} /> <div className="min-h-full w-full">
<Route path="*" element={<Navigate to="/" replace />} /> <Routes>
</Routes> <Route path="/" element={<PublicPage />} />
<Route path="/request/:token" element={<PublicPage />} />
<Route
path="/login"
element={
<UnloggedRoute>
<LoginPage />
</UnloggedRoute>
}
/>
<Route
path="/planning"
element={
<AdminRoute>
<AdminPlanningPage />
</AdminRoute>
}
/>
<Route
path="/requests"
element={
<AdminRoute>
<AdminRequestsPage />
</AdminRoute>
}
/>
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
</div>
</main>
<footer className="site-footer shrink-0 px-3 py-2">
<p className="text-right text-sm text-muted-foreground">&copy; 2026 Planning Louis.</p>
</footer>
</SidebarInset>
</SidebarProvider>
); );
} }
export default App;

83
frontend/src/api/axios.js Normal file
View 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;

View File

@ -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;

View 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;
}

View File

@ -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,
};

View 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,
};

View 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;

View 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;

View 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;

View 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;

View File

@ -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>
); );

View 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;

View File

@ -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>;
}

View File

@ -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>}

View 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 }

View 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,
}

View 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 }

View 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 }

View 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,
}

View 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,
}

View 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 }

View 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,
}

View 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 }

View 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,
}

View 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 }

View 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 }

View 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,
}

View 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 };

View 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,
}

View 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 }

View 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 }

View 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,
}

View 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,
}

View 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,
}

View 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,
}

View File

@ -0,0 +1,4 @@
export {
DirectionProvider,
useDirection,
} from "@base-ui/react/direction-provider"

View 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,
}

View 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,
}

View 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,
}

View 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,
}

View 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 }

View 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,
}

View 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 }

View 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 }

View 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,
}

View 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 }

View 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 }

View 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 }

View 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,
}

View 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,
}

View 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,
}

View 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 }

View 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,
}

View 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,
}

View 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,
}

View 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