This commit is contained in:
Vitrixxl 2025-10-14 16:11:46 +02:00
commit 07ac6c422f
48 changed files with 8342 additions and 0 deletions

7
backend/.env Normal file
View File

@ -0,0 +1,7 @@
DB_NAME=immonator_test
DB_PORT=5433
DB_HOST=34.135.132.141
DB_USERNAME=django
DB_PASSWORD=T17HE59a7Xlu2lBgPUQ9rdCPTe4159UNedGhcOvrTu8=
API_TOKEN=B0uKZQN+qIsLc0yNR/t9xCOkgP6Keg0oarLUiZkO2Mo=

486
backend/api_summary.json Normal file
View File

@ -0,0 +1,486 @@
{
"api_meta": {
"name": "Managinator Backend API",
"overview": "Bearer-protected Flask service for managing investment profiles, users, and scrapers.",
"base_url_example": "http://localhost:8000",
"default_headers": {
"Content-Type": "application/json"
},
"cors": {
"allow_origin": "*",
"allow_credentials": true,
"notes": "Server responds to OPTIONS preflight without authentication and accepts credentialed requests from any origin."
}
},
"authentication": {
"strategy": "bearer_token",
"header": "Authorization",
"format": "Bearer <API_TOKEN>",
"env_variable": "API_TOKEN",
"notes": [
"Every request (except OPTIONS and Flask static assets) must carry the bearer token in the Authorization header.",
"There is no login endpoint; the expected token value is configured server-side via the API_TOKEN environment variable.",
"Missing, malformed, or incorrect bearer tokens return HTTP 401."
]
},
"conventions": {
"datetime_format": "Use ISO 8601 strings with timezone. Responses normalize to UTC with trailing Z.",
"boolean_input": "Send JSON booleans true/false. The backend also accepts 1/0 and common string equivalents, but clients should prefer booleans.",
"integer_input": "Integer fields accept JSON numbers. Numeric strings will be coerced server-side but should be avoided.",
"null_handling": "Fields documented as nullable accept JSON null and will persist as SQL NULL.",
"error_responses": {
"typical_status_codes": [400, 401, 404, 409, 503],
"body_format": "Flask default HTTP error responses; body may be plain text or HTML containing the description. Rely on HTTP status and message."
}
},
"schemas": {
"Profile": {
"description": "Investment profile descriptor persisted in users_investmentprofile.",
"fields": [
{"name": "profile_id", "type": "uuid", "nullable": false, "read_only": false, "description": "Primary key. Optional on create; autogenerated when omitted."},
{"name": "name", "type": "string", "nullable": false, "read_only": false, "description": "Human-readable profile label."},
{"name": "description", "type": "string", "nullable": true, "read_only": false, "description": "Optional free-form description. Empty string preserved."},
{"name": "criteria", "type": "json", "nullable": false, "read_only": false, "description": "Arbitrary JSON object/array describing selection rules."},
{"name": "created_at", "type": "datetime", "nullable": false, "read_only": false, "description": "Creation timestamp. Defaults to current UTC when omitted."},
{"name": "is_active", "type": "boolean", "nullable": false, "read_only": false, "description": "Activation flag. Defaults to true on create."}
]
},
"User": {
"description": "Auth user record persisted in auth_user.",
"fields": [
{"name": "id", "type": "integer", "nullable": false, "read_only": true, "description": "Database primary key."},
{"name": "username", "type": "string", "nullable": false, "read_only": false, "description": "Unique login name."},
{"name": "password", "type": "string", "nullable": false, "read_only": false, "description": "Raw password value inserted as-is. Not returned in responses."},
{"name": "first_name", "type": "string", "nullable": false, "read_only": false, "description": "User first name."},
{"name": "last_name", "type": "string", "nullable": false, "read_only": false, "description": "User last name."},
{"name": "email", "type": "string", "nullable": false, "read_only": false, "description": "User email address."},
{"name": "is_superuser", "type": "boolean", "nullable": false, "read_only": false, "description": "Administrative superuser flag."},
{"name": "is_staff", "type": "boolean", "nullable": false, "read_only": false, "description": "Staff membership flag."},
{"name": "is_active", "type": "boolean", "nullable": false, "read_only": false, "description": "Account enabled flag."},
{"name": "date_joined", "type": "datetime", "nullable": false, "read_only": false, "description": "Account creation timestamp. Defaults to current UTC when omitted."},
{"name": "last_login", "type": "datetime", "nullable": true, "read_only": false, "description": "Most recent login timestamp."}
]
},
"Scraper": {
"description": "Scraper configuration persisted in scraper table.",
"fields": [
{"name": "id", "type": "string", "nullable": false, "read_only": false, "description": "Primary key identifier."},
{"name": "params", "type": "string", "nullable": true, "read_only": false, "description": "Serialized scraper parameters."},
{"name": "last_seen_days", "type": "integer", "nullable": true, "read_only": false, "description": "Number of days since listings were last seen."},
{"name": "first_seen_days", "type": "integer", "nullable": true, "read_only": false, "description": "Number of days since listings were first seen."},
{"name": "frequency", "type": "string", "nullable": true, "read_only": false, "description": "Cron or human-readable frequency descriptor."},
{"name": "task_name", "type": "string", "nullable": true, "read_only": false, "description": "Associated task identifier."},
{"name": "enabled", "type": "integer", "nullable": true, "read_only": false, "description": "1/0 flag for activation. Stored as integer."},
{"name": "property_types", "type": "string", "nullable": true, "read_only": false, "description": "Comma-separated property type filters."},
{"name": "page_size", "type": "integer", "nullable": true, "read_only": false, "description": "Listings fetched per page."},
{"name": "max_pages", "type": "integer", "nullable": true, "read_only": false, "description": "Maximum number of pages to crawl."},
{"name": "enrich_llm", "type": "integer", "nullable": true, "read_only": false, "description": "Toggle for LLM enrichment (1 enabled, 0 disabled)."},
{"name": "only_match", "type": "integer", "nullable": true, "read_only": false, "description": "Toggle for strict matching (1 enabled, 0 disabled)."}
]
}
},
"endpoints": [
{
"operation_id": "listProfiles",
"method": "GET",
"path": "/profiles",
"authenticated": true,
"description": "List all investment profiles ordered by most recent creation.",
"request": {
"headers": {
"Authorization": {"type": "string", "required": true, "description": "Bearer <API_TOKEN>"}
}
},
"responses": {
"200": {"description": "Array of profiles.", "body": {"type": "array", "items": {"$ref": "#/schemas/Profile"}}},
"401": {"description": "Missing or invalid bearer token."},
"503": {"description": "Database unavailable."}
}
},
{
"operation_id": "getProfile",
"method": "GET",
"path": "/profiles/<profile_id>",
"authenticated": true,
"description": "Retrieve a single profile by UUID.",
"request": {
"headers": {
"Authorization": {"type": "string", "required": true, "description": "Bearer <API_TOKEN>"}
},
"path_params": [
{"name": "profile_id", "type": "uuid", "required": true, "description": "Profile identifier."}
]
},
"responses": {
"200": {"description": "Profile payload.", "body": {"$ref": "#/schemas/Profile"}},
"401": {"description": "Missing or invalid bearer token."},
"404": {"description": "Profile not found."},
"503": {"description": "Database unavailable."}
}
},
{
"operation_id": "createProfile",
"method": "POST",
"path": "/profiles",
"authenticated": true,
"description": "Create a new investment profile.",
"request": {
"headers": {
"Content-Type": {"type": "string", "required": true, "example": "application/json"},
"Authorization": {"type": "string", "required": true, "description": "Bearer <API_TOKEN>"}
},
"body": {
"type": "object",
"required_fields": ["name", "criteria"],
"fields": {
"profile_id": {"type": "uuid", "nullable": false, "description": "Optional explicit UUID. Auto-generated when omitted."},
"name": {"type": "string", "nullable": false, "description": "Profile name."},
"description": {"type": "string", "nullable": true, "description": "Optional description. Use null to clear."},
"criteria": {"type": "json", "nullable": false, "description": "Required JSON criteria payload."},
"created_at": {"type": "datetime", "nullable": false, "description": "Optional creation timestamp. Defaults to now (UTC)."},
"is_active": {"type": "boolean", "nullable": false, "description": "Defaults to true."}
}
}
},
"responses": {
"201": {"description": "Profile created.", "body": {"$ref": "#/schemas/Profile"}},
"400": {"description": "Validation failure (missing criteria, invalid field formats, or empty request)."},
"401": {"description": "Missing or invalid bearer token."},
"409": {"description": "Constraint violation (e.g., duplicate profile_id)."},
"503": {"description": "Database unavailable."}
}
},
{
"operation_id": "updateProfile",
"method": "PUT",
"path": "/profiles/<profile_id>",
"authenticated": true,
"description": "Update mutable fields on an existing profile.",
"request": {
"headers": {
"Content-Type": {"type": "string", "required": true, "example": "application/json"},
"Authorization": {"type": "string", "required": true, "description": "Bearer <API_TOKEN>"}
},
"path_params": [
{"name": "profile_id", "type": "uuid", "required": true, "description": "Profile identifier."}
],
"body": {
"type": "object",
"allowed_fields": ["name", "description", "criteria", "created_at", "is_active"],
"min_fields": 1,
"fields": {
"name": {"type": "string", "nullable": false, "description": "New profile name."},
"description": {"type": "string", "nullable": true, "description": "Use null to clear description."},
"criteria": {"type": "json", "nullable": false, "description": "Updated criteria JSON."},
"created_at": {"type": "datetime", "nullable": false, "description": "New creation timestamp."},
"is_active": {"type": "boolean", "nullable": false, "description": "Activation flag."}
}
}
},
"responses": {
"200": {"description": "Profile updated.", "body": {"$ref": "#/schemas/Profile"}},
"400": {"description": "No updatable fields provided or invalid data."},
"401": {"description": "Missing or invalid bearer token."},
"404": {"description": "Profile not found."},
"409": {"description": "Constraint violation."},
"503": {"description": "Database unavailable."}
}
},
{
"operation_id": "deleteProfile",
"method": "DELETE",
"path": "/profiles/<profile_id>",
"authenticated": true,
"description": "Remove a profile by UUID.",
"request": {
"headers": {
"Authorization": {"type": "string", "required": true, "description": "Bearer <API_TOKEN>"}
},
"path_params": [
{"name": "profile_id", "type": "uuid", "required": true, "description": "Profile identifier."}
]
},
"responses": {
"204": {"description": "Profile deleted. Empty body."},
"401": {"description": "Missing or invalid bearer token."},
"404": {"description": "Profile not found."},
"503": {"description": "Database unavailable."}
}
},
{
"operation_id": "listUsers",
"method": "GET",
"path": "/users",
"authenticated": true,
"description": "List all users ordered by ascending id.",
"request": {
"headers": {
"Authorization": {"type": "string", "required": true, "description": "Bearer <API_TOKEN>"}
}
},
"responses": {
"200": {"description": "Array of users.", "body": {"type": "array", "items": {"$ref": "#/schemas/User"}}},
"401": {"description": "Missing or invalid bearer token."},
"503": {"description": "Database unavailable."}
}
},
{
"operation_id": "getUser",
"method": "GET",
"path": "/users/<user_id>",
"authenticated": true,
"description": "Retrieve a user by numeric id.",
"request": {
"headers": {
"Authorization": {"type": "string", "required": true, "description": "Bearer <API_TOKEN>"}
},
"path_params": [
{"name": "user_id", "type": "integer", "required": true, "description": "User primary key."}
]
},
"responses": {
"200": {"description": "User payload.", "body": {"$ref": "#/schemas/User"}},
"401": {"description": "Missing or invalid bearer token."},
"404": {"description": "User not found."},
"503": {"description": "Database unavailable."}
}
},
{
"operation_id": "createUser",
"method": "POST",
"path": "/users",
"authenticated": true,
"description": "Create a new user record.",
"request": {
"headers": {
"Content-Type": {"type": "string", "required": true, "example": "application/json"},
"Authorization": {"type": "string", "required": true, "description": "Bearer <API_TOKEN>"}
},
"body": {
"type": "object",
"required_fields": ["password", "username", "first_name", "last_name", "email"],
"fields": {
"password": {"type": "string", "nullable": false, "description": "Password to store. No hashing performed."},
"username": {"type": "string", "nullable": false, "description": "Unique username."},
"first_name": {"type": "string", "nullable": false, "description": "First name."},
"last_name": {"type": "string", "nullable": false, "description": "Last name."},
"email": {"type": "string", "nullable": false, "description": "Email address."},
"is_superuser": {"type": "boolean", "nullable": false, "description": "Defaults to false when omitted."},
"is_staff": {"type": "boolean", "nullable": false, "description": "Defaults to false when omitted."},
"is_active": {"type": "boolean", "nullable": false, "description": "Defaults to true when omitted."},
"date_joined": {"type": "datetime", "nullable": true, "description": "Optional. Defaults to current UTC when omitted or null."},
"last_login": {"type": "datetime", "nullable": true, "description": "Optional last login timestamp."}
}
}
},
"responses": {
"201": {"description": "User created.", "body": {"$ref": "#/schemas/User"}},
"400": {"description": "Validation failure."},
"401": {"description": "Missing or invalid bearer token."},
"409": {"description": "Constraint violation (e.g., duplicate username or email)."},
"503": {"description": "Database unavailable."}
}
},
{
"operation_id": "updateUser",
"method": "PUT",
"path": "/users/<user_id>",
"authenticated": true,
"description": "Update mutable fields on a user.",
"request": {
"headers": {
"Content-Type": {"type": "string", "required": true, "example": "application/json"},
"Authorization": {"type": "string", "required": true, "description": "Bearer <API_TOKEN>"}
},
"path_params": [
{"name": "user_id", "type": "integer", "required": true, "description": "User primary key."}
],
"body": {
"type": "object",
"allowed_fields": ["password", "username", "first_name", "last_name", "email", "is_superuser", "is_staff", "is_active", "date_joined", "last_login"],
"min_fields": 1,
"fields": {
"password": {"type": "string", "nullable": false, "description": "New raw password."},
"username": {"type": "string", "nullable": false, "description": "New username."},
"first_name": {"type": "string", "nullable": false, "description": "New first name."},
"last_name": {"type": "string", "nullable": false, "description": "New last name."},
"email": {"type": "string", "nullable": false, "description": "New email."},
"is_superuser": {"type": "boolean", "nullable": false, "description": "Updated admin flag."},
"is_staff": {"type": "boolean", "nullable": false, "description": "Updated staff flag."},
"is_active": {"type": "boolean", "nullable": false, "description": "Updated active flag."},
"date_joined": {"type": "datetime", "nullable": false, "description": "Updated join timestamp."},
"last_login": {"type": "datetime", "nullable": true, "description": "Updated last login timestamp."}
}
}
},
"responses": {
"200": {"description": "User updated.", "body": {"$ref": "#/schemas/User"}},
"400": {"description": "No updatable fields provided or invalid data."},
"401": {"description": "Missing or invalid bearer token."},
"404": {"description": "User not found."},
"409": {"description": "Constraint violation."},
"503": {"description": "Database unavailable."}
}
},
{
"operation_id": "deleteUser",
"method": "DELETE",
"path": "/users/<user_id>",
"authenticated": true,
"description": "Delete a user by id.",
"request": {
"headers": {
"Authorization": {"type": "string", "required": true, "description": "Bearer <API_TOKEN>"}
},
"path_params": [
{"name": "user_id", "type": "integer", "required": true, "description": "User primary key."}
]
},
"responses": {
"204": {"description": "User deleted. Empty body."},
"401": {"description": "Missing or invalid bearer token."},
"404": {"description": "User not found."},
"503": {"description": "Database unavailable."}
}
},
{
"operation_id": "listScrapers",
"method": "GET",
"path": "/scrapers",
"authenticated": true,
"description": "List all scraper configurations ordered by id.",
"request": {
"headers": {
"Authorization": {"type": "string", "required": true, "description": "Bearer <API_TOKEN>"}
}
},
"responses": {
"200": {"description": "Array of scrapers.", "body": {"type": "array", "items": {"$ref": "#/schemas/Scraper"}}},
"401": {"description": "Missing or invalid bearer token."},
"503": {"description": "Database unavailable."}
}
},
{
"operation_id": "getScraper",
"method": "GET",
"path": "/scrapers/<scraper_id>",
"authenticated": true,
"description": "Retrieve a single scraper by text id.",
"request": {
"headers": {
"Authorization": {"type": "string", "required": true, "description": "Bearer <API_TOKEN>"}
},
"path_params": [
{"name": "scraper_id", "type": "string", "required": true, "description": "Scraper identifier."}
]
},
"responses": {
"200": {"description": "Scraper payload.", "body": {"$ref": "#/schemas/Scraper"}},
"401": {"description": "Missing or invalid bearer token."},
"404": {"description": "Scraper not found."},
"503": {"description": "Database unavailable."}
}
},
{
"operation_id": "createScraper",
"method": "POST",
"path": "/scrapers",
"authenticated": true,
"description": "Create a scraper configuration.",
"request": {
"headers": {
"Content-Type": {"type": "string", "required": true, "example": "application/json"},
"Authorization": {"type": "string", "required": true, "description": "Bearer <API_TOKEN>"}
},
"body": {
"type": "object",
"required_fields": ["id"],
"fields": {
"id": {"type": "string", "nullable": false, "description": "Scraper primary key."},
"params": {"type": "string", "nullable": true, "description": "Serialized parameter payload."},
"frequency": {"type": "string", "nullable": true, "description": "Execution cadence descriptor."},
"task_name": {"type": "string", "nullable": true, "description": "Linked task name."},
"property_types": {"type": "string", "nullable": true, "description": "Comma-separated property types."},
"last_seen_days": {"type": "integer", "nullable": true, "description": "Days since listing last seen."},
"first_seen_days": {"type": "integer", "nullable": true, "description": "Days since listing first seen."},
"page_size": {"type": "integer", "nullable": true, "description": "Fetch size per page."},
"max_pages": {"type": "integer", "nullable": true, "description": "Maximum crawl pages."},
"enabled": {"type": "integer", "nullable": true, "description": "Activation flag (1 or 0)."},
"enrich_llm": {"type": "integer", "nullable": true, "description": "LLM enrichment toggle (1 or 0)."},
"only_match": {"type": "integer", "nullable": true, "description": "Strict matching toggle (1 or 0)."}
}
}
},
"responses": {
"201": {"description": "Scraper created.", "body": {"$ref": "#/schemas/Scraper"}},
"400": {"description": "Validation failure."},
"401": {"description": "Missing or invalid bearer token."},
"409": {"description": "Constraint violation (e.g., duplicate id)."},
"503": {"description": "Database unavailable."}
}
},
{
"operation_id": "updateScraper",
"method": "PUT",
"path": "/scrapers/<scraper_id>",
"authenticated": true,
"description": "Update mutable fields on a scraper configuration.",
"request": {
"headers": {
"Content-Type": {"type": "string", "required": true, "example": "application/json"},
"Authorization": {"type": "string", "required": true, "description": "Bearer <API_TOKEN>"}
},
"path_params": [
{"name": "scraper_id", "type": "string", "required": true, "description": "Scraper identifier."}
],
"body": {
"type": "object",
"allowed_fields": ["params", "frequency", "task_name", "property_types", "last_seen_days", "first_seen_days", "page_size", "max_pages", "enabled", "enrich_llm", "only_match"],
"min_fields": 1,
"fields": {
"params": {"type": "string", "nullable": true, "description": "Updated serialized parameters."},
"frequency": {"type": "string", "nullable": true, "description": "Updated cadence descriptor."},
"task_name": {"type": "string", "nullable": true, "description": "Updated task name."},
"property_types": {"type": "string", "nullable": true, "description": "Updated property type list."},
"last_seen_days": {"type": "integer", "nullable": true, "description": "Updated last seen days."},
"first_seen_days": {"type": "integer", "nullable": true, "description": "Updated first seen days."},
"page_size": {"type": "integer", "nullable": true, "description": "Updated page size."},
"max_pages": {"type": "integer", "nullable": true, "description": "Updated max pages."},
"enabled": {"type": "integer", "nullable": true, "description": "Updated enabled flag."},
"enrich_llm": {"type": "integer", "nullable": true, "description": "Updated enrichment flag."},
"only_match": {"type": "integer", "nullable": true, "description": "Updated only match flag."}
}
}
},
"responses": {
"200": {"description": "Scraper updated.", "body": {"$ref": "#/schemas/Scraper"}},
"400": {"description": "No updatable fields provided or invalid data."},
"401": {"description": "Missing or invalid bearer token."},
"404": {"description": "Scraper not found."},
"409": {"description": "Constraint violation."},
"503": {"description": "Database unavailable."}
}
},
{
"operation_id": "deleteScraper",
"method": "DELETE",
"path": "/scrapers/<scraper_id>",
"authenticated": true,
"description": "Delete a scraper configuration.",
"request": {
"headers": {
"Authorization": {"type": "string", "required": true, "description": "Bearer <API_TOKEN>"}
},
"path_params": [
{"name": "scraper_id", "type": "string", "required": true, "description": "Scraper identifier."}
]
},
"responses": {
"204": {"description": "Scraper deleted. Empty body."},
"401": {"description": "Missing or invalid bearer token."},
"404": {"description": "Scraper not found."},
"503": {"description": "Database unavailable."}
}
}
]
}

723
backend/app.py Normal file
View File

@ -0,0 +1,723 @@
"""Minimal Flask API with token-based authentication middleware."""
from __future__ import annotations
import os
from datetime import datetime, timezone
from typing import Any, Dict, Iterable, List, Mapping, MutableMapping
from uuid import UUID, uuid4
import psycopg
from psycopg import sql
from psycopg.rows import dict_row
from dotenv import load_dotenv
from flask import Flask, abort, jsonify, request, g
from flask_cors import CORS
load_dotenv()
API_TOKEN = os.getenv("API_TOKEN")
REQUIRED_DB_SETTINGS = {
"DB_NAME": os.getenv("DB_NAME", ""),
"DB_HOST": os.getenv("DB_HOST", ""),
"DB_PORT": os.getenv("DB_PORT", ""),
"DB_USERNAME": os.getenv("DB_USERNAME", ""),
"DB_PASSWORD": os.getenv("DB_PASSWORD", ""),
}
missing_db_settings = [
name for name, value in REQUIRED_DB_SETTINGS.items() if not value
]
if missing_db_settings:
missing = ", ".join(missing_db_settings)
raise RuntimeError(
f"Database configuration missing for: {missing}. Did you configure the .env file?"
)
DB_NAME = REQUIRED_DB_SETTINGS["DB_NAME"]
DB_HOST = REQUIRED_DB_SETTINGS["DB_HOST"]
DB_USERNAME = REQUIRED_DB_SETTINGS["DB_USERNAME"]
DB_PASSWORD = REQUIRED_DB_SETTINGS["DB_PASSWORD"]
try:
DB_PORT = int(REQUIRED_DB_SETTINGS["DB_PORT"])
except ValueError as exc:
raise RuntimeError("DB_PORT must be an integer") from exc
USER_TABLE = os.getenv("DB_TABLE_USERS", "auth_user")
INVESTMENT_PROFILE_TABLE = os.getenv(
"DB_TABLE_INVESTMENT_PROFILES", "users_investmentprofile"
)
SCRAPER_TABLE = os.getenv("DB_TABLE_SCRAPERS", "scraper")
if not API_TOKEN:
raise RuntimeError(
"API_TOKEN missing from environment. Did you configure the .env file?"
)
app = Flask(__name__)
CORS(app, resources={r"/*": {"origins": "*"}}, supports_credentials=True)
def get_db_connection() -> psycopg.Connection:
connection = psycopg.connect(
dbname=DB_NAME,
user=DB_USERNAME,
password=DB_PASSWORD,
host=DB_HOST,
port=DB_PORT,
row_factory=dict_row,
)
connection.autocommit = True
return connection
def get_db() -> psycopg.Connection:
if "db_connection" not in g:
try:
g.db_connection = get_db_connection()
except psycopg.OperationalError:
abort(503, description="Database connection failed")
return g.db_connection
@app.teardown_appcontext
def close_db_connection(_: BaseException | None) -> None:
db_connection = g.pop("db_connection", None)
if db_connection is not None:
db_connection.close()
def _get_json_body() -> MutableMapping[str, Any]:
payload = request.get_json(silent=True)
if not isinstance(payload, MutableMapping):
abort(400, description="Request body must be a JSON object")
return payload
def _parse_bool(value: Any, field_name: str) -> bool:
if isinstance(value, bool):
return value
if isinstance(value, str):
lowered = value.strip().lower()
if lowered in {"true", "1", "yes", "y"}:
return True
if lowered in {"false", "0", "no", "n"}:
return False
if isinstance(value, (int, float)):
if value in {0, 1}:
return bool(value)
abort(400, description=f"Field '{field_name}' must be a boolean value")
def _parse_datetime(value: Any, field_name: str) -> datetime | None:
if value is None:
return None
if isinstance(value, datetime):
dt = value
elif isinstance(value, str):
try:
normalized = value.replace("Z", "+00:00")
dt = datetime.fromisoformat(normalized)
except ValueError:
abort(
400,
description=f"Field '{field_name}' must be a valid ISO 8601 datetime",
)
else:
abort(
400, description=f"Field '{field_name}' must be a valid ISO 8601 datetime"
)
if dt.tzinfo is None:
dt = dt.replace(tzinfo=timezone.utc)
else:
dt = dt.astimezone(timezone.utc)
return dt
def _parse_string(value: Any, field_name: str, *, allow_empty: bool = False) -> str:
if not isinstance(value, str):
abort(400, description=f"Field '{field_name}' must be a string")
stripped = value.strip()
if not allow_empty and not stripped:
abort(400, description=f"Field '{field_name}' cannot be empty")
return stripped if not allow_empty else value
def _isoformat(dt: datetime | None) -> str | None:
if dt is None:
return None
if dt.tzinfo is None:
dt = dt.replace(tzinfo=timezone.utc)
return dt.astimezone(timezone.utc).isoformat().replace("+00:00", "Z")
def _ensure_json_compatible(value: Any, field_name: str) -> Any:
if isinstance(value, (dict, list, str, int, float, bool)) or value is None:
return value
abort(400, description=f"Field '{field_name}' must be valid JSON data")
def _parse_int(value: Any, field_name: str) -> int:
if isinstance(value, int):
return value
if isinstance(value, str):
try:
return int(value, 10)
except ValueError:
abort(400, description=f"Field '{field_name}' must be an integer")
abort(400, description=f"Field '{field_name}' must be an integer")
def _parse_uuid(value: Any, field_name: str) -> UUID:
if isinstance(value, UUID):
return value
if isinstance(value, str):
try:
return UUID(value)
except ValueError:
abort(400, description=f"Field '{field_name}' must be a valid UUID")
abort(400, description=f"Field '{field_name}' must be a valid UUID")
def _require_bearer_token(header_value: str | None) -> str:
if not header_value:
abort(401, description="Missing bearer token")
parts = header_value.strip().split()
if len(parts) != 2 or parts[0].lower() != "bearer":
abort(401, description="Authorization header must be 'Bearer <token>'")
token = parts[1].strip()
if not token:
abort(401, description="Authorization header must include a token")
return token
def _serialize_row(
row: Mapping[str, Any], *, datetime_fields: Iterable[str] | None = None
) -> Dict[str, Any]:
result: Dict[str, Any] = dict(row)
for field in datetime_fields or ():
result[field] = _isoformat(result.get(field))
for field, value in list(result.items()):
if isinstance(value, UUID):
result[field] = str(value)
return result
def _fetch_one(
query: sql.Composed, params: Mapping[str, Any]
) -> Mapping[str, Any] | None:
conn = get_db()
with conn.cursor() as cur:
cur.execute(query, params)
return cur.fetchone()
def _fetch_all(
query: sql.Composed, params: Mapping[str, Any] | None = None
) -> List[Mapping[str, Any]]:
conn = get_db()
with conn.cursor() as cur:
cur.execute(query, params or {})
return cur.fetchall()
def _execute(query: sql.Composed, params: Mapping[str, Any]) -> None:
conn = get_db()
with conn.cursor() as cur:
cur.execute(query, params)
def _columns_sql(columns: Iterable[str]) -> sql.Composed:
return sql.SQL(", ").join(sql.Identifier(column) for column in columns)
def _placeholders(columns: Iterable[str]) -> sql.Composed:
return sql.SQL(", ").join(sql.Placeholder(column) for column in columns)
def _insert_row(
table: str, data: Mapping[str, Any], returning: Iterable[str]
) -> Mapping[str, Any]:
if not data:
raise ValueError("Cannot insert without data")
query = sql.SQL(
"INSERT INTO {table} ({columns}) VALUES ({values}) RETURNING {returning}"
).format(
table=sql.Identifier(table),
columns=_columns_sql(data.keys()),
values=_placeholders(data.keys()),
returning=_columns_sql(returning),
)
row = _fetch_one(query, data)
if row is None:
raise RuntimeError("Insert statement did not return a row")
return row
def _update_row(
table: str,
identifier_column: str,
identifier_value: Any,
data: Mapping[str, Any],
returning: Iterable[str],
) -> Mapping[str, Any] | None:
if not data:
raise ValueError("Cannot update without data")
assignments = sql.SQL(", ").join(
sql.SQL("{column} = {placeholder}").format(
column=sql.Identifier(column),
placeholder=sql.Placeholder(column),
)
for column in data.keys()
)
query = sql.SQL(
"UPDATE {table} SET {assignments} WHERE {identifier_column} = {identifier} "
"RETURNING {returning}"
).format(
table=sql.Identifier(table),
assignments=assignments,
identifier_column=sql.Identifier(identifier_column),
identifier=sql.Placeholder("identifier"),
returning=_columns_sql(returning),
)
params: Dict[str, Any] = dict(data)
params["identifier"] = identifier_value
return _fetch_one(query, params)
def _delete_row(table: str, identifier_column: str, identifier_value: Any) -> bool:
query = sql.SQL(
"DELETE FROM {table} WHERE {identifier_column} = {identifier}"
).format(
table=sql.Identifier(table),
identifier_column=sql.Identifier(identifier_column),
identifier=sql.Placeholder("identifier"),
)
conn = get_db()
with conn.cursor() as cur:
cur.execute(query, {"identifier": identifier_value})
return cur.rowcount > 0
def _abort_for_integrity_error(exc: psycopg.IntegrityError) -> None:
detail = getattr(getattr(exc, "diag", None), "message_detail", None)
abort(409, description=detail or "Database constraint violation")
USER_RESPONSE_FIELDS = (
"id",
"username",
"first_name",
"last_name",
"email",
"is_superuser",
"is_staff",
"is_active",
"date_joined",
"last_login",
)
USER_DATETIME_FIELDS = ("date_joined", "last_login")
USER_BOOL_FIELDS = ("is_superuser", "is_staff", "is_active")
PROFILE_RESPONSE_FIELDS = (
"profile_id",
"name",
"description",
"criteria",
"created_at",
"is_active",
)
PROFILE_DATETIME_FIELDS = ("created_at",)
SCRAPER_RESPONSE_FIELDS = (
"id",
"params",
"last_seen_days",
"first_seen_days",
"frequency",
"task_name",
"enabled",
"property_types",
"page_size",
"max_pages",
"enrich_llm",
"only_match",
)
SCRAPER_INT_FIELDS = (
"last_seen_days",
"first_seen_days",
"page_size",
"max_pages",
"enabled",
"enrich_llm",
"only_match",
)
@app.before_request
def enforce_bearer_token() -> None:
if request.method == "OPTIONS":
return
# Allow Flask internals without auth.
if request.endpoint == "static":
return
provided_token = _require_bearer_token(request.headers.get("Authorization"))
if provided_token != API_TOKEN:
abort(401, description="Invalid bearer token")
@app.get("/profiles")
def get_profiles():
rows = _fetch_all(
sql.SQL("SELECT {columns} FROM {table} ORDER BY created_at DESC").format(
columns=_columns_sql(PROFILE_RESPONSE_FIELDS),
table=sql.Identifier(INVESTMENT_PROFILE_TABLE),
)
)
payload = [
_serialize_row(row, datetime_fields=PROFILE_DATETIME_FIELDS) for row in rows
]
return jsonify(payload)
@app.get("/profiles/<profile_id>")
def get_profile(profile_id: str):
profile_uuid = _parse_uuid(profile_id, "profile_id")
row = _fetch_one(
sql.SQL("SELECT {columns} FROM {table} WHERE profile_id = {identifier}").format(
columns=_columns_sql(PROFILE_RESPONSE_FIELDS),
table=sql.Identifier(INVESTMENT_PROFILE_TABLE),
identifier=sql.Placeholder("profile_id"),
),
{"profile_id": profile_uuid},
)
if row is None:
abort(404, description="Profile not found")
return jsonify(_serialize_row(row, datetime_fields=PROFILE_DATETIME_FIELDS))
@app.post("/profiles")
def create_profile():
payload = _get_json_body()
profile_identifier = payload.get("profile_id")
profile_uuid = (
_parse_uuid(profile_identifier, "profile_id") if profile_identifier else uuid4()
)
name = _parse_string(payload.get("name"), "name")
description_value = payload.get("description")
description = (
None
if description_value is None
else _parse_string(description_value, "description", allow_empty=True)
)
criteria_raw = payload.get("criteria")
if criteria_raw is None:
abort(400, description="Field 'criteria' is required")
criteria = _ensure_json_compatible(criteria_raw, "criteria")
created_at_value = payload.get("created_at")
created_at = (
datetime.now(timezone.utc)
if created_at_value is None
else _parse_datetime(created_at_value, "created_at")
)
is_active = _parse_bool(payload.get("is_active", True), "is_active")
data = {
"profile_id": profile_uuid,
"name": name,
"description": description,
"criteria": criteria,
"created_at": created_at,
"is_active": is_active,
}
try:
row = _insert_row(INVESTMENT_PROFILE_TABLE, data, PROFILE_RESPONSE_FIELDS)
except psycopg.IntegrityError as exc:
_abort_for_integrity_error(exc)
return (
jsonify(_serialize_row(row, datetime_fields=PROFILE_DATETIME_FIELDS)),
201,
)
@app.put("/profiles/<profile_id>")
def update_profile(profile_id: str):
profile_uuid = _parse_uuid(profile_id, "profile_id")
payload = _get_json_body()
updates: Dict[str, Any] = {}
if "name" in payload:
updates["name"] = _parse_string(payload["name"], "name")
if "description" in payload:
description_value = payload["description"]
updates["description"] = (
None
if description_value is None
else _parse_string(description_value, "description", allow_empty=True)
)
if "criteria" in payload:
updates["criteria"] = _ensure_json_compatible(payload["criteria"], "criteria")
if "created_at" in payload:
updates["created_at"] = _parse_datetime(payload["created_at"], "created_at")
if "is_active" in payload:
updates["is_active"] = _parse_bool(payload["is_active"], "is_active")
if not updates:
abort(400, description="No updatable fields provided")
try:
row = _update_row(
INVESTMENT_PROFILE_TABLE,
"profile_id",
profile_uuid,
updates,
PROFILE_RESPONSE_FIELDS,
)
except psycopg.IntegrityError as exc:
_abort_for_integrity_error(exc)
if row is None:
abort(404, description="Profile not found")
return jsonify(_serialize_row(row, datetime_fields=PROFILE_DATETIME_FIELDS))
@app.delete("/profiles/<profile_id>")
def delete_profile(profile_id: str):
profile_uuid = _parse_uuid(profile_id, "profile_id")
deleted = _delete_row(INVESTMENT_PROFILE_TABLE, "profile_id", profile_uuid)
if not deleted:
abort(404, description="Profile not found")
return "", 204
@app.get("/users")
def get_users():
rows = _fetch_all(
sql.SQL("SELECT {columns} FROM {table} ORDER BY id").format(
columns=_columns_sql(USER_RESPONSE_FIELDS),
table=sql.Identifier(USER_TABLE),
)
)
payload = [
_serialize_row(row, datetime_fields=USER_DATETIME_FIELDS) for row in rows
]
return jsonify(payload)
@app.get("/users/<int:user_id>")
def get_user(user_id: int):
row = _fetch_one(
sql.SQL("SELECT {columns} FROM {table} WHERE id = {identifier}").format(
columns=_columns_sql(USER_RESPONSE_FIELDS),
table=sql.Identifier(USER_TABLE),
identifier=sql.Placeholder("user_id"),
),
{"user_id": user_id},
)
if row is None:
abort(404, description="User not found")
return jsonify(_serialize_row(row, datetime_fields=USER_DATETIME_FIELDS))
@app.post("/users")
def create_user():
payload = _get_json_body()
user_data: Dict[str, Any] = {}
user_data["password"] = _parse_string(payload.get("password"), "password")
user_data["username"] = _parse_string(payload.get("username"), "username")
user_data["first_name"] = _parse_string(payload.get("first_name"), "first_name")
user_data["last_name"] = _parse_string(payload.get("last_name"), "last_name")
user_data["email"] = _parse_string(payload.get("email"), "email")
user_data["is_superuser"] = _parse_bool(
payload.get("is_superuser", False), "is_superuser"
)
user_data["is_staff"] = _parse_bool(payload.get("is_staff", False), "is_staff")
user_data["is_active"] = _parse_bool(payload.get("is_active", True), "is_active")
user_data["date_joined"] = _parse_datetime(
payload.get("date_joined"), "date_joined"
)
if user_data["date_joined"] is None:
user_data["date_joined"] = datetime.now(timezone.utc)
user_data["last_login"] = _parse_datetime(payload.get("last_login"), "last_login")
try:
row = _insert_row(USER_TABLE, user_data, USER_RESPONSE_FIELDS)
except psycopg.IntegrityError as exc:
_abort_for_integrity_error(exc)
return (
jsonify(_serialize_row(row, datetime_fields=USER_DATETIME_FIELDS)),
201,
)
@app.put("/users/<int:user_id>")
def update_user(user_id: int):
payload = _get_json_body()
updates: Dict[str, Any] = {}
if "password" in payload:
updates["password"] = _parse_string(payload["password"], "password")
if "username" in payload:
updates["username"] = _parse_string(payload["username"], "username")
if "first_name" in payload:
updates["first_name"] = _parse_string(payload["first_name"], "first_name")
if "last_name" in payload:
updates["last_name"] = _parse_string(payload["last_name"], "last_name")
if "email" in payload:
updates["email"] = _parse_string(payload["email"], "email")
for field in USER_BOOL_FIELDS:
if field in payload:
updates[field] = _parse_bool(payload[field], field)
if "date_joined" in payload:
updates["date_joined"] = _parse_datetime(payload["date_joined"], "date_joined")
if "last_login" in payload:
updates["last_login"] = _parse_datetime(payload["last_login"], "last_login")
if not updates:
abort(400, description="No updatable fields provided")
try:
row = _update_row(USER_TABLE, "id", user_id, updates, USER_RESPONSE_FIELDS)
except psycopg.IntegrityError as exc:
_abort_for_integrity_error(exc)
if row is None:
abort(404, description="User not found")
return jsonify(_serialize_row(row, datetime_fields=USER_DATETIME_FIELDS))
@app.delete("/users/<int:user_id>")
def delete_user(user_id: int):
deleted = _delete_row(USER_TABLE, "id", user_id)
if not deleted:
abort(404, description="User not found")
return "", 204
@app.get("/scrapers")
def get_scrapers():
rows = _fetch_all(
sql.SQL("SELECT {columns} FROM {table} ORDER BY id").format(
columns=_columns_sql(SCRAPER_RESPONSE_FIELDS),
table=sql.Identifier(SCRAPER_TABLE),
)
)
return jsonify([dict(row) for row in rows])
@app.get("/scrapers/<scraper_id>")
def get_scraper(scraper_id: str):
row = _fetch_one(
sql.SQL("SELECT {columns} FROM {table} WHERE id = {identifier}").format(
columns=_columns_sql(SCRAPER_RESPONSE_FIELDS),
table=sql.Identifier(SCRAPER_TABLE),
identifier=sql.Placeholder("scraper_id"),
),
{"scraper_id": _parse_string(scraper_id, "id")},
)
if row is None:
abort(404, description="Scraper not found")
return jsonify(dict(row))
@app.post("/scrapers")
def create_scraper():
payload = _get_json_body()
scraper_id = _parse_string(payload.get("id"), "id")
data: Dict[str, Any] = {"id": scraper_id}
for field in ("params", "frequency", "task_name", "property_types"):
if field in payload:
value = payload[field]
data[field] = (
None if value is None else _parse_string(value, field, allow_empty=True)
)
for field in SCRAPER_INT_FIELDS:
if field in payload:
value = payload[field]
data[field] = None if value is None else _parse_int(value, field)
try:
row = _insert_row(SCRAPER_TABLE, data, SCRAPER_RESPONSE_FIELDS)
except psycopg.IntegrityError as exc:
_abort_for_integrity_error(exc)
return jsonify(dict(row)), 201
@app.put("/scrapers/<scraper_id>")
def update_scraper(scraper_id: str):
payload = _get_json_body()
updates: Dict[str, Any] = {}
for field in ("params", "frequency", "task_name", "property_types"):
if field in payload:
value = payload[field]
updates[field] = (
None if value is None else _parse_string(value, field, allow_empty=True)
)
for field in SCRAPER_INT_FIELDS:
if field in payload:
value = payload[field]
updates[field] = None if value is None else _parse_int(value, field)
if not updates:
abort(400, description="No updatable fields provided")
try:
row = _update_row(
SCRAPER_TABLE,
"id",
_parse_string(scraper_id, "id"),
updates,
SCRAPER_RESPONSE_FIELDS,
)
except psycopg.IntegrityError as exc:
_abort_for_integrity_error(exc)
if row is None:
abort(404, description="Scraper not found")
return jsonify(dict(row))
@app.delete("/scrapers/<scraper_id>")
def delete_scraper(scraper_id: str):
deleted = _delete_row(SCRAPER_TABLE, "id", _parse_string(scraper_id, "id"))
if not deleted:
abort(404, description="Scraper not found")
return "", 204
if __name__ == "__main__":
app.run(host="0.0.0.0", port=int(os.getenv("PORT", "8000")), debug=False)

4
backend/requirements.txt Normal file
View File

@ -0,0 +1,4 @@
Flask>=3.0.0,<4.0.0
python-dotenv>=1.0.0,<2.0.0
psycopg[binary]>=3.1,<4.0
Flask-Cors>=4.0.0,<5.0.0

353
backend/search_schema.json Normal file
View File

@ -0,0 +1,353 @@
{
"adverts": [
{
"firstSeenAt": { "max": "date", "min": "date" },
"flxId": ["string"],
"isOnline": "boolean",
"lastSeenAt": { "max": "date", "min": "date" },
"location": {
"city": "string",
"cityCoordinate": {
"location": { "lon": "number", "lat": "number" }
},
"department": "string",
"inseeCode": "string",
"irisCode": "string",
"locationCoordinate": {
"location": { "lon": "number", "lat": "number" }
},
"postalCode": "string"
},
"price": {
"currency": ["CURRENCY_EUR", "CURRENCY_USD"],
"initial": {
"source": {
"flxId": "string",
"url": "string",
"website": "string"
},
"value": { "max": "number", "min": "number" },
"valuePerArea": { "max": "number", "min": "number" }
},
"isAuction": "boolean",
"latest": {
"source": {
"flxId": "string",
"url": "string",
"website": "string"
},
"value": { "max": "number", "min": "number" },
"valuePerArea": { "max": "number", "min": "number" }
},
"scope": ["PRICING_ONE_OFF", "PRICING_MENSUAL"],
"warrantyDeposit": { "max": "number", "min": "number" },
"variation": [
{
"sinceLastModified": { "max": "number", "min": "number" },
"sincePublished": { "max": "number", "min": "number" }
}
]
},
"source": { "flxId": "string", "url": "string", "website": "string" },
"isPro": "boolean",
"seller": [
{
"flxId": "string",
"name": "string",
"siren": "string",
"type": "SELLER_TYPE_UNKNOWN | SELLER_TYPE_AGENCY | SELLER_TYPE_NETWORK"
}
],
"hasAnomaly": "boolean",
"isExclusive": "boolean"
}
],
"habitation": {
"bathroomCount": { "max": "number", "min": "number" },
"bedroomCount": { "max": "number", "min": "number" },
"characteristics": {
"hasAlarm": "boolean",
"hasBalcony": "boolean",
"hasCellar": "boolean",
"hasConcierge": "boolean",
"hasDigicode": "boolean",
"hasFireplace": "boolean",
"hasGarage": "boolean",
"hasGarden": "boolean",
"hasGrenier": "boolean",
"hasInterphone": "boolean",
"hasJacuzzi": "boolean",
"hasLand": "boolean",
"hasLift": "boolean",
"hasMezzanine": "boolean",
"hasParking": "boolean",
"hasPool": "boolean",
"hasTerrace": "boolean",
"hasVisAVis": "boolean",
"isPeaceful": "boolean"
},
"climate": {
"epcClimate": [
"GREENHOUSE_CLASSIFICATION_UNKNOWN",
"GREENHOUSE_CLASSIFICATION_G",
"GREENHOUSE_CLASSIFICATION_F",
"GREENHOUSE_CLASSIFICATION_E",
"GREENHOUSE_CLASSIFICATION_D",
"GREENHOUSE_CLASSIFICATION_C",
"GREENHOUSE_CLASSIFICATION_B",
"GREENHOUSE_CLASSIFICATION_A",
"GREENHOUSE_CLASSIFICATION_NC"
],
"epcClimateScore": { "max": "number", "min": "number" },
"epcEnergy": [
"ENERGY_CLASSIFICATION_UNKNOWN",
"ENERGY_CLASSIFICATION_G",
"ENERGY_CLASSIFICATION_F",
"ENERGY_CLASSIFICATION_E",
"ENERGY_CLASSIFICATION_D",
"ENERGY_CLASSIFICATION_C",
"ENERGY_CLASSIFICATION_B",
"ENERGY_CLASSIFICATION_A",
"ENERGY_CLASSIFICATION_NC"
],
"epcEnergyScore": { "max": "number", "min": "number" },
"epcClimateDate": { "max": "date", "min": "date" },
"epcEnergyDate": { "max": "date", "min": "date" }
},
"features": {
"exposure": [
"EXPOSURE_UNKNOWN",
"EXPOSURE_NORTH",
"EXPOSURE_NORTH_EAST",
"EXPOSURE_EAST",
"EXPOSURE_SOUTH_EAST",
"EXPOSURE_SOUTH",
"EXPOSURE_SOUTH_WEST",
"EXPOSURE_WEST",
"EXPOSURE_NORTH_WEST"
],
"furniture": [
"UNKNOWN_FURNITURE",
"UNFURNISHED",
"PARTIALLY_FURNISHED",
"FULLY_FURNISHED"
],
"propertyFloor": { "max": "number", "min": "number" },
"propertyTotalFloor": { "max": "number", "min": "number" },
"constructionMaterials": ["string"],
"glazingTypes": ["string"],
"hasThroughExposure": "boolean",
"viewOns": ["string"],
"viewTypes": ["string"]
},
"heatTypes": [
"HEAT_TYPE_UNKNOWN",
"HEAT_TYPE_INDIVIDUAL",
"HEAT_TYPE_SHARED",
"HEAT_TYPE_MIX",
"HEAT_TYPE_CITY",
"HEAT_TYPE_CISTERN"
],
"heatTypeDetails": [
"HEAT_DETAIL_UNKNOWN",
"HEAT_DETAIL_FLOOR",
"HEAT_DETAIL_CEILING",
"HEAT_DETAIL_FIREPLACE",
"HEAT_DETAIL_INSERT",
"HEAT_DETAIL_AIR_CON",
"HEAT_DETAIL_REVERSIBLE_AIR_CON",
"HEAT_DETAIL_RADIANTS_TUBES",
"HEAT_DETAIL_RADIATOR",
"HEAT_DETAIL_SHEAHS",
"HEAT_DETAIL_CONVECTOR"
],
"heatings": [
"HEATING_UNKNOWN",
"HEATING_FUEL_OIL",
"HEATING_ELECTRICAL",
"HEATING_GAS",
"HEATING_BIOMASS",
"HEATING_SOLAR",
"HEATING_GEOTHERMAL",
"HEATING_WOOD",
"HEATING_HEAT_PUMP",
"HEATING_PELLET",
"HEATING_CANADIAN_WELL",
"HEATING_COAL"
],
"propertyCondition": [
{
"constructionYear": "number",
"interiorCondition": [
"INTERIOR_CONDITION_UNKNOWN",
"INTERIOR_CONDITION_EXCELLENT",
"INTERIOR_CONDITION_TO_REFRESH",
"INTERIOR_CONDITION_SMALL_WORKS_TO_BE_PLANNED",
"INTERIOR_CONDITION_MAJOR_WORKS_TO_BE_PALLNED",
"INTERIOR_CONDITION_BRAND_NEW",
"INTERIOR_CONDITION_GOOD_CONDITION",
"INTERIOR_CONDITION_TO_BE_RENOVATED"
],
"renovationYear": "number",
"generalConditions": ["string"]
}
],
"roomCount": { "max": "number", "min": "number" },
"surface": {
"balconies": { "max": "number", "min": "number" },
"floorSpace": { "max": "number", "min": "number" },
"gardens": { "max": "number", "min": "number" },
"groundFloor": { "max": "number", "min": "number" },
"kitchen": { "max": "number", "min": "number" },
"livingSpace": { "max": "number", "min": "number" },
"livingroom": { "max": "number", "min": "number" },
"terraces": { "max": "number", "min": "number" },
"total": { "max": "number", "min": "number" }
},
"type": [
"PROPERTY_TYPE_UNKNOWN",
"PROPERTY_TYPE_STUDIO",
"PROPERTY_TYPE_T1",
"PROPERTY_TYPE_T1_T2",
"PROPERTY_TYPE_T2",
"PROPERTY_TYPE_T2_T3",
"PROPERTY_TYPE_T3",
"PROPERTY_TYPE_T3_4",
"PROPERTY_TYPE_T4",
"PROPERTY_TYPE_T4_5",
"PROPERTY_TYPE_T5_MORE",
"PROPERTY_TYPE_LOFT",
"PROPERTY_TYPE_DUPLEX",
"PROPERTY_TYPE_OTHER_APARTMENT_TYPE",
"PROPERTY_TYPE_INDIVIDUAL",
"PROPERTY_TYPE_ONE_SIDE_TERRACED",
"PROPERTY_TYPE_TWO_SIDE_TERRACED",
"PROPERTY_TYPE_SINGLE_STOREY",
"PROPERTY_TYPE_TRADITIONAL",
"PROPERTY_TYPE_CONTEMPORARY",
"PROPERTY_TYPE_BOURGEOIS",
"PROPERTY_TYPE_VILLA",
"PROPERTY_TYPE_MANOR",
"PROPERTY_TYPE_CASTLE",
"PROPERTY_TYPE_FARM",
"PROPERTY_TYPE_MAS",
"PROPERTY_TYPE_BASTIDE",
"PROPERTY_TYPE_CHALET",
"PROPERTY_TYPE_ANCIENT",
"PROPERTY_TYPE_HERITAGE_LISTED",
"PROPERTY_TYPE_BUNGALOW"
],
"wcCount": { "max": "number", "min": "number" }
},
"isUrgent": "boolean",
"land": {
"canConstruct": "boolean",
"isServiced": "boolean",
"surface": { "max": "number", "min": "number" },
"surfaceConstructable": { "max": "number", "min": "number" },
"type": [
"LAND_UNKNOWN",
"LAND_BUILDING_PLOT",
"LAND_AGRICULTURAL",
"LAND_VINEYARD",
"LAND_INDUSTRIAL",
"LAND_POND",
"LAND_FOREST"
],
"haveBuildingPermit": "boolean",
"haveElectricity": "boolean",
"haveTelecom": "boolean",
"haveWater": "boolean"
},
"location": [
{
"city": "string",
"cityCoordinate": { "location": { "lon": "number", "lat": "number" } },
"department": "string",
"inseeCode": "string",
"irisCode": "string",
"locationCoordinate": {
"location": { "lon": "number", "lat": "number" }
},
"postalCode": "string"
}
],
"meta": {
"firstSeenAt": { "max": "date", "min": "date" },
"isTotallyOffline": "boolean",
"lastPublishedAt": { "max": "date", "min": "date" },
"lastSeenAt": { "max": "date", "min": "date" },
"lastUpdatedAt": { "max": "date", "min": "date" }
},
"parking": {
"count": { "max": "number", "min": "number" },
"numberOfCars": { "max": "number", "min": "number" },
"surface": { "max": "number", "min": "number" },
"type": ["PARKING_UNKNOWN", "PARKING_GARAGE", "PARKING_PARKING"]
},
"price": {
"currency": ["CURRENCY_EUR", "CURRENCY_USD"],
"initial": {
"source": { "flxId": "string", "url": "string", "website": "string" },
"value": { "max": "number", "min": "number" },
"valuePerArea": { "max": "number", "min": "number" }
},
"isAuction": "boolean",
"latest": {
"source": { "flxId": "string", "url": "string", "website": "string" },
"value": { "max": "number", "min": "number" },
"valuePerArea": { "max": "number", "min": "number" }
},
"scope": ["PRICING_ONE_OFF", "PRICING_MENSUAL"],
"warrantyDeposit": { "max": "number", "min": "number" },
"variation": [
{
"sinceLastModified": { "max": "number", "min": "number" },
"sincePublished": { "max": "number", "min": "number" }
}
]
},
"process": [
"PROCESS_UNKNOWN",
"PROCESS_AVAILABLE_ON_MARKET",
"PROCESS_UNDER_COMPROMISE",
"PROCESS_RENTED_SOLD",
"PROCESS_REMOVED",
"PROCESS_RESERVED",
"PROCESS_ARCHIVED"
],
"tags": ["string"],
"type": [
"CLASS_UNKNOWN",
"CLASS_HOUSE",
"CLASS_FLAT",
"CLASS_PROGRAM",
"CLASS_SHOP",
"CLASS_PREMISES",
"CLASS_OFFICE",
"CLASS_LAND",
"CLASS_BUILDING",
"CLASS_PARKING"
],
"hasAnomaly": "boolean",
"offer": [
{
"isCurrentlyOccupied": "boolean",
"renting": {
"isColocation": "boolean",
"isLongTerm": "boolean",
"isShortTerm": "boolean",
"isSubLease": "boolean"
},
"type": [
"OFFER_UNKNOWN",
"OFFER_BUY",
"OFFER_RENT",
"OFFER_BUSINESS_TAKE_OVER",
"OFFER_LEASE_BACK",
"OFFER_LIFE_ANNUITY_SALE",
"OFFER_HOLIDAYS"
]
}
]
}

24
frontend/.gitignore vendored Normal file
View File

@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

75
frontend/README.md Normal file
View File

@ -0,0 +1,75 @@
# React + TypeScript + Vite
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
Currently, two official plugins are available:
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) (or [oxc](https://oxc.rs) when used in [rolldown-vite](https://vite.dev/guide/rolldown)) for Fast Refresh
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
## React Compiler
The React Compiler is enabled on this template. See [this documentation](https://react.dev/learn/react-compiler) for more information.
Note: This will impact Vite dev & build performances.
## Expanding the ESLint configuration
If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
```js
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
// Other configs...
// Remove tseslint.configs.recommended and replace with this
tseslint.configs.recommendedTypeChecked,
// Alternatively, use this for stricter rules
tseslint.configs.strictTypeChecked,
// Optionally, add this for stylistic rules
tseslint.configs.stylisticTypeChecked,
// Other configs...
],
languageOptions: {
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
// other options...
},
},
])
```
You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
```js
// eslint.config.js
import reactX from 'eslint-plugin-react-x'
import reactDom from 'eslint-plugin-react-dom'
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
// Other configs...
// Enable lint rules for React
reactX.configs['recommended-typescript'],
// Enable lint rules for React DOM
reactDom.configs.recommended,
],
languageOptions: {
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
// other options...
},
},
])
```

1289
frontend/api-schema.json Normal file

File diff suppressed because it is too large Load Diff

680
frontend/bun.lock Normal file
View File

@ -0,0 +1,680 @@
{
"lockfileVersion": 1,
"workspaces": {
"": {
"name": "frontend",
"dependencies": {
"@radix-ui/react-checkbox": "^1.3.3",
"@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-slot": "^1.2.3",
"@radix-ui/react-tabs": "^1.1.13",
"@tailwindcss/vite": "^4.1.14",
"@tanstack/react-query": "^5.90.3",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"lucide-react": "^0.545.0",
"react": "^19.1.1",
"react-dom": "^19.1.1",
"react-hook-form": "^7.65.0",
"tailwind-merge": "^3.3.1",
"tailwindcss": "^4.1.14",
},
"devDependencies": {
"@eslint/js": "^9.36.0",
"@types/node": "^24.6.0",
"@types/react": "^19.1.16",
"@types/react-dom": "^19.1.9",
"@vitejs/plugin-react": "^5.0.4",
"babel-plugin-react-compiler": "^19.1.0-rc.3",
"eslint": "^9.36.0",
"eslint-plugin-react-hooks": "^5.2.0",
"eslint-plugin-react-refresh": "^0.4.22",
"globals": "^16.4.0",
"tw-animate-css": "^1.4.0",
"typescript": "~5.9.3",
"typescript-eslint": "^8.45.0",
"vite": "npm:rolldown-vite@7.1.14",
},
},
},
"overrides": {
"vite": "npm:rolldown-vite@7.1.14",
},
"packages": {
"@babel/code-frame": ["@babel/code-frame@7.27.1", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.27.1", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg=="],
"@babel/compat-data": ["@babel/compat-data@7.28.4", "", {}, "sha512-YsmSKC29MJwf0gF8Rjjrg5LQCmyh+j/nD8/eP7f+BeoQTKYqs9RoWbjGOdy0+1Ekr68RJZMUOPVQaQisnIo4Rw=="],
"@babel/core": ["@babel/core@7.28.4", "", { "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.3", "@babel/helper-compilation-targets": "^7.27.2", "@babel/helper-module-transforms": "^7.28.3", "@babel/helpers": "^7.28.4", "@babel/parser": "^7.28.4", "@babel/template": "^7.27.2", "@babel/traverse": "^7.28.4", "@babel/types": "^7.28.4", "@jridgewell/remapping": "^2.3.5", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", "json5": "^2.2.3", "semver": "^6.3.1" } }, "sha512-2BCOP7TN8M+gVDj7/ht3hsaO/B/n5oDbiAyyvnRlNOs+u1o+JWNYTQrmpuNp1/Wq2gcFrI01JAW+paEKDMx/CA=="],
"@babel/generator": ["@babel/generator@7.28.3", "", { "dependencies": { "@babel/parser": "^7.28.3", "@babel/types": "^7.28.2", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" } }, "sha512-3lSpxGgvnmZznmBkCRnVREPUFJv2wrv9iAoFDvADJc0ypmdOxdUtcLeBgBJ6zE0PMeTKnxeQzyk0xTBq4Ep7zw=="],
"@babel/helper-compilation-targets": ["@babel/helper-compilation-targets@7.27.2", "", { "dependencies": { "@babel/compat-data": "^7.27.2", "@babel/helper-validator-option": "^7.27.1", "browserslist": "^4.24.0", "lru-cache": "^5.1.1", "semver": "^6.3.1" } }, "sha512-2+1thGUUWWjLTYTHZWK1n8Yga0ijBz1XAhUXcKy81rd5g6yh7hGqMp45v7cadSbEHc9G3OTv45SyneRN3ps4DQ=="],
"@babel/helper-globals": ["@babel/helper-globals@7.28.0", "", {}, "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw=="],
"@babel/helper-module-imports": ["@babel/helper-module-imports@7.27.1", "", { "dependencies": { "@babel/traverse": "^7.27.1", "@babel/types": "^7.27.1" } }, "sha512-0gSFWUPNXNopqtIPQvlD5WgXYI5GY2kP2cCvoT8kczjbfcfuIljTbcWrulD1CIPIX2gt1wghbDy08yE1p+/r3w=="],
"@babel/helper-module-transforms": ["@babel/helper-module-transforms@7.28.3", "", { "dependencies": { "@babel/helper-module-imports": "^7.27.1", "@babel/helper-validator-identifier": "^7.27.1", "@babel/traverse": "^7.28.3" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-gytXUbs8k2sXS9PnQptz5o0QnpLL51SwASIORY6XaBKF88nsOT0Zw9szLqlSGQDP/4TljBAD5y98p2U1fqkdsw=="],
"@babel/helper-plugin-utils": ["@babel/helper-plugin-utils@7.27.1", "", {}, "sha512-1gn1Up5YXka3YYAHGKpbideQ5Yjf1tDa9qYcgysz+cNCXukyLl6DjPXhD3VRwSb8c0J9tA4b2+rHEZtc6R0tlw=="],
"@babel/helper-string-parser": ["@babel/helper-string-parser@7.27.1", "", {}, "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA=="],
"@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.27.1", "", {}, "sha512-D2hP9eA+Sqx1kBZgzxZh0y1trbuU+JoDkiEwqhQ36nodYqJwyEIhPSdMNd7lOm/4io72luTPWH20Yda0xOuUow=="],
"@babel/helper-validator-option": ["@babel/helper-validator-option@7.27.1", "", {}, "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg=="],
"@babel/helpers": ["@babel/helpers@7.28.4", "", { "dependencies": { "@babel/template": "^7.27.2", "@babel/types": "^7.28.4" } }, "sha512-HFN59MmQXGHVyYadKLVumYsA9dBFun/ldYxipEjzA4196jpLZd8UjEEBLkbEkvfYreDqJhZxYAWFPtrfhNpj4w=="],
"@babel/parser": ["@babel/parser@7.28.4", "", { "dependencies": { "@babel/types": "^7.28.4" }, "bin": "./bin/babel-parser.js" }, "sha512-yZbBqeM6TkpP9du/I2pUZnJsRMGGvOuIrhjzC1AwHwW+6he4mni6Bp/m8ijn0iOuZuPI2BfkCoSRunpyjnrQKg=="],
"@babel/plugin-transform-react-jsx-self": ["@babel/plugin-transform-react-jsx-self@7.27.1", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.27.1" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw=="],
"@babel/plugin-transform-react-jsx-source": ["@babel/plugin-transform-react-jsx-source@7.27.1", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.27.1" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw=="],
"@babel/template": ["@babel/template@7.27.2", "", { "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/parser": "^7.27.2", "@babel/types": "^7.27.1" } }, "sha512-LPDZ85aEJyYSd18/DkjNh4/y1ntkE5KwUHWTiqgRxruuZL2F1yuHligVHLvcHY2vMHXttKFpJn6LwfI7cw7ODw=="],
"@babel/traverse": ["@babel/traverse@7.28.4", "", { "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.3", "@babel/helper-globals": "^7.28.0", "@babel/parser": "^7.28.4", "@babel/template": "^7.27.2", "@babel/types": "^7.28.4", "debug": "^4.3.1" } }, "sha512-YEzuboP2qvQavAcjgQNVgsvHIDv6ZpwXvcvjmyySP2DIMuByS/6ioU5G9pYrWHM6T2YDfc7xga9iNzYOs12CFQ=="],
"@babel/types": ["@babel/types@7.28.4", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.27.1" } }, "sha512-bkFqkLhh3pMBUQQkpVgWDWq/lqzc2678eUyDlTBhRqhCHFguYYGM0Efga7tYk4TogG/3x0EEl66/OQ+WGbWB/Q=="],
"@emnapi/core": ["@emnapi/core@1.5.0", "", { "dependencies": { "@emnapi/wasi-threads": "1.1.0", "tslib": "^2.4.0" } }, "sha512-sbP8GzB1WDzacS8fgNPpHlp6C9VZe+SJP3F90W9rLemaQj2PzIuTEl1qDOYQf58YIpyjViI24y9aPWCjEzY2cg=="],
"@emnapi/runtime": ["@emnapi/runtime@1.5.0", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-97/BJ3iXHww3djw6hYIfErCZFee7qCtrneuLa20UXFCOTCfBM2cvQHjWJ2EG0s0MtdNwInarqCTz35i4wWXHsQ=="],
"@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.1.0", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-WI0DdZ8xFSbgMjR1sFsKABJ/C5OnRrjT06JXbZKexJGrDuPTzZdDYfFlsgcCXCyf+suG5QU2e/y1Wo2V/OapLQ=="],
"@eslint-community/eslint-utils": ["@eslint-community/eslint-utils@4.9.0", "", { "dependencies": { "eslint-visitor-keys": "^3.4.3" }, "peerDependencies": { "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" } }, "sha512-ayVFHdtZ+hsq1t2Dy24wCmGXGe4q9Gu3smhLYALJrr473ZH27MsnSL+LKUlimp4BWJqMDMLmPpx/Q9R3OAlL4g=="],
"@eslint-community/regexpp": ["@eslint-community/regexpp@4.12.1", "", {}, "sha512-CCZCDJuduB9OUkFkY2IgppNZMi2lBQgD2qzwXkEia16cge2pijY/aXi96CJMquDMn3nJdlPV1A5KrJEXwfLNzQ=="],
"@eslint/config-array": ["@eslint/config-array@0.21.0", "", { "dependencies": { "@eslint/object-schema": "^2.1.6", "debug": "^4.3.1", "minimatch": "^3.1.2" } }, "sha512-ENIdc4iLu0d93HeYirvKmrzshzofPw6VkZRKQGe9Nv46ZnWUzcF1xV01dcvEg/1wXUR61OmmlSfyeyO7EvjLxQ=="],
"@eslint/config-helpers": ["@eslint/config-helpers@0.4.0", "", { "dependencies": { "@eslint/core": "^0.16.0" } }, "sha512-WUFvV4WoIwW8Bv0KeKCIIEgdSiFOsulyN0xrMu+7z43q/hkOLXjvb5u7UC9jDxvRzcrbEmuZBX5yJZz1741jog=="],
"@eslint/core": ["@eslint/core@0.16.0", "", { "dependencies": { "@types/json-schema": "^7.0.15" } }, "sha512-nmC8/totwobIiFcGkDza3GIKfAw1+hLiYVrh3I1nIomQ8PEr5cxg34jnkmGawul/ep52wGRAcyeDCNtWKSOj4Q=="],
"@eslint/eslintrc": ["@eslint/eslintrc@3.3.1", "", { "dependencies": { "ajv": "^6.12.4", "debug": "^4.3.2", "espree": "^10.0.1", "globals": "^14.0.0", "ignore": "^5.2.0", "import-fresh": "^3.2.1", "js-yaml": "^4.1.0", "minimatch": "^3.1.2", "strip-json-comments": "^3.1.1" } }, "sha512-gtF186CXhIl1p4pJNGZw8Yc6RlshoePRvE0X91oPGb3vZ8pM3qOS9W9NGPat9LziaBV7XrJWGylNQXkGcnM3IQ=="],
"@eslint/js": ["@eslint/js@9.37.0", "", {}, "sha512-jaS+NJ+hximswBG6pjNX0uEJZkrT0zwpVi3BA3vX22aFGjJjmgSTSmPpZCRKmoBL5VY/M6p0xsSJx7rk7sy5gg=="],
"@eslint/object-schema": ["@eslint/object-schema@2.1.6", "", {}, "sha512-RBMg5FRL0I0gs51M/guSAj5/e14VQ4tpZnQNWwuDT66P14I43ItmPfIZRhO9fUVIPOAQXU47atlywZ/czoqFPA=="],
"@eslint/plugin-kit": ["@eslint/plugin-kit@0.4.0", "", { "dependencies": { "@eslint/core": "^0.16.0", "levn": "^0.4.1" } }, "sha512-sB5uyeq+dwCWyPi31B2gQlVlo+j5brPlWx4yZBrEaRo/nhdDE8Xke1gsGgtiBdaBTxuTkceLVuVt/pclrasb0A=="],
"@floating-ui/core": ["@floating-ui/core@1.7.3", "", { "dependencies": { "@floating-ui/utils": "^0.2.10" } }, "sha512-sGnvb5dmrJaKEZ+LDIpguvdX3bDlEllmv4/ClQ9awcmCZrlx5jQyyMWFM5kBI+EyNOCDDiKk8il0zeuX3Zlg/w=="],
"@floating-ui/dom": ["@floating-ui/dom@1.7.4", "", { "dependencies": { "@floating-ui/core": "^1.7.3", "@floating-ui/utils": "^0.2.10" } }, "sha512-OOchDgh4F2CchOX94cRVqhvy7b3AFb+/rQXyswmzmGakRfkMgoWVjfnLWkRirfLEfuD4ysVW16eXzwt3jHIzKA=="],
"@floating-ui/react-dom": ["@floating-ui/react-dom@2.1.6", "", { "dependencies": { "@floating-ui/dom": "^1.7.4" }, "peerDependencies": { "react": ">=16.8.0", "react-dom": ">=16.8.0" } }, "sha512-4JX6rEatQEvlmgU80wZyq9RT96HZJa88q8hp0pBd+LrczeDI4o6uA2M+uvxngVHo4Ihr8uibXxH6+70zhAFrVw=="],
"@floating-ui/utils": ["@floating-ui/utils@0.2.10", "", {}, "sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ=="],
"@humanfs/core": ["@humanfs/core@0.19.1", "", {}, "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA=="],
"@humanfs/node": ["@humanfs/node@0.16.7", "", { "dependencies": { "@humanfs/core": "^0.19.1", "@humanwhocodes/retry": "^0.4.0" } }, "sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ=="],
"@humanwhocodes/module-importer": ["@humanwhocodes/module-importer@1.0.1", "", {}, "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA=="],
"@humanwhocodes/retry": ["@humanwhocodes/retry@0.4.3", "", {}, "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ=="],
"@isaacs/fs-minipass": ["@isaacs/fs-minipass@4.0.1", "", { "dependencies": { "minipass": "^7.0.4" } }, "sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w=="],
"@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="],
"@jridgewell/remapping": ["@jridgewell/remapping@2.3.5", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ=="],
"@jridgewell/resolve-uri": ["@jridgewell/resolve-uri@3.1.2", "", {}, "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw=="],
"@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.5", "", {}, "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="],
"@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="],
"@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.0.7", "", { "dependencies": { "@emnapi/core": "^1.5.0", "@emnapi/runtime": "^1.5.0", "@tybys/wasm-util": "^0.10.1" } }, "sha512-SeDnOO0Tk7Okiq6DbXmmBODgOAb9dp9gjlphokTUxmt8U3liIP1ZsozBahH69j/RJv+Rfs6IwUKHTgQYJ/HBAw=="],
"@nodelib/fs.scandir": ["@nodelib/fs.scandir@2.1.5", "", { "dependencies": { "@nodelib/fs.stat": "2.0.5", "run-parallel": "^1.1.9" } }, "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g=="],
"@nodelib/fs.stat": ["@nodelib/fs.stat@2.0.5", "", {}, "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A=="],
"@nodelib/fs.walk": ["@nodelib/fs.walk@1.2.8", "", { "dependencies": { "@nodelib/fs.scandir": "2.1.5", "fastq": "^1.6.0" } }, "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg=="],
"@oxc-project/runtime": ["@oxc-project/runtime@0.92.0", "", {}, "sha512-Z7x2dZOmznihvdvCvLKMl+nswtOSVxS2H2ocar+U9xx6iMfTp0VGIrX6a4xB1v80IwOPC7dT1LXIJrY70Xu3Jw=="],
"@oxc-project/types": ["@oxc-project/types@0.93.0", "", {}, "sha512-yNtwmWZIBtJsMr5TEfoZFDxIWV6OdScOpza/f5YxbqUMJk+j6QX3Cf3jgZShGEFYWQJ5j9mJ6jM0tZHu2J9Yrg=="],
"@radix-ui/number": ["@radix-ui/number@1.1.1", "", {}, "sha512-MkKCwxlXTgz6CFoJx3pCwn07GKp36+aZyu/u2Ln2VrA5DcdyCZkASEDBTd8x5whTQQL5CiYf4prXKLcgQdv29g=="],
"@radix-ui/primitive": ["@radix-ui/primitive@1.1.3", "", {}, "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg=="],
"@radix-ui/react-arrow": ["@radix-ui/react-arrow@1.1.7", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-F+M1tLhO+mlQaOWspE8Wstg+z6PwxwRd8oQ8IXceWz92kfAmalTRf0EjrouQeo7QssEPfCn05B4Ihs1K9WQ/7w=="],
"@radix-ui/react-checkbox": ["@radix-ui/react-checkbox@1.3.3", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-use-previous": "1.1.1", "@radix-ui/react-use-size": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-wBbpv+NQftHDdG86Qc0pIyXk5IR3tM8Vd0nWLKDcX8nNn4nXFOFwsKuqw2okA/1D/mpaAkmuyndrPJTYDNZtFw=="],
"@radix-ui/react-collection": ["@radix-ui/react-collection@1.1.7", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-Fh9rGN0MoI4ZFUNyfFVNU4y9LUz93u9/0K+yLgA2bwRojxM8JU1DyvvMBabnZPBgMWREAJvU2jjVzq+LrFUglw=="],
"@radix-ui/react-compose-refs": ["@radix-ui/react-compose-refs@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg=="],
"@radix-ui/react-context": ["@radix-ui/react-context@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA=="],
"@radix-ui/react-direction": ["@radix-ui/react-direction@1.1.1", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-1UEWRX6jnOA2y4H5WczZ44gOOjTEmlqv1uNW4GAJEO5+bauCBhv8snY65Iw5/VOS/ghKN9gr2KjnLKxrsvoMVw=="],
"@radix-ui/react-dismissable-layer": ["@radix-ui/react-dismissable-layer@1.1.11", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-escape-keydown": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-Nqcp+t5cTB8BinFkZgXiMJniQH0PsUt2k51FUhbdfeKvc4ACcG2uQniY/8+h1Yv6Kza4Q7lD7PQV0z0oicE0Mg=="],
"@radix-ui/react-focus-guards": ["@radix-ui/react-focus-guards@1.1.3", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-0rFg/Rj2Q62NCm62jZw0QX7a3sz6QCQU0LpZdNrJX8byRGaGVTqbrW9jAoIAHyMQqsNpeZ81YgSizOt5WXq0Pw=="],
"@radix-ui/react-focus-scope": ["@radix-ui/react-focus-scope@1.1.7", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-t2ODlkXBQyn7jkl6TNaw/MtVEVvIGelJDCG41Okq/KwUsJBwQ4XVZsHAVUkK4mBv3ewiAS3PGuUWuY2BoK4ZUw=="],
"@radix-ui/react-id": ["@radix-ui/react-id@1.1.1", "", { "dependencies": { "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-kGkGegYIdQsOb4XjsfM97rXsiHaBwco+hFI66oO4s9LU+PLAC5oJ7khdOVFxkhsmlbpUqDAvXw11CluXP+jkHg=="],
"@radix-ui/react-popper": ["@radix-ui/react-popper@1.2.8", "", { "dependencies": { "@floating-ui/react-dom": "^2.0.0", "@radix-ui/react-arrow": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-layout-effect": "1.1.1", "@radix-ui/react-use-rect": "1.1.1", "@radix-ui/react-use-size": "1.1.1", "@radix-ui/rect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-0NJQ4LFFUuWkE7Oxf0htBKS6zLkkjBH+hM1uk7Ng705ReR8m/uelduy1DBo0PyBXPKVnBA6YBlU94MBGXrSBCw=="],
"@radix-ui/react-portal": ["@radix-ui/react-portal@1.1.9", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-bpIxvq03if6UNwXZ+HTK71JLh4APvnXntDc6XOX8UVq4XQOVl7lwok0AvIl+b8zgCw3fSaVTZMpAPPagXbKmHQ=="],
"@radix-ui/react-presence": ["@radix-ui/react-presence@1.1.5", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-/jfEwNDdQVBCNvjkGit4h6pMOzq8bHkopq458dPt2lMjx+eBQUohZNG9A7DtO/O5ukSbxuaNGXMjHicgwy6rQQ=="],
"@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="],
"@radix-ui/react-roving-focus": ["@radix-ui/react-roving-focus@1.1.11", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-controllable-state": "1.2.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-7A6S9jSgm/S+7MdtNDSb+IU859vQqJ/QAtcYQcfFC6W8RS4IxIZDldLR0xqCFZ6DCyrQLjLPsxtTNch5jVA4lA=="],
"@radix-ui/react-select": ["@radix-ui/react-select@2.2.6", "", { "dependencies": { "@radix-ui/number": "1.1.1", "@radix-ui/primitive": "1.1.3", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-dismissable-layer": "1.1.11", "@radix-ui/react-focus-guards": "1.1.3", "@radix-ui/react-focus-scope": "1.1.7", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-popper": "1.2.8", "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-use-layout-effect": "1.1.1", "@radix-ui/react-use-previous": "1.1.1", "@radix-ui/react-visually-hidden": "1.2.3", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.6.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-I30RydO+bnn2PQztvo25tswPH+wFBjehVGtmagkU78yMdwTwVf12wnAOF+AeP8S2N8xD+5UPbGhkUfPyvT+mwQ=="],
"@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
"@radix-ui/react-tabs": ["@radix-ui/react-tabs@1.1.13", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-roving-focus": "1.1.11", "@radix-ui/react-use-controllable-state": "1.2.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-7xdcatg7/U+7+Udyoj2zodtI9H/IIopqo+YOIcZOq1nJwXWBZ9p8xiu5llXlekDbZkca79a/fozEYQXIA4sW6A=="],
"@radix-ui/react-use-callback-ref": ["@radix-ui/react-use-callback-ref@1.1.1", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-FkBMwD+qbGQeMu1cOHnuGB6x4yzPjho8ap5WtbEJ26umhgqVXbhekKUQO+hZEL1vU92a3wHwdp0HAcqAUF5iDg=="],
"@radix-ui/react-use-controllable-state": ["@radix-ui/react-use-controllable-state@1.2.2", "", { "dependencies": { "@radix-ui/react-use-effect-event": "0.0.2", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-BjasUjixPFdS+NKkypcyyN5Pmg83Olst0+c6vGov0diwTEo6mgdqVR6hxcEgFuh4QrAs7Rc+9KuGJ9TVCj0Zzg=="],
"@radix-ui/react-use-effect-event": ["@radix-ui/react-use-effect-event@0.0.2", "", { "dependencies": { "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Qp8WbZOBe+blgpuUT+lw2xheLP8q0oatc9UpmiemEICxGvFLYmHm9QowVZGHtJlGbS6A6yJ3iViad/2cVjnOiA=="],
"@radix-ui/react-use-escape-keydown": ["@radix-ui/react-use-escape-keydown@1.1.1", "", { "dependencies": { "@radix-ui/react-use-callback-ref": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Il0+boE7w/XebUHyBjroE+DbByORGR9KKmITzbR7MyQ4akpORYP/ZmbhAr0DG7RmmBqoOnZdy2QlvajJ2QA59g=="],
"@radix-ui/react-use-layout-effect": ["@radix-ui/react-use-layout-effect@1.1.1", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ=="],
"@radix-ui/react-use-previous": ["@radix-ui/react-use-previous@1.1.1", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-2dHfToCj/pzca2Ck724OZ5L0EVrr3eHRNsG/b3xQJLA2hZpVCS99bLAX+hm1IHXDEnzU6by5z/5MIY794/a8NQ=="],
"@radix-ui/react-use-rect": ["@radix-ui/react-use-rect@1.1.1", "", { "dependencies": { "@radix-ui/rect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-QTYuDesS0VtuHNNvMh+CjlKJ4LJickCMUAqjlE3+j8w+RlRpwyX3apEQKGFzbZGdo7XNG1tXa+bQqIE7HIXT2w=="],
"@radix-ui/react-use-size": ["@radix-ui/react-use-size@1.1.1", "", { "dependencies": { "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-ewrXRDTAqAXlkl6t/fkXWNAhFX9I+CkKlw6zjEwk86RSPKwZr3xpBRso655aqYafwtnbpHLj6toFzmd6xdVptQ=="],
"@radix-ui/react-visually-hidden": ["@radix-ui/react-visually-hidden@1.2.3", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-pzJq12tEaaIhqjbzpCuv/OypJY/BPavOofm+dbab+MHLajy277+1lLm6JFcGgF5eskJ6mquGirhXY2GD/8u8Ug=="],
"@radix-ui/rect": ["@radix-ui/rect@1.1.1", "", {}, "sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw=="],
"@rolldown/binding-android-arm64": ["@rolldown/binding-android-arm64@1.0.0-beta.41", "", { "os": "android", "cpu": "arm64" }, "sha512-Edflndd9lU7JVhVIvJlZhdCj5DkhYDJPIRn4Dx0RUdfc8asP9xHOI5gMd8MesDDx+BJpdIT/uAmVTearteU/mQ=="],
"@rolldown/binding-darwin-arm64": ["@rolldown/binding-darwin-arm64@1.0.0-beta.41", "", { "os": "darwin", "cpu": "arm64" }, "sha512-XGCzqfjdk7550PlyZRTBKbypXrB7ATtXhw/+bjtxnklLQs0mKP/XkQVOKyn9qGKSlvH8I56JLYryVxl0PCvSNw=="],
"@rolldown/binding-darwin-x64": ["@rolldown/binding-darwin-x64@1.0.0-beta.41", "", { "os": "darwin", "cpu": "x64" }, "sha512-Ho6lIwGJed98zub7n0xcRKuEtnZgbxevAmO4x3zn3C3N4GVXZD5xvCvTVxSMoeBJwTcIYzkVDRTIhylQNsTgLQ=="],
"@rolldown/binding-freebsd-x64": ["@rolldown/binding-freebsd-x64@1.0.0-beta.41", "", { "os": "freebsd", "cpu": "x64" }, "sha512-ijAZETywvL+gACjbT4zBnCp5ez1JhTRs6OxRN4J+D6AzDRbU2zb01Esl51RP5/8ZOlvB37xxsRQ3X4YRVyYb3g=="],
"@rolldown/binding-linux-arm-gnueabihf": ["@rolldown/binding-linux-arm-gnueabihf@1.0.0-beta.41", "", { "os": "linux", "cpu": "arm" }, "sha512-EgIOZt7UildXKFEFvaiLNBXm+4ggQyGe3E5Z1QP9uRcJJs9omihOnm897FwOBQdCuMvI49iBgjFrkhH+wMJ2MA=="],
"@rolldown/binding-linux-arm64-gnu": ["@rolldown/binding-linux-arm64-gnu@1.0.0-beta.41", "", { "os": "linux", "cpu": "arm64" }, "sha512-F8bUwJq8v/JAU8HSwgF4dztoqJ+FjdyjuvX4//3+Fbe2we9UktFeZ27U4lRMXF1vxWtdV4ey6oCSqI7yUrSEeg=="],
"@rolldown/binding-linux-arm64-musl": ["@rolldown/binding-linux-arm64-musl@1.0.0-beta.41", "", { "os": "linux", "cpu": "arm64" }, "sha512-MioXcCIX/wB1pBnBoJx8q4OGucUAfC1+/X1ilKFsjDK05VwbLZGRgOVD5OJJpUQPK86DhQciNBrfOKDiatxNmg=="],
"@rolldown/binding-linux-x64-gnu": ["@rolldown/binding-linux-x64-gnu@1.0.0-beta.41", "", { "os": "linux", "cpu": "x64" }, "sha512-m66M61fizvRCwt5pOEiZQMiwBL9/y0bwU/+Kc4Ce/Pef6YfoEkR28y+DzN9rMdjo8Z28NXjsDPq9nH4mXnAP0g=="],
"@rolldown/binding-linux-x64-musl": ["@rolldown/binding-linux-x64-musl@1.0.0-beta.41", "", { "os": "linux", "cpu": "x64" }, "sha512-yRxlSfBvWnnfrdtJfvi9lg8xfG5mPuyoSHm0X01oiE8ArmLRvoJGHUTJydCYz+wbK2esbq5J4B4Tq9WAsOlP1Q=="],
"@rolldown/binding-openharmony-arm64": ["@rolldown/binding-openharmony-arm64@1.0.0-beta.41", "", { "os": "none", "cpu": "arm64" }, "sha512-PHVxYhBpi8UViS3/hcvQQb9RFqCtvFmFU1PvUoTRiUdBtgHA6fONNHU4x796lgzNlVSD3DO/MZNk1s5/ozSMQg=="],
"@rolldown/binding-wasm32-wasi": ["@rolldown/binding-wasm32-wasi@1.0.0-beta.41", "", { "dependencies": { "@napi-rs/wasm-runtime": "^1.0.5" }, "cpu": "none" }, "sha512-OAfcO37ME6GGWmj9qTaDT7jY4rM0T2z0/8ujdQIJQ2x2nl+ztO32EIwURfmXOK0U1tzkyuaKYvE34Pug/ucXlQ=="],
"@rolldown/binding-win32-arm64-msvc": ["@rolldown/binding-win32-arm64-msvc@1.0.0-beta.41", "", { "os": "win32", "cpu": "arm64" }, "sha512-NIYGuCcuXaq5BC4Q3upbiMBvmZsTsEPG9k/8QKQdmrch+ocSy5Jv9tdpdmXJyighKqm182nh/zBt+tSJkYoNlg=="],
"@rolldown/binding-win32-ia32-msvc": ["@rolldown/binding-win32-ia32-msvc@1.0.0-beta.41", "", { "os": "win32", "cpu": "ia32" }, "sha512-kANdsDbE5FkEOb5NrCGBJBCaZ2Sabp3D7d4PRqMYJqyLljwh9mDyYyYSv5+QNvdAmifj+f3lviNEUUuUZPEFPw=="],
"@rolldown/binding-win32-x64-msvc": ["@rolldown/binding-win32-x64-msvc@1.0.0-beta.41", "", { "os": "win32", "cpu": "x64" }, "sha512-UlpxKmFdik0Y2VjZrgUCgoYArZJiZllXgIipdBRV1hw6uK45UbQabSTW6Kp6enuOu7vouYWftwhuxfpE8J2JAg=="],
"@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-beta.38", "", {}, "sha512-N/ICGKleNhA5nc9XXQG/kkKHJ7S55u0x0XUJbbkmdCnFuoRkM1Il12q9q0eX19+M7KKUEPw/daUPIRnxhcxAIw=="],
"@tailwindcss/node": ["@tailwindcss/node@4.1.14", "", { "dependencies": { "@jridgewell/remapping": "^2.3.4", "enhanced-resolve": "^5.18.3", "jiti": "^2.6.0", "lightningcss": "1.30.1", "magic-string": "^0.30.19", "source-map-js": "^1.2.1", "tailwindcss": "4.1.14" } }, "sha512-hpz+8vFk3Ic2xssIA3e01R6jkmsAhvkQdXlEbRTk6S10xDAtiQiM3FyvZVGsucefq764euO/b8WUW9ysLdThHw=="],
"@tailwindcss/oxide": ["@tailwindcss/oxide@4.1.14", "", { "dependencies": { "detect-libc": "^2.0.4", "tar": "^7.5.1" }, "optionalDependencies": { "@tailwindcss/oxide-android-arm64": "4.1.14", "@tailwindcss/oxide-darwin-arm64": "4.1.14", "@tailwindcss/oxide-darwin-x64": "4.1.14", "@tailwindcss/oxide-freebsd-x64": "4.1.14", "@tailwindcss/oxide-linux-arm-gnueabihf": "4.1.14", "@tailwindcss/oxide-linux-arm64-gnu": "4.1.14", "@tailwindcss/oxide-linux-arm64-musl": "4.1.14", "@tailwindcss/oxide-linux-x64-gnu": "4.1.14", "@tailwindcss/oxide-linux-x64-musl": "4.1.14", "@tailwindcss/oxide-wasm32-wasi": "4.1.14", "@tailwindcss/oxide-win32-arm64-msvc": "4.1.14", "@tailwindcss/oxide-win32-x64-msvc": "4.1.14" } }, "sha512-23yx+VUbBwCg2x5XWdB8+1lkPajzLmALEfMb51zZUBYaYVPDQvBSD/WYDqiVyBIo2BZFa3yw1Rpy3G2Jp+K0dw=="],
"@tailwindcss/oxide-android-arm64": ["@tailwindcss/oxide-android-arm64@4.1.14", "", { "os": "android", "cpu": "arm64" }, "sha512-a94ifZrGwMvbdeAxWoSuGcIl6/DOP5cdxagid7xJv6bwFp3oebp7y2ImYsnZBMTwjn5Ev5xESvS3FFYUGgPODQ=="],
"@tailwindcss/oxide-darwin-arm64": ["@tailwindcss/oxide-darwin-arm64@4.1.14", "", { "os": "darwin", "cpu": "arm64" }, "sha512-HkFP/CqfSh09xCnrPJA7jud7hij5ahKyWomrC3oiO2U9i0UjP17o9pJbxUN0IJ471GTQQmzwhp0DEcpbp4MZTA=="],
"@tailwindcss/oxide-darwin-x64": ["@tailwindcss/oxide-darwin-x64@4.1.14", "", { "os": "darwin", "cpu": "x64" }, "sha512-eVNaWmCgdLf5iv6Qd3s7JI5SEFBFRtfm6W0mphJYXgvnDEAZ5sZzqmI06bK6xo0IErDHdTA5/t7d4eTfWbWOFw=="],
"@tailwindcss/oxide-freebsd-x64": ["@tailwindcss/oxide-freebsd-x64@4.1.14", "", { "os": "freebsd", "cpu": "x64" }, "sha512-QWLoRXNikEuqtNb0dhQN6wsSVVjX6dmUFzuuiL09ZeXju25dsei2uIPl71y2Ic6QbNBsB4scwBoFnlBfabHkEw=="],
"@tailwindcss/oxide-linux-arm-gnueabihf": ["@tailwindcss/oxide-linux-arm-gnueabihf@4.1.14", "", { "os": "linux", "cpu": "arm" }, "sha512-VB4gjQni9+F0VCASU+L8zSIyjrLLsy03sjcR3bM0V2g4SNamo0FakZFKyUQ96ZVwGK4CaJsc9zd/obQy74o0Fw=="],
"@tailwindcss/oxide-linux-arm64-gnu": ["@tailwindcss/oxide-linux-arm64-gnu@4.1.14", "", { "os": "linux", "cpu": "arm64" }, "sha512-qaEy0dIZ6d9vyLnmeg24yzA8XuEAD9WjpM5nIM1sUgQ/Zv7cVkharPDQcmm/t/TvXoKo/0knI3me3AGfdx6w1w=="],
"@tailwindcss/oxide-linux-arm64-musl": ["@tailwindcss/oxide-linux-arm64-musl@4.1.14", "", { "os": "linux", "cpu": "arm64" }, "sha512-ISZjT44s59O8xKsPEIesiIydMG/sCXoMBCqsphDm/WcbnuWLxxb+GcvSIIA5NjUw6F8Tex7s5/LM2yDy8RqYBQ=="],
"@tailwindcss/oxide-linux-x64-gnu": ["@tailwindcss/oxide-linux-x64-gnu@4.1.14", "", { "os": "linux", "cpu": "x64" }, "sha512-02c6JhLPJj10L2caH4U0zF8Hji4dOeahmuMl23stk0MU1wfd1OraE7rOloidSF8W5JTHkFdVo/O7uRUJJnUAJg=="],
"@tailwindcss/oxide-linux-x64-musl": ["@tailwindcss/oxide-linux-x64-musl@4.1.14", "", { "os": "linux", "cpu": "x64" }, "sha512-TNGeLiN1XS66kQhxHG/7wMeQDOoL0S33x9BgmydbrWAb9Qw0KYdd8o1ifx4HOGDWhVmJ+Ul+JQ7lyknQFilO3Q=="],
"@tailwindcss/oxide-wasm32-wasi": ["@tailwindcss/oxide-wasm32-wasi@4.1.14", "", { "dependencies": { "@emnapi/core": "^1.5.0", "@emnapi/runtime": "^1.5.0", "@emnapi/wasi-threads": "^1.1.0", "@napi-rs/wasm-runtime": "^1.0.5", "@tybys/wasm-util": "^0.10.1", "tslib": "^2.4.0" }, "cpu": "none" }, "sha512-uZYAsaW/jS/IYkd6EWPJKW/NlPNSkWkBlaeVBi/WsFQNP05/bzkebUL8FH1pdsqx4f2fH/bWFcUABOM9nfiJkQ=="],
"@tailwindcss/oxide-win32-arm64-msvc": ["@tailwindcss/oxide-win32-arm64-msvc@4.1.14", "", { "os": "win32", "cpu": "arm64" }, "sha512-Az0RnnkcvRqsuoLH2Z4n3JfAef0wElgzHD5Aky/e+0tBUxUhIeIqFBTMNQvmMRSP15fWwmvjBxZ3Q8RhsDnxAA=="],
"@tailwindcss/oxide-win32-x64-msvc": ["@tailwindcss/oxide-win32-x64-msvc@4.1.14", "", { "os": "win32", "cpu": "x64" }, "sha512-ttblVGHgf68kEE4om1n/n44I0yGPkCPbLsqzjvybhpwa6mKKtgFfAzy6btc3HRmuW7nHe0OOrSeNP9sQmmH9XA=="],
"@tailwindcss/vite": ["@tailwindcss/vite@4.1.14", "", { "dependencies": { "@tailwindcss/node": "4.1.14", "@tailwindcss/oxide": "4.1.14", "tailwindcss": "4.1.14" }, "peerDependencies": { "vite": "^5.2.0 || ^6 || ^7" } }, "sha512-BoFUoU0XqgCUS1UXWhmDJroKKhNXeDzD7/XwabjkDIAbMnc4ULn5e2FuEuBbhZ6ENZoSYzKlzvZ44Yr6EUDUSA=="],
"@tanstack/query-core": ["@tanstack/query-core@5.90.3", "", {}, "sha512-HtPOnCwmx4dd35PfXU8jjkhwYrsHfuqgC8RCJIwWglmhIUIlzPP0ZcEkDAc+UtAWCiLm7T8rxeEfHZlz3hYMCA=="],
"@tanstack/react-query": ["@tanstack/react-query@5.90.3", "", { "dependencies": { "@tanstack/query-core": "5.90.3" }, "peerDependencies": { "react": "^18 || ^19" } }, "sha512-i/LRL6DtuhG6bjGzavIMIVuKKPWx2AnEBIsBfuMm3YoHne0a20nWmsatOCBcVSaT0/8/5YFjNkebHAPLVUSi0Q=="],
"@tybys/wasm-util": ["@tybys/wasm-util@0.10.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg=="],
"@types/babel__core": ["@types/babel__core@7.20.5", "", { "dependencies": { "@babel/parser": "^7.20.7", "@babel/types": "^7.20.7", "@types/babel__generator": "*", "@types/babel__template": "*", "@types/babel__traverse": "*" } }, "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA=="],
"@types/babel__generator": ["@types/babel__generator@7.27.0", "", { "dependencies": { "@babel/types": "^7.0.0" } }, "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg=="],
"@types/babel__template": ["@types/babel__template@7.4.4", "", { "dependencies": { "@babel/parser": "^7.1.0", "@babel/types": "^7.0.0" } }, "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A=="],
"@types/babel__traverse": ["@types/babel__traverse@7.28.0", "", { "dependencies": { "@babel/types": "^7.28.2" } }, "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q=="],
"@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="],
"@types/json-schema": ["@types/json-schema@7.0.15", "", {}, "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA=="],
"@types/node": ["@types/node@24.7.2", "", { "dependencies": { "undici-types": "~7.14.0" } }, "sha512-/NbVmcGTP+lj5oa4yiYxxeBjRivKQ5Ns1eSZeB99ExsEQ6rX5XYU1Zy/gGxY/ilqtD4Etx9mKyrPxZRetiahhA=="],
"@types/react": ["@types/react@19.2.2", "", { "dependencies": { "csstype": "^3.0.2" } }, "sha512-6mDvHUFSjyT2B2yeNx2nUgMxh9LtOWvkhIU3uePn2I2oyNymUAX1NIsdgviM4CH+JSrp2D2hsMvJOkxY+0wNRA=="],
"@types/react-dom": ["@types/react-dom@19.2.2", "", { "peerDependencies": { "@types/react": "^19.2.0" } }, "sha512-9KQPoO6mZCi7jcIStSnlOWn2nEF3mNmyr3rIAsGnAbQKYbRLyqmeSc39EVgtxXVia+LMT8j3knZLAZAh+xLmrw=="],
"@typescript-eslint/eslint-plugin": ["@typescript-eslint/eslint-plugin@8.46.1", "", { "dependencies": { "@eslint-community/regexpp": "^4.10.0", "@typescript-eslint/scope-manager": "8.46.1", "@typescript-eslint/type-utils": "8.46.1", "@typescript-eslint/utils": "8.46.1", "@typescript-eslint/visitor-keys": "8.46.1", "graphemer": "^1.4.0", "ignore": "^7.0.0", "natural-compare": "^1.4.0", "ts-api-utils": "^2.1.0" }, "peerDependencies": { "@typescript-eslint/parser": "^8.46.1", "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-rUsLh8PXmBjdiPY+Emjz9NX2yHvhS11v0SR6xNJkm5GM1MO9ea/1GoDKlHHZGrOJclL/cZ2i/vRUYVtjRhrHVQ=="],
"@typescript-eslint/parser": ["@typescript-eslint/parser@8.46.1", "", { "dependencies": { "@typescript-eslint/scope-manager": "8.46.1", "@typescript-eslint/types": "8.46.1", "@typescript-eslint/typescript-estree": "8.46.1", "@typescript-eslint/visitor-keys": "8.46.1", "debug": "^4.3.4" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-6JSSaBZmsKvEkbRUkf7Zj7dru/8ZCrJxAqArcLaVMee5907JdtEbKGsZ7zNiIm/UAkpGUkaSMZEXShnN2D1HZA=="],
"@typescript-eslint/project-service": ["@typescript-eslint/project-service@8.46.1", "", { "dependencies": { "@typescript-eslint/tsconfig-utils": "^8.46.1", "@typescript-eslint/types": "^8.46.1", "debug": "^4.3.4" }, "peerDependencies": { "typescript": ">=4.8.4 <6.0.0" } }, "sha512-FOIaFVMHzRskXr5J4Jp8lFVV0gz5ngv3RHmn+E4HYxSJ3DgDzU7fVI1/M7Ijh1zf6S7HIoaIOtln1H5y8V+9Zg=="],
"@typescript-eslint/scope-manager": ["@typescript-eslint/scope-manager@8.46.1", "", { "dependencies": { "@typescript-eslint/types": "8.46.1", "@typescript-eslint/visitor-keys": "8.46.1" } }, "sha512-weL9Gg3/5F0pVQKiF8eOXFZp8emqWzZsOJuWRUNtHT+UNV2xSJegmpCNQHy37aEQIbToTq7RHKhWvOsmbM680A=="],
"@typescript-eslint/tsconfig-utils": ["@typescript-eslint/tsconfig-utils@8.46.1", "", { "peerDependencies": { "typescript": ">=4.8.4 <6.0.0" } }, "sha512-X88+J/CwFvlJB+mK09VFqx5FE4H5cXD+H/Bdza2aEWkSb8hnWIQorNcscRl4IEo1Cz9VI/+/r/jnGWkbWPx54g=="],
"@typescript-eslint/type-utils": ["@typescript-eslint/type-utils@8.46.1", "", { "dependencies": { "@typescript-eslint/types": "8.46.1", "@typescript-eslint/typescript-estree": "8.46.1", "@typescript-eslint/utils": "8.46.1", "debug": "^4.3.4", "ts-api-utils": "^2.1.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-+BlmiHIiqufBxkVnOtFwjah/vrkF4MtKKvpXrKSPLCkCtAp8H01/VV43sfqA98Od7nJpDcFnkwgyfQbOG0AMvw=="],
"@typescript-eslint/types": ["@typescript-eslint/types@8.46.1", "", {}, "sha512-C+soprGBHwWBdkDpbaRC4paGBrkIXxVlNohadL5o0kfhsXqOC6GYH2S/Obmig+I0HTDl8wMaRySwrfrXVP8/pQ=="],
"@typescript-eslint/typescript-estree": ["@typescript-eslint/typescript-estree@8.46.1", "", { "dependencies": { "@typescript-eslint/project-service": "8.46.1", "@typescript-eslint/tsconfig-utils": "8.46.1", "@typescript-eslint/types": "8.46.1", "@typescript-eslint/visitor-keys": "8.46.1", "debug": "^4.3.4", "fast-glob": "^3.3.2", "is-glob": "^4.0.3", "minimatch": "^9.0.4", "semver": "^7.6.0", "ts-api-utils": "^2.1.0" }, "peerDependencies": { "typescript": ">=4.8.4 <6.0.0" } }, "sha512-uIifjT4s8cQKFQ8ZBXXyoUODtRoAd7F7+G8MKmtzj17+1UbdzFl52AzRyZRyKqPHhgzvXunnSckVu36flGy8cg=="],
"@typescript-eslint/utils": ["@typescript-eslint/utils@8.46.1", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.7.0", "@typescript-eslint/scope-manager": "8.46.1", "@typescript-eslint/types": "8.46.1", "@typescript-eslint/typescript-estree": "8.46.1" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-vkYUy6LdZS7q1v/Gxb2Zs7zziuXN0wxqsetJdeZdRe/f5dwJFglmuvZBfTUivCtjH725C1jWCDfpadadD95EDQ=="],
"@typescript-eslint/visitor-keys": ["@typescript-eslint/visitor-keys@8.46.1", "", { "dependencies": { "@typescript-eslint/types": "8.46.1", "eslint-visitor-keys": "^4.2.1" } }, "sha512-ptkmIf2iDkNUjdeu2bQqhFPV1m6qTnFFjg7PPDjxKWaMaP0Z6I9l30Jr3g5QqbZGdw8YdYvLp+XnqnWWZOg/NA=="],
"@vitejs/plugin-react": ["@vitejs/plugin-react@5.0.4", "", { "dependencies": { "@babel/core": "^7.28.4", "@babel/plugin-transform-react-jsx-self": "^7.27.1", "@babel/plugin-transform-react-jsx-source": "^7.27.1", "@rolldown/pluginutils": "1.0.0-beta.38", "@types/babel__core": "^7.20.5", "react-refresh": "^0.17.0" }, "peerDependencies": { "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, "sha512-La0KD0vGkVkSk6K+piWDKRUyg8Rl5iAIKRMH0vMJI0Eg47bq1eOxmoObAaQG37WMW9MSyk7Cs8EIWwJC1PtzKA=="],
"acorn": ["acorn@8.15.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg=="],
"acorn-jsx": ["acorn-jsx@5.3.2", "", { "peerDependencies": { "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ=="],
"ajv": ["ajv@6.12.6", "", { "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", "json-schema-traverse": "^0.4.1", "uri-js": "^4.2.2" } }, "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g=="],
"ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="],
"ansis": ["ansis@4.2.0", "", {}, "sha512-HqZ5rWlFjGiV0tDm3UxxgNRqsOTniqoKZu0pIAfh7TZQMGuZK+hH0drySty0si0QXj1ieop4+SkSfPZBPPkHig=="],
"argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="],
"aria-hidden": ["aria-hidden@1.2.6", "", { "dependencies": { "tslib": "^2.0.0" } }, "sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA=="],
"babel-plugin-react-compiler": ["babel-plugin-react-compiler@19.1.0-rc.1-rc-af1b7da-20250421", "", { "dependencies": { "@babel/types": "^7.26.0" } }, "sha512-E3kaokBhWDLf7ZD8fuYjYn0ZJHYZ+3EHtAWCdX2hl4lpu1z9S/Xr99sxhx2bTCVB41oIesz9FtM8f4INsrZaOw=="],
"balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="],
"baseline-browser-mapping": ["baseline-browser-mapping@2.8.16", "", { "bin": { "baseline-browser-mapping": "dist/cli.js" } }, "sha512-OMu3BGQ4E7P1ErFsIPpbJh0qvDudM/UuJeHgkAvfWe+0HFJCXh+t/l8L6fVLR55RI/UbKrVLnAXZSVwd9ysWYw=="],
"brace-expansion": ["brace-expansion@1.1.12", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg=="],
"braces": ["braces@3.0.3", "", { "dependencies": { "fill-range": "^7.1.1" } }, "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA=="],
"browserslist": ["browserslist@4.26.3", "", { "dependencies": { "baseline-browser-mapping": "^2.8.9", "caniuse-lite": "^1.0.30001746", "electron-to-chromium": "^1.5.227", "node-releases": "^2.0.21", "update-browserslist-db": "^1.1.3" }, "bin": { "browserslist": "cli.js" } }, "sha512-lAUU+02RFBuCKQPj/P6NgjlbCnLBMp4UtgTx7vNHd3XSIJF87s9a5rA3aH2yw3GS9DqZAUbOtZdCCiZeVRqt0w=="],
"callsites": ["callsites@3.1.0", "", {}, "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ=="],
"caniuse-lite": ["caniuse-lite@1.0.30001750", "", {}, "sha512-cuom0g5sdX6rw00qOoLNSFCJ9/mYIsuSOA+yzpDw8eopiFqcVwQvZHqov0vmEighRxX++cfC0Vg1G+1Iy/mSpQ=="],
"chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="],
"chownr": ["chownr@3.0.0", "", {}, "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g=="],
"class-variance-authority": ["class-variance-authority@0.7.1", "", { "dependencies": { "clsx": "^2.1.1" } }, "sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg=="],
"clsx": ["clsx@2.1.1", "", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="],
"color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="],
"color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="],
"concat-map": ["concat-map@0.0.1", "", {}, "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg=="],
"convert-source-map": ["convert-source-map@2.0.0", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="],
"cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="],
"csstype": ["csstype@3.1.3", "", {}, "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw=="],
"debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
"deep-is": ["deep-is@0.1.4", "", {}, "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ=="],
"detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],
"detect-node-es": ["detect-node-es@1.1.0", "", {}, "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ=="],
"electron-to-chromium": ["electron-to-chromium@1.5.235", "", {}, "sha512-i/7ntLFwOdoHY7sgjlTIDo4Sl8EdoTjWIaKinYOVfC6bOp71bmwenyZthWHcasxgHDNWbWxvG9M3Ia116zIaYQ=="],
"enhanced-resolve": ["enhanced-resolve@5.18.3", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.2.0" } }, "sha512-d4lC8xfavMeBjzGr2vECC3fsGXziXZQyJxD868h2M/mBI3PwAuODxAkLkq5HYuvrPYcUtiLzsTo8U3PgX3Ocww=="],
"escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="],
"escape-string-regexp": ["escape-string-regexp@4.0.0", "", {}, "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA=="],
"eslint": ["eslint@9.37.0", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", "@eslint/config-array": "^0.21.0", "@eslint/config-helpers": "^0.4.0", "@eslint/core": "^0.16.0", "@eslint/eslintrc": "^3.3.1", "@eslint/js": "9.37.0", "@eslint/plugin-kit": "^0.4.0", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", "@humanwhocodes/retry": "^0.4.2", "@types/estree": "^1.0.6", "@types/json-schema": "^7.0.15", "ajv": "^6.12.4", "chalk": "^4.0.0", "cross-spawn": "^7.0.6", "debug": "^4.3.2", "escape-string-regexp": "^4.0.0", "eslint-scope": "^8.4.0", "eslint-visitor-keys": "^4.2.1", "espree": "^10.4.0", "esquery": "^1.5.0", "esutils": "^2.0.2", "fast-deep-equal": "^3.1.3", "file-entry-cache": "^8.0.0", "find-up": "^5.0.0", "glob-parent": "^6.0.2", "ignore": "^5.2.0", "imurmurhash": "^0.1.4", "is-glob": "^4.0.0", "json-stable-stringify-without-jsonify": "^1.0.1", "lodash.merge": "^4.6.2", "minimatch": "^3.1.2", "natural-compare": "^1.4.0", "optionator": "^0.9.3" }, "peerDependencies": { "jiti": "*" }, "optionalPeers": ["jiti"], "bin": { "eslint": "bin/eslint.js" } }, "sha512-XyLmROnACWqSxiGYArdef1fItQd47weqB7iwtfr9JHwRrqIXZdcFMvvEcL9xHCmL0SNsOvF0c42lWyM1U5dgig=="],
"eslint-plugin-react-hooks": ["eslint-plugin-react-hooks@5.2.0", "", { "peerDependencies": { "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0" } }, "sha512-+f15FfK64YQwZdJNELETdn5ibXEUQmW1DZL6KXhNnc2heoy/sg9VJJeT7n8TlMWouzWqSWavFkIhHyIbIAEapg=="],
"eslint-plugin-react-refresh": ["eslint-plugin-react-refresh@0.4.23", "", { "peerDependencies": { "eslint": ">=8.40" } }, "sha512-G4j+rv0NmbIR45kni5xJOrYvCtyD3/7LjpVH8MPPcudXDcNu8gv+4ATTDXTtbRR8rTCM5HxECvCSsRmxKnWDsA=="],
"eslint-scope": ["eslint-scope@8.4.0", "", { "dependencies": { "esrecurse": "^4.3.0", "estraverse": "^5.2.0" } }, "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg=="],
"eslint-visitor-keys": ["eslint-visitor-keys@4.2.1", "", {}, "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ=="],
"espree": ["espree@10.4.0", "", { "dependencies": { "acorn": "^8.15.0", "acorn-jsx": "^5.3.2", "eslint-visitor-keys": "^4.2.1" } }, "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ=="],
"esquery": ["esquery@1.6.0", "", { "dependencies": { "estraverse": "^5.1.0" } }, "sha512-ca9pw9fomFcKPvFLXhBKUK90ZvGibiGOvRJNbjljY7s7uq/5YO4BOzcYtJqExdx99rF6aAcnRxHmcUHcz6sQsg=="],
"esrecurse": ["esrecurse@4.3.0", "", { "dependencies": { "estraverse": "^5.2.0" } }, "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag=="],
"estraverse": ["estraverse@5.3.0", "", {}, "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA=="],
"esutils": ["esutils@2.0.3", "", {}, "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g=="],
"fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="],
"fast-glob": ["fast-glob@3.3.3", "", { "dependencies": { "@nodelib/fs.stat": "^2.0.2", "@nodelib/fs.walk": "^1.2.3", "glob-parent": "^5.1.2", "merge2": "^1.3.0", "micromatch": "^4.0.8" } }, "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg=="],
"fast-json-stable-stringify": ["fast-json-stable-stringify@2.1.0", "", {}, "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw=="],
"fast-levenshtein": ["fast-levenshtein@2.0.6", "", {}, "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw=="],
"fastq": ["fastq@1.19.1", "", { "dependencies": { "reusify": "^1.0.4" } }, "sha512-GwLTyxkCXjXbxqIhTsMI2Nui8huMPtnxg7krajPJAjnEG/iiOS7i+zCtWGZR9G0NBKbXKh6X9m9UIsYX/N6vvQ=="],
"fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="],
"file-entry-cache": ["file-entry-cache@8.0.0", "", { "dependencies": { "flat-cache": "^4.0.0" } }, "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ=="],
"fill-range": ["fill-range@7.1.1", "", { "dependencies": { "to-regex-range": "^5.0.1" } }, "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg=="],
"find-up": ["find-up@5.0.0", "", { "dependencies": { "locate-path": "^6.0.0", "path-exists": "^4.0.0" } }, "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng=="],
"flat-cache": ["flat-cache@4.0.1", "", { "dependencies": { "flatted": "^3.2.9", "keyv": "^4.5.4" } }, "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw=="],
"flatted": ["flatted@3.3.3", "", {}, "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg=="],
"fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
"gensync": ["gensync@1.0.0-beta.2", "", {}, "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg=="],
"get-nonce": ["get-nonce@1.0.1", "", {}, "sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q=="],
"glob-parent": ["glob-parent@6.0.2", "", { "dependencies": { "is-glob": "^4.0.3" } }, "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A=="],
"globals": ["globals@16.4.0", "", {}, "sha512-ob/2LcVVaVGCYN+r14cnwnoDPUufjiYgSqRhiFD0Q1iI4Odora5RE8Iv1D24hAz5oMophRGkGz+yuvQmmUMnMw=="],
"graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="],
"graphemer": ["graphemer@1.4.0", "", {}, "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag=="],
"has-flag": ["has-flag@4.0.0", "", {}, "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ=="],
"ignore": ["ignore@5.3.2", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="],
"import-fresh": ["import-fresh@3.3.1", "", { "dependencies": { "parent-module": "^1.0.0", "resolve-from": "^4.0.0" } }, "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ=="],
"imurmurhash": ["imurmurhash@0.1.4", "", {}, "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA=="],
"is-extglob": ["is-extglob@2.1.1", "", {}, "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ=="],
"is-glob": ["is-glob@4.0.3", "", { "dependencies": { "is-extglob": "^2.1.1" } }, "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg=="],
"is-number": ["is-number@7.0.0", "", {}, "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng=="],
"isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="],
"jiti": ["jiti@2.6.1", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ=="],
"js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="],
"js-yaml": ["js-yaml@4.1.0", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA=="],
"jsesc": ["jsesc@3.1.0", "", { "bin": { "jsesc": "bin/jsesc" } }, "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA=="],
"json-buffer": ["json-buffer@3.0.1", "", {}, "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ=="],
"json-schema-traverse": ["json-schema-traverse@0.4.1", "", {}, "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg=="],
"json-stable-stringify-without-jsonify": ["json-stable-stringify-without-jsonify@1.0.1", "", {}, "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw=="],
"json5": ["json5@2.2.3", "", { "bin": { "json5": "lib/cli.js" } }, "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg=="],
"keyv": ["keyv@4.5.4", "", { "dependencies": { "json-buffer": "3.0.1" } }, "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw=="],
"levn": ["levn@0.4.1", "", { "dependencies": { "prelude-ls": "^1.2.1", "type-check": "~0.4.0" } }, "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ=="],
"lightningcss": ["lightningcss@1.30.2", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.30.2", "lightningcss-darwin-arm64": "1.30.2", "lightningcss-darwin-x64": "1.30.2", "lightningcss-freebsd-x64": "1.30.2", "lightningcss-linux-arm-gnueabihf": "1.30.2", "lightningcss-linux-arm64-gnu": "1.30.2", "lightningcss-linux-arm64-musl": "1.30.2", "lightningcss-linux-x64-gnu": "1.30.2", "lightningcss-linux-x64-musl": "1.30.2", "lightningcss-win32-arm64-msvc": "1.30.2", "lightningcss-win32-x64-msvc": "1.30.2" } }, "sha512-utfs7Pr5uJyyvDETitgsaqSyjCb2qNRAtuqUeWIAKztsOYdcACf2KtARYXg2pSvhkt+9NfoaNY7fxjl6nuMjIQ=="],
"lightningcss-android-arm64": ["lightningcss-android-arm64@1.30.2", "", { "os": "android", "cpu": "arm64" }, "sha512-BH9sEdOCahSgmkVhBLeU7Hc9DWeZ1Eb6wNS6Da8igvUwAe0sqROHddIlvU06q3WyXVEOYDZ6ykBZQnjTbmo4+A=="],
"lightningcss-darwin-arm64": ["lightningcss-darwin-arm64@1.30.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-ylTcDJBN3Hp21TdhRT5zBOIi73P6/W0qwvlFEk22fkdXchtNTOU4Qc37SkzV+EKYxLouZ6M4LG9NfZ1qkhhBWA=="],
"lightningcss-darwin-x64": ["lightningcss-darwin-x64@1.30.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-oBZgKchomuDYxr7ilwLcyms6BCyLn0z8J0+ZZmfpjwg9fRVZIR5/GMXd7r9RH94iDhld3UmSjBM6nXWM2TfZTQ=="],
"lightningcss-freebsd-x64": ["lightningcss-freebsd-x64@1.30.2", "", { "os": "freebsd", "cpu": "x64" }, "sha512-c2bH6xTrf4BDpK8MoGG4Bd6zAMZDAXS569UxCAGcA7IKbHNMlhGQ89eRmvpIUGfKWNVdbhSbkQaWhEoMGmGslA=="],
"lightningcss-linux-arm-gnueabihf": ["lightningcss-linux-arm-gnueabihf@1.30.2", "", { "os": "linux", "cpu": "arm" }, "sha512-eVdpxh4wYcm0PofJIZVuYuLiqBIakQ9uFZmipf6LF/HRj5Bgm0eb3qL/mr1smyXIS1twwOxNWndd8z0E374hiA=="],
"lightningcss-linux-arm64-gnu": ["lightningcss-linux-arm64-gnu@1.30.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-UK65WJAbwIJbiBFXpxrbTNArtfuznvxAJw4Q2ZGlU8kPeDIWEX1dg3rn2veBVUylA2Ezg89ktszWbaQnxD/e3A=="],
"lightningcss-linux-arm64-musl": ["lightningcss-linux-arm64-musl@1.30.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-5Vh9dGeblpTxWHpOx8iauV02popZDsCYMPIgiuw97OJ5uaDsL86cnqSFs5LZkG3ghHoX5isLgWzMs+eD1YzrnA=="],
"lightningcss-linux-x64-gnu": ["lightningcss-linux-x64-gnu@1.30.2", "", { "os": "linux", "cpu": "x64" }, "sha512-Cfd46gdmj1vQ+lR6VRTTadNHu6ALuw2pKR9lYq4FnhvgBc4zWY1EtZcAc6EffShbb1MFrIPfLDXD6Xprbnni4w=="],
"lightningcss-linux-x64-musl": ["lightningcss-linux-x64-musl@1.30.2", "", { "os": "linux", "cpu": "x64" }, "sha512-XJaLUUFXb6/QG2lGIW6aIk6jKdtjtcffUT0NKvIqhSBY3hh9Ch+1LCeH80dR9q9LBjG3ewbDjnumefsLsP6aiA=="],
"lightningcss-win32-arm64-msvc": ["lightningcss-win32-arm64-msvc@1.30.2", "", { "os": "win32", "cpu": "arm64" }, "sha512-FZn+vaj7zLv//D/192WFFVA0RgHawIcHqLX9xuWiQt7P0PtdFEVaxgF9rjM/IRYHQXNnk61/H/gb2Ei+kUQ4xQ=="],
"lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.30.2", "", { "os": "win32", "cpu": "x64" }, "sha512-5g1yc73p+iAkid5phb4oVFMB45417DkRevRbt/El/gKXJk4jid+vPFF/AXbxn05Aky8PapwzZrdJShv5C0avjw=="],
"locate-path": ["locate-path@6.0.0", "", { "dependencies": { "p-locate": "^5.0.0" } }, "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw=="],
"lodash.merge": ["lodash.merge@4.6.2", "", {}, "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ=="],
"lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="],
"lucide-react": ["lucide-react@0.545.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-7r1/yUuflQDSt4f1bpn5ZAocyIxcTyVyBBChSVtBKn5M+392cPmI5YJMWOJKk/HUWGm5wg83chlAZtCcGbEZtw=="],
"magic-string": ["magic-string@0.30.19", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-2N21sPY9Ws53PZvsEpVtNuSW+ScYbQdp4b9qUaL+9QkHUrGFKo56Lg9Emg5s9V/qrtNBmiR01sYhUOwu3H+VOw=="],
"merge2": ["merge2@1.4.1", "", {}, "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg=="],
"micromatch": ["micromatch@4.0.8", "", { "dependencies": { "braces": "^3.0.3", "picomatch": "^2.3.1" } }, "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA=="],
"minimatch": ["minimatch@3.1.2", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw=="],
"minipass": ["minipass@7.1.2", "", {}, "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw=="],
"minizlib": ["minizlib@3.1.0", "", { "dependencies": { "minipass": "^7.1.2" } }, "sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw=="],
"ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
"nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="],
"natural-compare": ["natural-compare@1.4.0", "", {}, "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw=="],
"node-releases": ["node-releases@2.0.23", "", {}, "sha512-cCmFDMSm26S6tQSDpBCg/NR8NENrVPhAJSf+XbxBG4rPFaaonlEoE9wHQmun+cls499TQGSb7ZyPBRlzgKfpeg=="],
"optionator": ["optionator@0.9.4", "", { "dependencies": { "deep-is": "^0.1.3", "fast-levenshtein": "^2.0.6", "levn": "^0.4.1", "prelude-ls": "^1.2.1", "type-check": "^0.4.0", "word-wrap": "^1.2.5" } }, "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g=="],
"p-limit": ["p-limit@3.1.0", "", { "dependencies": { "yocto-queue": "^0.1.0" } }, "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ=="],
"p-locate": ["p-locate@5.0.0", "", { "dependencies": { "p-limit": "^3.0.2" } }, "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw=="],
"parent-module": ["parent-module@1.0.1", "", { "dependencies": { "callsites": "^3.0.0" } }, "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g=="],
"path-exists": ["path-exists@4.0.0", "", {}, "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w=="],
"path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="],
"picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="],
"picomatch": ["picomatch@4.0.3", "", {}, "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q=="],
"postcss": ["postcss@8.5.6", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg=="],
"prelude-ls": ["prelude-ls@1.2.1", "", {}, "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g=="],
"punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="],
"queue-microtask": ["queue-microtask@1.2.3", "", {}, "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A=="],
"react": ["react@19.2.0", "", {}, "sha512-tmbWg6W31tQLeB5cdIBOicJDJRR2KzXsV7uSK9iNfLWQ5bIZfxuPEHp7M8wiHyHnn0DD1i7w3Zmin0FtkrwoCQ=="],
"react-dom": ["react-dom@19.2.0", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.0" } }, "sha512-UlbRu4cAiGaIewkPyiRGJk0imDN2T3JjieT6spoL2UeSf5od4n5LB/mQ4ejmxhCFT1tYe8IvaFulzynWovsEFQ=="],
"react-hook-form": ["react-hook-form@7.65.0", "", { "peerDependencies": { "react": "^16.8.0 || ^17 || ^18 || ^19" } }, "sha512-xtOzDz063WcXvGWaHgLNrNzlsdFgtUWcb32E6WFaGTd7kPZG3EeDusjdZfUsPwKCKVXy1ZlntifaHZ4l8pAsmw=="],
"react-refresh": ["react-refresh@0.17.0", "", {}, "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ=="],
"react-remove-scroll": ["react-remove-scroll@2.7.1", "", { "dependencies": { "react-remove-scroll-bar": "^2.3.7", "react-style-singleton": "^2.2.3", "tslib": "^2.1.0", "use-callback-ref": "^1.3.3", "use-sidecar": "^1.1.3" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-HpMh8+oahmIdOuS5aFKKY6Pyog+FNaZV/XyJOq7b4YFwsFHe5yYfdbIalI4k3vU2nSDql7YskmUseHsRrJqIPA=="],
"react-remove-scroll-bar": ["react-remove-scroll-bar@2.3.8", "", { "dependencies": { "react-style-singleton": "^2.2.2", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" }, "optionalPeers": ["@types/react"] }, "sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q=="],
"react-style-singleton": ["react-style-singleton@2.2.3", "", { "dependencies": { "get-nonce": "^1.0.0", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ=="],
"resolve-from": ["resolve-from@4.0.0", "", {}, "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g=="],
"reusify": ["reusify@1.1.0", "", {}, "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw=="],
"rolldown": ["rolldown@1.0.0-beta.41", "", { "dependencies": { "@oxc-project/types": "=0.93.0", "@rolldown/pluginutils": "1.0.0-beta.41", "ansis": "=4.2.0" }, "optionalDependencies": { "@rolldown/binding-android-arm64": "1.0.0-beta.41", "@rolldown/binding-darwin-arm64": "1.0.0-beta.41", "@rolldown/binding-darwin-x64": "1.0.0-beta.41", "@rolldown/binding-freebsd-x64": "1.0.0-beta.41", "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-beta.41", "@rolldown/binding-linux-arm64-gnu": "1.0.0-beta.41", "@rolldown/binding-linux-arm64-musl": "1.0.0-beta.41", "@rolldown/binding-linux-x64-gnu": "1.0.0-beta.41", "@rolldown/binding-linux-x64-musl": "1.0.0-beta.41", "@rolldown/binding-openharmony-arm64": "1.0.0-beta.41", "@rolldown/binding-wasm32-wasi": "1.0.0-beta.41", "@rolldown/binding-win32-arm64-msvc": "1.0.0-beta.41", "@rolldown/binding-win32-ia32-msvc": "1.0.0-beta.41", "@rolldown/binding-win32-x64-msvc": "1.0.0-beta.41" }, "bin": { "rolldown": "bin/cli.mjs" } }, "sha512-U+NPR0Bkg3wm61dteD2L4nAM1U9dtaqVrpDXwC36IKRHpEO/Ubpid4Nijpa2imPchcVNHfxVFwSSMJdwdGFUbg=="],
"run-parallel": ["run-parallel@1.2.0", "", { "dependencies": { "queue-microtask": "^1.2.2" } }, "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA=="],
"scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="],
"semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
"shebang-command": ["shebang-command@2.0.0", "", { "dependencies": { "shebang-regex": "^3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="],
"shebang-regex": ["shebang-regex@3.0.0", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="],
"source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="],
"strip-json-comments": ["strip-json-comments@3.1.1", "", {}, "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig=="],
"supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="],
"tailwind-merge": ["tailwind-merge@3.3.1", "", {}, "sha512-gBXpgUm/3rp1lMZZrM/w7D8GKqshif0zAymAhbCyIt8KMe+0v9DQ7cdYLR4FHH/cKpdTXb+A/tKKU3eolfsI+g=="],
"tailwindcss": ["tailwindcss@4.1.14", "", {}, "sha512-b7pCxjGO98LnxVkKjaZSDeNuljC4ueKUddjENJOADtubtdo8llTaJy7HwBMeLNSSo2N5QIAgklslK1+Ir8r6CA=="],
"tapable": ["tapable@2.3.0", "", {}, "sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg=="],
"tar": ["tar@7.5.1", "", { "dependencies": { "@isaacs/fs-minipass": "^4.0.0", "chownr": "^3.0.0", "minipass": "^7.1.2", "minizlib": "^3.1.0", "yallist": "^5.0.0" } }, "sha512-nlGpxf+hv0v7GkWBK2V9spgactGOp0qvfWRxUMjqHyzrt3SgwE48DIv/FhqPHJYLHpgW1opq3nERbz5Anq7n1g=="],
"tinyglobby": ["tinyglobby@0.2.15", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.3" } }, "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ=="],
"to-regex-range": ["to-regex-range@5.0.1", "", { "dependencies": { "is-number": "^7.0.0" } }, "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ=="],
"ts-api-utils": ["ts-api-utils@2.1.0", "", { "peerDependencies": { "typescript": ">=4.8.4" } }, "sha512-CUgTZL1irw8u29bzrOD/nH85jqyc74D6SshFgujOIA7osm2Rz7dYH77agkx7H4FBNxDq7Cjf+IjaX/8zwFW+ZQ=="],
"tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
"tw-animate-css": ["tw-animate-css@1.4.0", "", {}, "sha512-7bziOlRqH0hJx80h/3mbicLW7o8qLsH5+RaLR2t+OHM3D0JlWGODQKQ4cxbK7WlvmUxpcj6Kgu6EKqjrGFe3QQ=="],
"type-check": ["type-check@0.4.0", "", { "dependencies": { "prelude-ls": "^1.2.1" } }, "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew=="],
"typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
"typescript-eslint": ["typescript-eslint@8.46.1", "", { "dependencies": { "@typescript-eslint/eslint-plugin": "8.46.1", "@typescript-eslint/parser": "8.46.1", "@typescript-eslint/typescript-estree": "8.46.1", "@typescript-eslint/utils": "8.46.1" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-VHgijW803JafdSsDO8I761r3SHrgk4T00IdyQ+/UsthtgPRsBWQLqoSxOolxTpxRKi1kGXK0bSz4CoAc9ObqJA=="],
"undici-types": ["undici-types@7.14.0", "", {}, "sha512-QQiYxHuyZ9gQUIrmPo3IA+hUl4KYk8uSA7cHrcKd/l3p1OTpZcM0Tbp9x7FAtXdAYhlasd60ncPpgu6ihG6TOA=="],
"update-browserslist-db": ["update-browserslist-db@1.1.3", "", { "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.1" }, "peerDependencies": { "browserslist": ">= 4.21.0" }, "bin": { "update-browserslist-db": "cli.js" } }, "sha512-UxhIZQ+QInVdunkDAaiazvvT/+fXL5Osr0JZlJulepYu6Jd7qJtDZjlur0emRlT71EN3ScPoE7gvsuIKKNavKw=="],
"uri-js": ["uri-js@4.4.1", "", { "dependencies": { "punycode": "^2.1.0" } }, "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg=="],
"use-callback-ref": ["use-callback-ref@1.3.3", "", { "dependencies": { "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg=="],
"use-sidecar": ["use-sidecar@1.1.3", "", { "dependencies": { "detect-node-es": "^1.1.0", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ=="],
"vite": ["rolldown-vite@7.1.14", "", { "dependencies": { "@oxc-project/runtime": "0.92.0", "fdir": "^6.5.0", "lightningcss": "^1.30.1", "picomatch": "^4.0.3", "postcss": "^8.5.6", "rolldown": "1.0.0-beta.41", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "esbuild": "^0.25.0", "jiti": ">=1.21.0", "less": "^4.0.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "esbuild", "jiti", "less", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-eSiiRJmovt8qDJkGyZuLnbxAOAdie6NCmmd0NkTC0RJI9duiSBTfr8X2mBYJOUFzxQa2USaHmL99J9uMxkjCyw=="],
"which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="],
"word-wrap": ["word-wrap@1.2.5", "", {}, "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA=="],
"yallist": ["yallist@5.0.0", "", {}, "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw=="],
"yocto-queue": ["yocto-queue@0.1.0", "", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="],
"@eslint-community/eslint-utils/eslint-visitor-keys": ["eslint-visitor-keys@3.4.3", "", {}, "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag=="],
"@eslint/eslintrc/globals": ["globals@14.0.0", "", {}, "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ=="],
"@tailwindcss/node/lightningcss": ["lightningcss@1.30.1", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-darwin-arm64": "1.30.1", "lightningcss-darwin-x64": "1.30.1", "lightningcss-freebsd-x64": "1.30.1", "lightningcss-linux-arm-gnueabihf": "1.30.1", "lightningcss-linux-arm64-gnu": "1.30.1", "lightningcss-linux-arm64-musl": "1.30.1", "lightningcss-linux-x64-gnu": "1.30.1", "lightningcss-linux-x64-musl": "1.30.1", "lightningcss-win32-arm64-msvc": "1.30.1", "lightningcss-win32-x64-msvc": "1.30.1" } }, "sha512-xi6IyHML+c9+Q3W0S4fCQJOym42pyurFiJUHEcEyHS0CeKzia4yZDEsLlqOFykxOdHpNy0NmvVO31vcSqAxJCg=="],
"@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.5.0", "", { "dependencies": { "@emnapi/wasi-threads": "1.1.0", "tslib": "^2.4.0" }, "bundled": true }, "sha512-sbP8GzB1WDzacS8fgNPpHlp6C9VZe+SJP3F90W9rLemaQj2PzIuTEl1qDOYQf58YIpyjViI24y9aPWCjEzY2cg=="],
"@tailwindcss/oxide-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.5.0", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-97/BJ3iXHww3djw6hYIfErCZFee7qCtrneuLa20UXFCOTCfBM2cvQHjWJ2EG0s0MtdNwInarqCTz35i4wWXHsQ=="],
"@tailwindcss/oxide-wasm32-wasi/@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.1.0", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-WI0DdZ8xFSbgMjR1sFsKABJ/C5OnRrjT06JXbZKexJGrDuPTzZdDYfFlsgcCXCyf+suG5QU2e/y1Wo2V/OapLQ=="],
"@tailwindcss/oxide-wasm32-wasi/@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.0.7", "", { "dependencies": { "@emnapi/core": "^1.5.0", "@emnapi/runtime": "^1.5.0", "@tybys/wasm-util": "^0.10.1" }, "bundled": true }, "sha512-SeDnOO0Tk7Okiq6DbXmmBODgOAb9dp9gjlphokTUxmt8U3liIP1ZsozBahH69j/RJv+Rfs6IwUKHTgQYJ/HBAw=="],
"@tailwindcss/oxide-wasm32-wasi/@tybys/wasm-util": ["@tybys/wasm-util@0.10.1", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg=="],
"@tailwindcss/oxide-wasm32-wasi/tslib": ["tslib@2.8.1", "", { "bundled": true }, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
"@typescript-eslint/eslint-plugin/ignore": ["ignore@7.0.5", "", {}, "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg=="],
"@typescript-eslint/typescript-estree/minimatch": ["minimatch@9.0.5", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow=="],
"@typescript-eslint/typescript-estree/semver": ["semver@7.7.3", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q=="],
"fast-glob/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="],
"lru-cache/yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="],
"micromatch/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="],
"rolldown/@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-beta.41", "", {}, "sha512-ycMEPrS3StOIeb87BT3/+bu+blEtyvwQ4zmo2IcJQy0Rd1DAAhKksA0iUZ3MYSpJtjlPhg0Eo6mvVS6ggPhRbw=="],
"@tailwindcss/node/lightningcss/lightningcss-darwin-arm64": ["lightningcss-darwin-arm64@1.30.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-c8JK7hyE65X1MHMN+Viq9n11RRC7hgin3HhYKhrMyaXflk5GVplZ60IxyoVtzILeKr+xAJwg6zK6sjTBJ0FKYQ=="],
"@tailwindcss/node/lightningcss/lightningcss-darwin-x64": ["lightningcss-darwin-x64@1.30.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-k1EvjakfumAQoTfcXUcHQZhSpLlkAuEkdMBsI/ivWw9hL+7FtilQc0Cy3hrx0AAQrVtQAbMI7YjCgYgvn37PzA=="],
"@tailwindcss/node/lightningcss/lightningcss-freebsd-x64": ["lightningcss-freebsd-x64@1.30.1", "", { "os": "freebsd", "cpu": "x64" }, "sha512-kmW6UGCGg2PcyUE59K5r0kWfKPAVy4SltVeut+umLCFoJ53RdCUWxcRDzO1eTaxf/7Q2H7LTquFHPL5R+Gjyig=="],
"@tailwindcss/node/lightningcss/lightningcss-linux-arm-gnueabihf": ["lightningcss-linux-arm-gnueabihf@1.30.1", "", { "os": "linux", "cpu": "arm" }, "sha512-MjxUShl1v8pit+6D/zSPq9S9dQ2NPFSQwGvxBCYaBYLPlCWuPh9/t1MRS8iUaR8i+a6w7aps+B4N0S1TYP/R+Q=="],
"@tailwindcss/node/lightningcss/lightningcss-linux-arm64-gnu": ["lightningcss-linux-arm64-gnu@1.30.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-gB72maP8rmrKsnKYy8XUuXi/4OctJiuQjcuqWNlJQ6jZiWqtPvqFziskH3hnajfvKB27ynbVCucKSm2rkQp4Bw=="],
"@tailwindcss/node/lightningcss/lightningcss-linux-arm64-musl": ["lightningcss-linux-arm64-musl@1.30.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-jmUQVx4331m6LIX+0wUhBbmMX7TCfjF5FoOH6SD1CttzuYlGNVpA7QnrmLxrsub43ClTINfGSYyHe2HWeLl5CQ=="],
"@tailwindcss/node/lightningcss/lightningcss-linux-x64-gnu": ["lightningcss-linux-x64-gnu@1.30.1", "", { "os": "linux", "cpu": "x64" }, "sha512-piWx3z4wN8J8z3+O5kO74+yr6ze/dKmPnI7vLqfSqI8bccaTGY5xiSGVIJBDd5K5BHlvVLpUB3S2YCfelyJ1bw=="],
"@tailwindcss/node/lightningcss/lightningcss-linux-x64-musl": ["lightningcss-linux-x64-musl@1.30.1", "", { "os": "linux", "cpu": "x64" }, "sha512-rRomAK7eIkL+tHY0YPxbc5Dra2gXlI63HL+v1Pdi1a3sC+tJTcFrHX+E86sulgAXeI7rSzDYhPSeHHjqFhqfeQ=="],
"@tailwindcss/node/lightningcss/lightningcss-win32-arm64-msvc": ["lightningcss-win32-arm64-msvc@1.30.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-mSL4rqPi4iXq5YVqzSsJgMVFENoa4nGTT/GjO2c0Yl9OuQfPsIfncvLrEW6RbbB24WtZ3xP/2CCmI3tNkNV4oA=="],
"@tailwindcss/node/lightningcss/lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.30.1", "", { "os": "win32", "cpu": "x64" }, "sha512-PVqXh48wh4T53F/1CCu8PIPCxLzWyCnn/9T5W1Jpmdy5h9Cwd+0YQS6/LwhHXSafuc61/xg9Lv5OrCby6a++jg=="],
"@typescript-eslint/typescript-estree/minimatch/brace-expansion": ["brace-expansion@2.0.2", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ=="],
}
}

22
frontend/components.json Normal file
View File

@ -0,0 +1,22 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "new-york",
"rsc": false,
"tsx": true,
"tailwind": {
"config": "",
"css": "src/index.css",
"baseColor": "neutral",
"cssVariables": true,
"prefix": ""
},
"iconLibrary": "lucide",
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
},
"registries": {}
}

23
frontend/eslint.config.js Normal file
View File

@ -0,0 +1,23 @@
import js from '@eslint/js'
import globals from 'globals'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
import tseslint from 'typescript-eslint'
import { defineConfig, globalIgnores } from 'eslint/config'
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
js.configs.recommended,
tseslint.configs.recommended,
reactHooks.configs['recommended-latest'],
reactRefresh.configs.vite,
],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
},
},
])

13
frontend/index.html Normal file
View File

@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>frontend</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

47
frontend/package.json Normal file
View File

@ -0,0 +1,47 @@
{
"name": "frontend",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"lint": "eslint .",
"preview": "vite preview"
},
"dependencies": {
"@radix-ui/react-checkbox": "^1.3.3",
"@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-slot": "^1.2.3",
"@radix-ui/react-tabs": "^1.1.13",
"@tailwindcss/vite": "^4.1.14",
"@tanstack/react-query": "^5.90.3",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"lucide-react": "^0.545.0",
"react": "^19.1.1",
"react-dom": "^19.1.1",
"react-hook-form": "^7.65.0",
"tailwind-merge": "^3.3.1",
"tailwindcss": "^4.1.14"
},
"devDependencies": {
"@eslint/js": "^9.36.0",
"@types/node": "^24.6.0",
"@types/react": "^19.1.16",
"@types/react-dom": "^19.1.9",
"@vitejs/plugin-react": "^5.0.4",
"babel-plugin-react-compiler": "^19.1.0-rc.3",
"eslint": "^9.36.0",
"eslint-plugin-react-hooks": "^5.2.0",
"eslint-plugin-react-refresh": "^0.4.22",
"globals": "^16.4.0",
"tw-animate-css": "^1.4.0",
"typescript": "~5.9.3",
"typescript-eslint": "^8.45.0",
"vite": "npm:rolldown-vite@7.1.14"
},
"overrides": {
"vite": "npm:rolldown-vite@7.1.14"
}
}

1
frontend/public/vite.svg Normal file
View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

View File

@ -0,0 +1,353 @@
{
"adverts": [
{
"firstSeenAt": { "max": "date", "min": "date" },
"flxId": ["string"],
"isOnline": "boolean",
"lastSeenAt": { "max": "date", "min": "date" },
"location": {
"city": "string",
"cityCoordinate": {
"location": { "lon": "number", "lat": "number" }
},
"department": "string",
"inseeCode": "string",
"irisCode": "string",
"locationCoordinate": {
"location": { "lon": "number", "lat": "number" }
},
"postalCode": "string"
},
"price": {
"currency": ["CURRENCY_EUR", "CURRENCY_USD"],
"initial": {
"source": {
"flxId": "string",
"url": "string",
"website": "string"
},
"value": { "max": "number", "min": "number" },
"valuePerArea": { "max": "number", "min": "number" }
},
"isAuction": "boolean",
"latest": {
"source": {
"flxId": "string",
"url": "string",
"website": "string"
},
"value": { "max": "number", "min": "number" },
"valuePerArea": { "max": "number", "min": "number" }
},
"scope": ["PRICING_ONE_OFF", "PRICING_MENSUAL"],
"warrantyDeposit": { "max": "number", "min": "number" },
"variation": [
{
"sinceLastModified": { "max": "number", "min": "number" },
"sincePublished": { "max": "number", "min": "number" }
}
]
},
"source": { "flxId": "string", "url": "string", "website": "string" },
"isPro": "boolean",
"seller": [
{
"flxId": "string",
"name": "string",
"siren": "string",
"type": "SELLER_TYPE_UNKNOWN | SELLER_TYPE_AGENCY | SELLER_TYPE_NETWORK"
}
],
"hasAnomaly": "boolean",
"isExclusive": "boolean"
}
],
"habitation": {
"bathroomCount": { "max": "number", "min": "number" },
"bedroomCount": { "max": "number", "min": "number" },
"characteristics": {
"hasAlarm": "boolean",
"hasBalcony": "boolean",
"hasCellar": "boolean",
"hasConcierge": "boolean",
"hasDigicode": "boolean",
"hasFireplace": "boolean",
"hasGarage": "boolean",
"hasGarden": "boolean",
"hasGrenier": "boolean",
"hasInterphone": "boolean",
"hasJacuzzi": "boolean",
"hasLand": "boolean",
"hasLift": "boolean",
"hasMezzanine": "boolean",
"hasParking": "boolean",
"hasPool": "boolean",
"hasTerrace": "boolean",
"hasVisAVis": "boolean",
"isPeaceful": "boolean"
},
"climate": {
"epcClimate": [
"GREENHOUSE_CLASSIFICATION_UNKNOWN",
"GREENHOUSE_CLASSIFICATION_G",
"GREENHOUSE_CLASSIFICATION_F",
"GREENHOUSE_CLASSIFICATION_E",
"GREENHOUSE_CLASSIFICATION_D",
"GREENHOUSE_CLASSIFICATION_C",
"GREENHOUSE_CLASSIFICATION_B",
"GREENHOUSE_CLASSIFICATION_A",
"GREENHOUSE_CLASSIFICATION_NC"
],
"epcClimateScore": { "max": "number", "min": "number" },
"epcEnergy": [
"ENERGY_CLASSIFICATION_UNKNOWN",
"ENERGY_CLASSIFICATION_G",
"ENERGY_CLASSIFICATION_F",
"ENERGY_CLASSIFICATION_E",
"ENERGY_CLASSIFICATION_D",
"ENERGY_CLASSIFICATION_C",
"ENERGY_CLASSIFICATION_B",
"ENERGY_CLASSIFICATION_A",
"ENERGY_CLASSIFICATION_NC"
],
"epcEnergyScore": { "max": "number", "min": "number" },
"epcClimateDate": { "max": "date", "min": "date" },
"epcEnergyDate": { "max": "date", "min": "date" }
},
"features": {
"exposure": [
"EXPOSURE_UNKNOWN",
"EXPOSURE_NORTH",
"EXPOSURE_NORTH_EAST",
"EXPOSURE_EAST",
"EXPOSURE_SOUTH_EAST",
"EXPOSURE_SOUTH",
"EXPOSURE_SOUTH_WEST",
"EXPOSURE_WEST",
"EXPOSURE_NORTH_WEST"
],
"furniture": [
"UNKNOWN_FURNITURE",
"UNFURNISHED",
"PARTIALLY_FURNISHED",
"FULLY_FURNISHED"
],
"propertyFloor": { "max": "number", "min": "number" },
"propertyTotalFloor": { "max": "number", "min": "number" },
"constructionMaterials": ["string"],
"glazingTypes": ["string"],
"hasThroughExposure": "boolean",
"viewOns": ["string"],
"viewTypes": ["string"]
},
"heatTypes": [
"HEAT_TYPE_UNKNOWN",
"HEAT_TYPE_INDIVIDUAL",
"HEAT_TYPE_SHARED",
"HEAT_TYPE_MIX",
"HEAT_TYPE_CITY",
"HEAT_TYPE_CISTERN"
],
"heatTypeDetails": [
"HEAT_DETAIL_UNKNOWN",
"HEAT_DETAIL_FLOOR",
"HEAT_DETAIL_CEILING",
"HEAT_DETAIL_FIREPLACE",
"HEAT_DETAIL_INSERT",
"HEAT_DETAIL_AIR_CON",
"HEAT_DETAIL_REVERSIBLE_AIR_CON",
"HEAT_DETAIL_RADIANTS_TUBES",
"HEAT_DETAIL_RADIATOR",
"HEAT_DETAIL_SHEAHS",
"HEAT_DETAIL_CONVECTOR"
],
"heatings": [
"HEATING_UNKNOWN",
"HEATING_FUEL_OIL",
"HEATING_ELECTRICAL",
"HEATING_GAS",
"HEATING_BIOMASS",
"HEATING_SOLAR",
"HEATING_GEOTHERMAL",
"HEATING_WOOD",
"HEATING_HEAT_PUMP",
"HEATING_PELLET",
"HEATING_CANADIAN_WELL",
"HEATING_COAL"
],
"propertyCondition": [
{
"constructionYear": "number",
"interiorCondition": [
"INTERIOR_CONDITION_UNKNOWN",
"INTERIOR_CONDITION_EXCELLENT",
"INTERIOR_CONDITION_TO_REFRESH",
"INTERIOR_CONDITION_SMALL_WORKS_TO_BE_PLANNED",
"INTERIOR_CONDITION_MAJOR_WORKS_TO_BE_PALLNED",
"INTERIOR_CONDITION_BRAND_NEW",
"INTERIOR_CONDITION_GOOD_CONDITION",
"INTERIOR_CONDITION_TO_BE_RENOVATED"
],
"renovationYear": "number",
"generalConditions": ["string"]
}
],
"roomCount": { "max": "number", "min": "number" },
"surface": {
"balconies": { "max": "number", "min": "number" },
"floorSpace": { "max": "number", "min": "number" },
"gardens": { "max": "number", "min": "number" },
"groundFloor": { "max": "number", "min": "number" },
"kitchen": { "max": "number", "min": "number" },
"livingSpace": { "max": "number", "min": "number" },
"livingroom": { "max": "number", "min": "number" },
"terraces": { "max": "number", "min": "number" },
"total": { "max": "number", "min": "number" }
},
"type": [
"PROPERTY_TYPE_UNKNOWN",
"PROPERTY_TYPE_STUDIO",
"PROPERTY_TYPE_T1",
"PROPERTY_TYPE_T1_T2",
"PROPERTY_TYPE_T2",
"PROPERTY_TYPE_T2_T3",
"PROPERTY_TYPE_T3",
"PROPERTY_TYPE_T3_4",
"PROPERTY_TYPE_T4",
"PROPERTY_TYPE_T4_5",
"PROPERTY_TYPE_T5_MORE",
"PROPERTY_TYPE_LOFT",
"PROPERTY_TYPE_DUPLEX",
"PROPERTY_TYPE_OTHER_APARTMENT_TYPE",
"PROPERTY_TYPE_INDIVIDUAL",
"PROPERTY_TYPE_ONE_SIDE_TERRACED",
"PROPERTY_TYPE_TWO_SIDE_TERRACED",
"PROPERTY_TYPE_SINGLE_STOREY",
"PROPERTY_TYPE_TRADITIONAL",
"PROPERTY_TYPE_CONTEMPORARY",
"PROPERTY_TYPE_BOURGEOIS",
"PROPERTY_TYPE_VILLA",
"PROPERTY_TYPE_MANOR",
"PROPERTY_TYPE_CASTLE",
"PROPERTY_TYPE_FARM",
"PROPERTY_TYPE_MAS",
"PROPERTY_TYPE_BASTIDE",
"PROPERTY_TYPE_CHALET",
"PROPERTY_TYPE_ANCIENT",
"PROPERTY_TYPE_HERITAGE_LISTED",
"PROPERTY_TYPE_BUNGALOW"
],
"wcCount": { "max": "number", "min": "number" }
},
"isUrgent": "boolean",
"land": {
"canConstruct": "boolean",
"isServiced": "boolean",
"surface": { "max": "number", "min": "number" },
"surfaceConstructable": { "max": "number", "min": "number" },
"type": [
"LAND_UNKNOWN",
"LAND_BUILDING_PLOT",
"LAND_AGRICULTURAL",
"LAND_VINEYARD",
"LAND_INDUSTRIAL",
"LAND_POND",
"LAND_FOREST"
],
"haveBuildingPermit": "boolean",
"haveElectricity": "boolean",
"haveTelecom": "boolean",
"haveWater": "boolean"
},
"location": [
{
"city": "string",
"cityCoordinate": { "location": { "lon": "number", "lat": "number" } },
"department": "string",
"inseeCode": "string",
"irisCode": "string",
"locationCoordinate": {
"location": { "lon": "number", "lat": "number" }
},
"postalCode": "string"
}
],
"meta": {
"firstSeenAt": { "max": "date", "min": "date" },
"isTotallyOffline": "boolean",
"lastPublishedAt": { "max": "date", "min": "date" },
"lastSeenAt": { "max": "date", "min": "date" },
"lastUpdatedAt": { "max": "date", "min": "date" }
},
"parking": {
"count": { "max": "number", "min": "number" },
"numberOfCars": { "max": "number", "min": "number" },
"surface": { "max": "number", "min": "number" },
"type": ["PARKING_UNKNOWN", "PARKING_GARAGE", "PARKING_PARKING"]
},
"price": {
"currency": ["CURRENCY_EUR", "CURRENCY_USD"],
"initial": {
"source": { "flxId": "string", "url": "string", "website": "string" },
"value": { "max": "number", "min": "number" },
"valuePerArea": { "max": "number", "min": "number" }
},
"isAuction": "boolean",
"latest": {
"source": { "flxId": "string", "url": "string", "website": "string" },
"value": { "max": "number", "min": "number" },
"valuePerArea": { "max": "number", "min": "number" }
},
"scope": ["PRICING_ONE_OFF", "PRICING_MENSUAL"],
"warrantyDeposit": { "max": "number", "min": "number" },
"variation": [
{
"sinceLastModified": { "max": "number", "min": "number" },
"sincePublished": { "max": "number", "min": "number" }
}
]
},
"process": [
"PROCESS_UNKNOWN",
"PROCESS_AVAILABLE_ON_MARKET",
"PROCESS_UNDER_COMPROMISE",
"PROCESS_RENTED_SOLD",
"PROCESS_REMOVED",
"PROCESS_RESERVED",
"PROCESS_ARCHIVED"
],
"tags": ["string"],
"type": [
"CLASS_UNKNOWN",
"CLASS_HOUSE",
"CLASS_FLAT",
"CLASS_PROGRAM",
"CLASS_SHOP",
"CLASS_PREMISES",
"CLASS_OFFICE",
"CLASS_LAND",
"CLASS_BUILDING",
"CLASS_PARKING"
],
"hasAnomaly": "boolean",
"offer": [
{
"isCurrentlyOccupied": "boolean",
"renting": {
"isColocation": "boolean",
"isLongTerm": "boolean",
"isShortTerm": "boolean",
"isSubLease": "boolean"
},
"type": [
"OFFER_UNKNOWN",
"OFFER_BUY",
"OFFER_RENT",
"OFFER_BUSINESS_TAKE_OVER",
"OFFER_LEASE_BACK",
"OFFER_LIFE_ANNUITY_SALE",
"OFFER_HOLIDAYS"
]
}
]
}

91
frontend/src/App.tsx Normal file
View File

@ -0,0 +1,91 @@
import { useEffect, useState } from "react";
import { clearSessionToken, getSessionToken, setSessionToken } from "@/lib/api";
import { ProfilesTab } from "@/features/profiles/ProfilesTab";
import { ScrapersTab } from "@/features/scrapers/ScrapersTab";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
function SessionTokenPanel() {
const [token, setToken] = useState<string>(() => getSessionToken() ?? "");
const [feedback, setFeedback] = useState<string | null>(null);
useEffect(() => {
setToken(getSessionToken() ?? "");
}, []);
const handleSave = () => {
setSessionToken(token.trim());
setFeedback("Token sauvegarde.");
};
const handleClear = () => {
clearSessionToken();
setToken("");
setFeedback("Token efface.");
};
return (
<Card>
<CardHeader>
<CardTitle>Session API</CardTitle>
<CardDescription>
Fournissez le jeton recu depuis <code>/auth/login</code> pour authentifier les appels.
</CardDescription>
</CardHeader>
<CardContent>
<div className="flex flex-col gap-3 md:flex-row md:items-center">
<Input
placeholder="X-Session-Token"
value={token}
onChange={(event) => {
setToken(event.target.value);
setFeedback(null);
}}
/>
<div className="flex gap-2">
<Button type="button" onClick={handleSave}>
Sauvegarder
</Button>
<Button type="button" variant="outline" onClick={handleClear}>
Effacer
</Button>
</div>
</div>
{feedback ? (
<p className="text-muted-foreground mt-3 text-sm">{feedback}</p>
) : null}
</CardContent>
</Card>
);
}
function App() {
return (
<div className="mx-auto flex max-w-6xl flex-col gap-6 p-6">
<SessionTokenPanel />
<Tabs defaultValue="profiles" className="space-y-6">
<TabsList>
<TabsTrigger value="profiles">Profils</TabsTrigger>
<TabsTrigger value="scrapers">Scrapers</TabsTrigger>
</TabsList>
<TabsContent value="profiles">
<ProfilesTab />
</TabsContent>
<TabsContent value="scrapers">
<ScrapersTab />
</TabsContent>
</Tabs>
</div>
);
}
export default App;

View File

@ -0,0 +1,26 @@
import { useForm } from "react-hook-form";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "./components/ui/card";
export type ScraperFormData = {
name: "string";
params: "string";
};
export const ScraperPage = () => {
const {} = useForm<ScraperFormData>();
return (
<Card>
<CardHeader>
<CardTitle></CardTitle>
<CardDescription></CardDescription>
</CardHeader>
<CardContent></CardContent>
</Card>
);
};

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>

After

Width:  |  Height:  |  Size: 4.0 KiB

View File

@ -0,0 +1,392 @@
import { useEffect, useMemo, useState } from "react";
import {
BracesIcon,
CodeIcon,
PlusIcon,
Trash2Icon,
} from "lucide-react";
import { cn } from "@/lib/utils";
import { Button } from "./ui/button";
import { Input } from "./ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "./ui/select";
import { Textarea } from "./ui/textarea";
type JsonPrimitiveType = "string" | "number" | "boolean" | "null" | "json";
const jsonTypeLabels: Record<JsonPrimitiveType, string> = {
string: "Texte",
number: "Nombre",
boolean: "Booleen",
null: "Null",
json: "JSON",
};
export type JsonObject = Record<string, unknown>;
interface JsonEntry {
id: string;
key: string;
type: JsonPrimitiveType;
value: string;
}
export interface JsonBuilderProps {
value?: JsonObject;
onChange: (nextValue: JsonObject) => void;
label?: string;
description?: string;
error?: string;
className?: string;
}
function createEntry(partial?: Partial<JsonEntry>): JsonEntry {
const id = typeof crypto !== "undefined" && crypto.randomUUID
? crypto.randomUUID()
: Math.random().toString(36).slice(2);
return {
id,
key: "",
type: "string",
value: "",
...partial,
};
}
function formatJson(value?: JsonObject) {
return JSON.stringify(value ?? {}, null, 2);
}
function isPlainObject(value: unknown): value is JsonObject {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function entriesFromValue(value?: JsonObject): JsonEntry[] {
if (!value || !isPlainObject(value)) {
return [createEntry()];
}
if (Object.keys(value).length === 0) {
return [createEntry()];
}
return Object.entries(value).map(([key, raw]) => {
if (raw === null) {
return createEntry({ key, type: "null", value: "" });
}
if (typeof raw === "boolean") {
return createEntry({ key, type: "boolean", value: raw ? "true" : "false" });
}
if (typeof raw === "number") {
return createEntry({ key, type: "number", value: String(raw) });
}
if (typeof raw === "string") {
return createEntry({ key, type: "string", value: raw });
}
return createEntry({ key, type: "json", value: JSON.stringify(raw, null, 2) });
});
}
function parseEntries(entries: JsonEntry[]) {
const result: JsonObject = {};
const errors: Record<string, string> = {};
const usedKeys = new Set<string>();
entries.forEach((entry) => {
const trimmedKey = entry.key.trim();
if (!trimmedKey) {
errors[entry.id] = "La cle est obligatoire.";
return;
}
if (usedKeys.has(trimmedKey)) {
errors[entry.id] = "Cette cle est deja utilisee.";
return;
}
switch (entry.type) {
case "string": {
result[trimmedKey] = entry.value;
break;
}
case "number": {
if (entry.value.trim() === "") {
errors[entry.id] = "Entrez un nombre.";
return;
}
const numeric = Number(entry.value);
if (Number.isNaN(numeric)) {
errors[entry.id] = "Valeur numerique invalide.";
return;
}
result[trimmedKey] = numeric;
break;
}
case "boolean": {
result[trimmedKey] = entry.value === "true";
break;
}
case "null": {
result[trimmedKey] = null;
break;
}
case "json": {
if (entry.value.trim() === "") {
errors[entry.id] = "Entrez un JSON valide.";
return;
}
try {
result[trimmedKey] = JSON.parse(entry.value);
} catch (parseError) {
errors[entry.id] = "JSON invalide.";
}
break;
}
default: {
errors[entry.id] = "Type non supporte.";
}
}
usedKeys.add(trimmedKey);
});
return { result, errors };
}
export function JsonBuilderField({
value,
onChange,
label,
description,
error,
className,
}: JsonBuilderProps) {
const [mode, setMode] = useState<"builder" | "raw">("builder");
const [entries, setEntries] = useState<JsonEntry[]>(() => entriesFromValue(value));
const [entryErrors, setEntryErrors] = useState<Record<string, string>>({});
const [rawValue, setRawValue] = useState<string>(() => formatJson(value));
const [rawError, setRawError] = useState<string | null>(null);
useEffect(() => {
setEntries(entriesFromValue(value));
setRawValue(formatJson(value));
}, [value]);
const hasBuilderErrors = useMemo(
() => Object.keys(entryErrors).length > 0,
[entryErrors]
);
useEffect(() => {
if (mode === "builder") {
const { result, errors } = parseEntries(entries);
setEntryErrors(errors);
if (Object.keys(errors).length === 0) {
onChange(result);
setRawValue(formatJson(result));
setRawError(null);
}
}
}, [entries, mode, onChange]);
const handleAddEntry = () => {
setEntries((current) => [...current, createEntry()]);
};
const handleRemoveEntry = (id: string) => {
setEntries((current) => current.filter((entry) => entry.id !== id));
};
const handleEntryChange = (id: string, update: Partial<Omit<JsonEntry, "id">>) => {
setEntries((current) =>
current.map((entry) => (entry.id === id ? { ...entry, ...update } : entry))
);
};
const builderContent = (
<div className="space-y-3">
{entries.map((entry) => {
const entryError = entryErrors[entry.id];
return (
<div key={entry.id} className="space-y-1">
<div className="grid gap-2 sm:grid-cols-[minmax(0,2fr)_minmax(0,140px)_minmax(0,2fr)_auto] sm:items-start">
<Input
placeholder="Cle"
value={entry.key}
onChange={(event) => handleEntryChange(entry.id, { key: event.target.value })}
/>
<Select
value={entry.type}
onValueChange={(type) => handleEntryChange(entry.id, { type: type as JsonPrimitiveType })}
>
<SelectTrigger size="sm">
<SelectValue aria-label={jsonTypeLabels[entry.type]}>
{jsonTypeLabels[entry.type]}
</SelectValue>
</SelectTrigger>
<SelectContent>
{Object.entries(jsonTypeLabels).map(([type, label]) => (
<SelectItem key={type} value={type}>
{label}
</SelectItem>
))}
</SelectContent>
</Select>
{entry.type === "boolean" ? (
<Select
value={entry.value || "false"}
onValueChange={(next) => handleEntryChange(entry.id, { value: next })}
>
<SelectTrigger size="sm">
<SelectValue>
{entry.value === "true" ? "true" : "false"}
</SelectValue>
</SelectTrigger>
<SelectContent>
<SelectItem value="true">true</SelectItem>
<SelectItem value="false">false</SelectItem>
</SelectContent>
</Select>
) : entry.type === "json" ? (
<Textarea
className="min-h-[80px]"
placeholder={`{
"cle": "valeur"
}`}
value={entry.value}
onChange={(event) => handleEntryChange(entry.id, { value: event.target.value })}
/>
) : entry.type === "null" ? (
<Input value="null" readOnly disabled />
) : (
<Input
placeholder={entry.type === "number" ? "Nombre" : "Valeur"}
value={entry.value}
onChange={(event) => handleEntryChange(entry.id, { value: event.target.value })}
/>
)}
<Button
type="button"
variant="ghost"
size="icon-sm"
onClick={() => handleRemoveEntry(entry.id)}
aria-label="Supprimer ce champ"
>
<Trash2Icon className="size-4" />
</Button>
</div>
{entryError ? (
<p className="text-destructive text-xs">{entryError}</p>
) : null}
</div>
);
})}
<Button type="button" variant="outline" size="sm" onClick={handleAddEntry}>
<PlusIcon className="size-4" />
Ajouter un champ
</Button>
</div>
);
const handleRawChange = (nextValue: string) => {
setRawValue(nextValue);
try {
if (nextValue.trim() === "") {
onChange({});
setEntries(entriesFromValue({}));
setRawError(null);
setEntryErrors({});
return;
}
const parsed = JSON.parse(nextValue);
if (!isPlainObject(parsed)) {
throw new Error("Le JSON doit etre un objet a la racine.");
}
setRawError(null);
setEntries(entriesFromValue(parsed));
onChange(parsed);
} catch (parseError) {
setRawError(parseError instanceof Error ? parseError.message : String(parseError));
}
};
const rawContent = (
<div className="space-y-2">
<Textarea
className="min-h-[220px] font-mono"
value={rawValue}
onChange={(event) => handleRawChange(event.target.value)}
placeholder={`{
"cle": "valeur"
}`}
/>
{rawError ? <p className="text-destructive text-xs">{rawError}</p> : null}
</div>
);
return (
<div className={cn("space-y-3", className)}>
{label ? <label className="text-sm font-medium">{label}</label> : null}
{description ? <p className="text-muted-foreground text-xs">{description}</p> : null}
<div className="flex items-center gap-2">
<Button
type="button"
variant={mode === "builder" ? "default" : "outline"}
size="sm"
onClick={() => setMode("builder")}
>
<BracesIcon className="size-4" />
Constructeur
</Button>
<Button
type="button"
variant={mode === "raw" ? "default" : "outline"}
size="sm"
onClick={() => setMode("raw")}
>
<CodeIcon className="size-4" />
JSON brut
</Button>
</div>
{mode === "builder" ? builderContent : rawContent}
{mode === "builder" && hasBuilderErrors ? (
<p className="text-destructive text-xs">
Corrigez les erreurs indiquees pour generer un JSON valide.
</p>
) : null}
{error ? <p className="text-destructive text-xs">{error}</p> : null}
</div>
);
}

View File

@ -0,0 +1,129 @@
import { useEffect, useMemo, useState } from "react";
import type { JsonObject } from "@/components/json-fields";
import { Button } from "@/components/ui/button";
import { Textarea } from "@/components/ui/textarea";
import { SchemaBuilder } from "./SchemaBuilder";
import { validateSchemaValue } from "./useSchemaValidation";
import { buildDefaultValue, cloneWithDefaults } from "@/schemas/utils";
import type { SchemaDefinition, SchemaValidationError } from "@/schemas/types";
interface SchemaAwareJsonFieldProps {
schema: SchemaDefinition;
value?: JsonObject;
onChange: (value: JsonObject) => void;
label?: string;
description?: string;
onValidationChange?: (payload: { valid: boolean; errors: SchemaValidationError[] }) => void;
}
type BuilderMode = "schema" | "raw";
export function SchemaAwareJsonField({
schema,
value,
onChange,
label,
description,
onValidationChange,
}: SchemaAwareJsonFieldProps) {
const [mode, setMode] = useState<BuilderMode>("schema");
const [rawValue, setRawValue] = useState(() =>
JSON.stringify(value ?? (buildDefaultValue(schema) as JsonObject), null, 2)
);
const [validation, setValidation] = useState<{ valid: boolean; errors: SchemaValidationError[] }>(
{ valid: true, errors: [] }
);
const [rawError, setRawError] = useState<string | null>(null);
const resolvedValue = useMemo(() => {
const base = cloneWithDefaults(schema, value);
return (base ?? buildDefaultValue(schema)) as JsonObject;
}, [schema, value]);
useEffect(() => {
if (!value) {
onChange(resolvedValue);
}
}, [value, onChange, resolvedValue]);
useEffect(() => {
setRawValue(JSON.stringify(resolvedValue, null, 2));
}, [resolvedValue]);
useEffect(() => {
onValidationChange?.(validation);
}, [validation, onValidationChange]);
return (
<div className="space-y-3">
<div className="flex items-center gap-2">
<Button
type="button"
size="sm"
variant={mode === "schema" ? "default" : "outline"}
onClick={() => setMode("schema")}
>
Mode schema
</Button>
<Button
type="button"
size="sm"
variant={mode === "raw" ? "default" : "outline"}
onClick={() => setMode("raw")}
>
Mode brut
</Button>
</div>
{mode === "schema" ? (
<SchemaBuilder
schema={schema}
value={resolvedValue}
onChange={(next) => {
onChange(next as JsonObject);
setRawValue(JSON.stringify(next, null, 2));
}}
onValidationChange={setValidation}
/>
) : (
<div className="space-y-2">
{label ? <p className="text-sm font-medium">{label}</p> : null}
{description ? <p className="text-muted-foreground text-xs">{description}</p> : null}
<Textarea
className="min-h-[220px] font-mono"
value={rawValue}
onChange={(event) => {
const nextValue = event.target.value;
setRawValue(nextValue);
try {
const parsed = JSON.parse(nextValue);
if (parsed && typeof parsed === "object") {
const validationResult = validateSchemaValue(schema, parsed);
const isValid = validationResult.length === 0;
onChange(parsed as JsonObject);
setValidation({ valid: isValid, errors: validationResult });
setRawError(isValid ? null : "Le JSON ne respecte pas le schema.");
} else {
setRawError("Le JSON doit etre un objet.");
setValidation({ valid: false, errors: [] });
}
} catch (parseError) {
setRawError("JSON invalide.");
setValidation({ valid: false, errors: [] });
}
}}
/>
{rawError ? <p className="text-destructive text-xs">{rawError}</p> : null}
</div>
)}
{mode === "raw" ? null : validation.valid ? null : (
<div className="rounded-md border border-destructive/40 bg-destructive/5 p-3">
<p className="text-destructive text-sm font-medium">Le JSON ne respecte pas le schema.</p>
</div>
)}
</div>
);
}

View File

@ -0,0 +1,43 @@
import { useEffect, useMemo, useState } from "react";
import { SchemaFieldRenderer } from "./SchemaFieldRenderer";
import { useSchemaValidation } from "./useSchemaValidation";
import type { SchemaNode, SchemaValidationError } from "@/schemas/types";
interface SchemaBuilderProps {
schema: SchemaNode;
value: unknown;
onChange: (value: unknown) => void;
onValidationChange?: (payload: { valid: boolean; errors: SchemaValidationError[] }) => void;
}
export function SchemaBuilder({ schema, value, onChange, onValidationChange }: SchemaBuilderProps) {
const { validate, buildErrorMap } = useSchemaValidation(schema);
const [errors, setErrors] = useState<SchemaValidationError[]>([]);
useEffect(() => {
const nextErrors = validate(value);
setErrors(nextErrors);
onValidationChange?.({ valid: nextErrors.length === 0, errors: nextErrors });
}, [value, validate, onValidationChange]);
const errorMap = useMemo(() => buildErrorMap(value), [buildErrorMap, value]);
return (
<div className="space-y-4">
<SchemaFieldRenderer schema={schema} value={value} onChange={onChange} path="" errorMap={errorMap} />
{errors.length > 0 ? (
<div className="rounded-md border border-destructive/40 bg-destructive/5 p-3">
<p className="text-destructive text-sm font-medium">{errors.length} erreur(s) detectee(s).</p>
<ul className="text-destructive mt-2 space-y-1 text-xs">
{errors.map((error, index) => (
<li key={`${error.path}-${index}`}>{error.path || "racine"}: {error.message}</li>
))}
</ul>
</div>
) : null}
</div>
);
}

View File

@ -0,0 +1,25 @@
import type { PropsWithChildren } from "react";
interface SchemaFieldLabelProps {
id?: string;
description?: string;
pathLabel?: string;
}
export function SchemaFieldLabel({
id,
description,
pathLabel,
children,
}: PropsWithChildren<SchemaFieldLabelProps>) {
return (
<div className="space-y-1">
<label htmlFor={id} className="text-sm font-medium">
{children}
{pathLabel ? <span className="text-muted-foreground ml-2 text-xs">{pathLabel}</span> : null}
</label>
{description ? <p className="text-muted-foreground text-xs">{description}</p> : null}
</div>
);
}

View File

@ -0,0 +1,342 @@
import { Fragment } from "react";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Textarea } from "@/components/ui/textarea";
import { buildDefaultValue } from "@/schemas/utils";
import type {
SchemaArrayNode,
SchemaEnumNode,
SchemaNode,
SchemaObjectField,
SchemaPrimitiveNode,
SchemaRangeNode,
SchemaValidationError,
} from "@/schemas/types";
import { SchemaFieldLabel } from "./SchemaFieldLabel";
interface SchemaFieldRendererProps {
schema: SchemaNode;
value: unknown;
onChange: (value: unknown) => void;
path: string;
errorMap: Record<string, SchemaValidationError[]>;
}
export function SchemaFieldRenderer({ schema, value, onChange, path, errorMap }: SchemaFieldRendererProps) {
switch (schema.kind) {
case "primitive":
return renderPrimitive(schema, value, onChange, path, errorMap);
case "enum":
return renderEnum(schema, value, onChange, path, errorMap);
case "range":
return renderRange(schema, value, onChange, path, errorMap);
case "array":
return renderArray(schema, value, onChange, path, errorMap);
case "object":
return renderObject(schema, value, onChange, path, errorMap);
default:
return null;
}
}
function renderPrimitive(
schema: SchemaPrimitiveNode,
value: unknown,
onChange: (value: unknown) => void,
path: string,
errorMap: Record<string, SchemaValidationError[]>
) {
const error = getFirstError(errorMap, path);
if (schema.type === "boolean") {
return (
<div className="space-y-1">
<label className="flex items-center gap-2 text-sm font-medium">
<Checkbox
checked={Boolean(value)}
onCheckedChange={(checked) => onChange(Boolean(checked))}
/>
{schema.label ?? path.split(".").slice(-1)[0] ?? "Valeur"}
</label>
{schema.description ? <p className="text-muted-foreground text-xs">{schema.description}</p> : null}
</div>
);
}
if (schema.type === "string" && (schema.label?.toLowerCase().includes("json") ?? false)) {
return (
<div className="space-y-1">
<SchemaFieldLabel description={schema.description}>{schema.label ?? getLabelFromPath(path)}</SchemaFieldLabel>
<Textarea
value={typeof value === "string" ? value : ""}
onChange={(event) => onChange(event.target.value)}
aria-invalid={Boolean(error)}
/>
{error ? <FieldError message={error.message} /> : null}
</div>
);
}
const inputType = schema.type === "number" ? "number" : schema.type === "date" ? "datetime-local" : "text";
return (
<div className="space-y-1">
<SchemaFieldLabel description={schema.description}>{schema.label ?? getLabelFromPath(path)}</SchemaFieldLabel>
<Input
type={inputType}
value={formatPrimitiveValue(schema, value)}
onChange={(event) => handlePrimitiveChange(schema, event.target.value, onChange)}
aria-invalid={Boolean(error)}
/>
{error ? <FieldError message={error.message} /> : null}
</div>
);
}
function renderEnum(
schema: SchemaEnumNode,
value: unknown,
onChange: (value: unknown) => void,
path: string,
errorMap: Record<string, SchemaValidationError[]>
) {
const error = getFirstError(errorMap, path);
if (schema.multiple) {
const current = Array.isArray(value) ? (value as string[]) : [];
return (
<div className="space-y-2">
<SchemaFieldLabel description={schema.description}>{schema.label ?? getLabelFromPath(path)}</SchemaFieldLabel>
<div className="flex flex-wrap gap-3">
{schema.options.map((option) => {
const checked = current.includes(option);
return (
<label key={option} className="flex items-center gap-2 text-sm">
<Checkbox
checked={checked}
onCheckedChange={(next) => {
if (next) {
onChange([...current, option]);
} else {
onChange(current.filter((item) => item !== option));
}
}}
/>
{option}
</label>
);
})}
</div>
{error ? <FieldError message={error.message} /> : null}
</div>
);
}
return (
<div className="space-y-1">
<SchemaFieldLabel description={schema.description}>{schema.label ?? getLabelFromPath(path)}</SchemaFieldLabel>
<Select
value={(value as string) ?? ""}
onValueChange={(next) => onChange(next)}
>
<SelectTrigger aria-invalid={Boolean(error)}>
<SelectValue placeholder="Selectionner" />
</SelectTrigger>
<SelectContent>
{schema.options.map((option) => (
<SelectItem key={option} value={option}>
{option}
</SelectItem>
))}
</SelectContent>
</Select>
{error ? <FieldError message={error.message} /> : null}
</div>
);
}
function renderRange(
schema: SchemaRangeNode,
value: unknown,
onChange: (value: unknown) => void,
path: string,
errorMap: Record<string, SchemaValidationError[]>
) {
const record = (value && typeof value === "object" ? value : {}) as Record<string, unknown>;
return (
<div className="space-y-2">
<SchemaFieldLabel description={schema.description}>{schema.label ?? getLabelFromPath(path)}</SchemaFieldLabel>
<div className="grid gap-2 sm:grid-cols-2">
<SchemaFieldRenderer
schema={{ kind: "primitive", type: schema.valueType, required: false }}
value={record.min}
onChange={(next) => onChange({ ...record, min: next })}
path={`${path}.min`}
errorMap={errorMap}
/>
<SchemaFieldRenderer
schema={{ kind: "primitive", type: schema.valueType, required: false }}
value={record.max}
onChange={(next) => onChange({ ...record, max: next })}
path={`${path}.max`}
errorMap={errorMap}
/>
</div>
</div>
);
}
function renderArray(
schema: SchemaArrayNode,
value: unknown,
onChange: (value: unknown) => void,
path: string,
errorMap: Record<string, SchemaValidationError[]>
) {
const items = Array.isArray(value) ? value : [];
return (
<div className="space-y-3">
<SchemaFieldLabel description={schema.description}>{schema.label ?? getLabelFromPath(path)}</SchemaFieldLabel>
<div className="space-y-3">
{items.map((item, index) => (
<div key={index} className="rounded-md border p-3">
<div className="flex items-center justify-between">
<span className="text-sm text-muted-foreground">Element {index + 1}</span>
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => {
const next = items.filter((_, itemIndex) => itemIndex !== index);
onChange(next);
}}
>
Supprimer
</Button>
</div>
<div className="mt-3 space-y-3">
<SchemaFieldRenderer
schema={schema.element}
value={item}
onChange={(next) => {
const copy = [...items];
copy[index] = next;
onChange(copy);
}}
path={`${path}[${index}]`}
errorMap={errorMap}
/>
</div>
</div>
))}
</div>
<Button
type="button"
variant="outline"
size="sm"
onClick={() => {
onChange([...items, buildDefaultValue(schema.element)]);
}}
>
Ajouter un element
</Button>
</div>
);
}
function renderObject(
schema: SchemaObjectNode,
value: unknown,
onChange: (value: unknown) => void,
path: string,
errorMap: Record<string, SchemaValidationError[]>
) {
const record = (value && typeof value === "object" ? value : {}) as Record<string, unknown>;
return (
<div className="space-y-4">
{schema.fields.map((field) => (
<Fragment key={field.key}>
<SchemaFieldRenderer
schema={field.schema}
value={record[field.key]}
onChange={(next) => onChange({ ...record, [field.key]: next })}
path={path ? `${path}.${field.key}` : field.key}
errorMap={errorMap}
/>
</Fragment>
))}
</div>
);
}
function getLabelFromPath(path: string) {
const parts = path.split(".");
const last = parts[parts.length - 1] ?? "";
return last.replace(/([A-Z])/g, " $1").replace(/_/g, " ").trim() || "Valeur";
}
function handlePrimitiveChange(
schema: SchemaPrimitiveNode,
rawValue: string,
onChange: (value: unknown) => void
) {
if (schema.type === "number") {
if (rawValue.trim() === "") {
onChange(null);
return;
}
onChange(Number(rawValue));
return;
}
if (schema.type === "boolean") {
onChange(rawValue === "true");
return;
}
onChange(rawValue);
}
function formatPrimitiveValue(schema: SchemaPrimitiveNode, value: unknown) {
if (value === null || value === undefined) {
return "";
}
if (schema.type === "number") {
return typeof value === "number" ? String(value) : "";
}
if (schema.type === "date") {
return typeof value === "string" ? value : "";
}
return typeof value === "string" ? value : "";
}
function getFirstError(errorMap: Record<string, SchemaValidationError[]>, path: string) {
const errors = errorMap[path];
return errors ? errors[0] : null;
}
interface FieldErrorProps {
message: string;
}
function FieldError({ message }: FieldErrorProps) {
return <p className="text-destructive text-xs">{message}</p>;
}

View File

@ -0,0 +1,3 @@
export { SchemaBuilder } from "./SchemaBuilder";
export { SchemaAwareJsonField } from "./SchemaAwareJsonField";

View File

@ -0,0 +1,181 @@
import { useCallback } from "react";
import type {
SchemaArrayNode,
SchemaEnumNode,
SchemaNode,
SchemaObjectNode,
SchemaPrimitiveNode,
SchemaRangeNode,
SchemaValidationError,
} from "@/schemas/types";
export function useSchemaValidation(schema: SchemaNode) {
const validate = useCallback(
(value: unknown) => validateSchemaValue(schema, value),
[schema]
);
return {
validate,
buildErrorMap(value: unknown) {
const validationErrors = validate(value);
return validationErrors.reduce<Record<string, SchemaValidationError[]>>((accumulator, error) => {
if (!accumulator[error.path]) {
accumulator[error.path] = [];
}
accumulator[error.path].push(error);
return accumulator;
}, {});
},
};
}
export function validateSchemaValue(schema: SchemaNode, value: unknown) {
return validateNode(schema, value);
}
function validateNode(schema: SchemaNode, value: unknown, path = ""): SchemaValidationError[] {
switch (schema.kind) {
case "primitive":
return validatePrimitive(schema, value, path);
case "enum":
return validateEnum(schema, value, path);
case "range":
return validateRange(schema, value, path);
case "array":
return validateArray(schema, value, path);
case "object":
return validateObject(schema, value, path);
default:
return [];
}
}
function validatePrimitive(schema: SchemaPrimitiveNode, value: unknown, path: string) {
if (value === undefined || value === null || value === "") {
return schema.required === false
? []
: [createError(path, "Valeur requise.")];
}
switch (schema.type) {
case "string":
return typeof value === "string"
? []
: [createError(path, "Doit etre une chaine de caracteres." )];
case "number":
return typeof value === "number"
? []
: [createError(path, "Doit etre un nombre.")];
case "boolean":
return typeof value === "boolean"
? []
: [createError(path, "Doit etre un booleen.")];
case "date":
return typeof value === "string"
? []
: [createError(path, "Doit etre une date ISO.")];
default:
return [];
}
}
function validateEnum(schema: SchemaEnumNode, value: unknown, path: string) {
if (schema.multiple) {
if (!Array.isArray(value)) {
return [createError(path, "Selection multiple attendue.")];
}
const invalid = value.filter((item) => !schema.options.includes(item as string));
if (invalid.length > 0) {
return [createError(path, "Valeurs invalides selectionnees.")];
}
return [];
}
if (value === undefined || value === null || value === "") {
return schema.required === false
? []
: [createError(path, "Valeur requise.")];
}
return schema.options.includes(value as string)
? []
: [createError(path, "Valeur non autorisee.")];
}
function validateRange(schema: SchemaRangeNode, value: unknown, path: string) {
if (!value || typeof value !== "object") {
return [createError(path, "Objet {min,max} attendu.")];
}
const record = value as Record<string, unknown>;
const errors: SchemaValidationError[] = [];
if (record.min !== undefined) {
errors.push(
...validatePrimitive(
{ kind: "primitive", type: schema.valueType, required: false },
record.min,
makeChildPath(path, "min")
)
);
}
if (record.max !== undefined) {
errors.push(
...validatePrimitive(
{ kind: "primitive", type: schema.valueType, required: false },
record.max,
makeChildPath(path, "max")
)
);
}
return errors;
}
function validateArray(schema: SchemaArrayNode, value: unknown, path: string) {
if (!Array.isArray(value)) {
return [createError(path, "Tableau attendu.")];
}
return value.flatMap((item, index) => validateNode(schema.element, item, makeIndexPath(path, index)));
}
function validateObject(schema: SchemaObjectNode, value: unknown, path: string) {
if (!value || typeof value !== "object") {
return [createError(path, "Objet attendu.")];
}
const record = value as Record<string, unknown>;
return schema.fields.flatMap((field) => {
const fieldPath = makeChildPath(path, field.key);
const fieldValue = record[field.key];
if (fieldValue === undefined && field.required !== false) {
return [createError(fieldPath, "Valeur requise.")];
}
return validateNode(field.schema, fieldValue, fieldPath);
});
}
function createError(path: string, message: string): SchemaValidationError {
return { path, message };
}
function makeChildPath(path: string, key: string) {
return path ? `${path}.${key}` : key;
}
function makeIndexPath(path: string, index: number) {
return `${path}[${index}]`;
}

View File

@ -0,0 +1,60 @@
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/90",
destructive:
"bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
outline:
"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50",
secondary:
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
ghost:
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-9 px-4 py-2 has-[>svg]:px-3",
sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5",
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
icon: "size-9",
"icon-sm": "size-8",
"icon-lg": "size-10",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
function Button({
className,
variant,
size,
asChild = false,
...props
}: React.ComponentProps<"button"> &
VariantProps<typeof buttonVariants> & {
asChild?: boolean
}) {
const Comp = asChild ? Slot : "button"
return (
<Comp
data-slot="button"
className={cn(buttonVariants({ variant, size, className }))}
{...props}
/>
)
}
export { Button, buttonVariants }

View File

@ -0,0 +1,92 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Card({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card"
className={cn(
"bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm",
className
)}
{...props}
/>
)
}
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-header"
className={cn(
"@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-2 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6",
className
)}
{...props}
/>
)
}
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-title"
className={cn("leading-none font-semibold", className)}
{...props}
/>
)
}
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-description"
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
)
}
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
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 }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-content"
className={cn("px-6", className)}
{...props}
/>
)
}
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-footer"
className={cn("flex items-center px-6 [.border-t]:pt-6", className)}
{...props}
/>
)
}
export {
Card,
CardHeader,
CardFooter,
CardTitle,
CardAction,
CardDescription,
CardContent,
}

View File

@ -0,0 +1,32 @@
"use client"
import * as React from "react"
import * as CheckboxPrimitive from "@radix-ui/react-checkbox"
import { CheckIcon } from "lucide-react"
import { cn } from "@/lib/utils"
function Checkbox({
className,
...props
}: React.ComponentProps<typeof CheckboxPrimitive.Root>) {
return (
<CheckboxPrimitive.Root
data-slot="checkbox"
className={cn(
"peer border-input dark:bg-input/30 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground dark:data-[state=checked]:bg-primary data-[state=checked]:border-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive size-4 shrink-0 rounded-[4px] border shadow-xs transition-shadow outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
className
)}
{...props}
>
<CheckboxPrimitive.Indicator
data-slot="checkbox-indicator"
className="grid place-content-center text-current transition-none"
>
<CheckIcon className="size-3.5" />
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root>
)
}
export { Checkbox }

View File

@ -0,0 +1,21 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
return (
<input
type={type}
data-slot="input"
className={cn(
"file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
className
)}
{...props}
/>
)
}
export { Input }

View File

@ -0,0 +1,185 @@
import * as React from "react"
import * as SelectPrimitive from "@radix-ui/react-select"
import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react"
import { cn } from "@/lib/utils"
function Select({
...props
}: React.ComponentProps<typeof SelectPrimitive.Root>) {
return <SelectPrimitive.Root data-slot="select" {...props} />
}
function SelectGroup({
...props
}: React.ComponentProps<typeof SelectPrimitive.Group>) {
return <SelectPrimitive.Group data-slot="select-group" {...props} />
}
function SelectValue({
...props
}: React.ComponentProps<typeof SelectPrimitive.Value>) {
return <SelectPrimitive.Value data-slot="select-value" {...props} />
}
function SelectTrigger({
className,
size = "default",
children,
...props
}: React.ComponentProps<typeof SelectPrimitive.Trigger> & {
size?: "sm" | "default"
}) {
return (
<SelectPrimitive.Trigger
data-slot="select-trigger"
data-size={size}
className={cn(
"border-input data-[placeholder]:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 dark:hover:bg-input/50 flex w-fit items-center justify-between gap-2 rounded-md border bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
{children}
<SelectPrimitive.Icon asChild>
<ChevronDownIcon className="size-4 opacity-50" />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
)
}
function SelectContent({
className,
children,
position = "popper",
align = "center",
...props
}: React.ComponentProps<typeof SelectPrimitive.Content>) {
return (
<SelectPrimitive.Portal>
<SelectPrimitive.Content
data-slot="select-content"
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 relative z-50 max-h-(--radix-select-content-available-height) min-w-[8rem] origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border shadow-md",
position === "popper" &&
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
className
)}
position={position}
align={align}
{...props}
>
<SelectScrollUpButton />
<SelectPrimitive.Viewport
className={cn(
"p-1",
position === "popper" &&
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)] scroll-my-1"
)}
>
{children}
</SelectPrimitive.Viewport>
<SelectScrollDownButton />
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
)
}
function SelectLabel({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.Label>) {
return (
<SelectPrimitive.Label
data-slot="select-label"
className={cn("text-muted-foreground px-2 py-1.5 text-xs", className)}
{...props}
/>
)
}
function SelectItem({
className,
children,
...props
}: React.ComponentProps<typeof SelectPrimitive.Item>) {
return (
<SelectPrimitive.Item
data-slot="select-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
className
)}
{...props}
>
<span className="absolute right-2 flex size-3.5 items-center justify-center">
<SelectPrimitive.ItemIndicator>
<CheckIcon className="size-4" />
</SelectPrimitive.ItemIndicator>
</span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
)
}
function SelectSeparator({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.Separator>) {
return (
<SelectPrimitive.Separator
data-slot="select-separator"
className={cn("bg-border pointer-events-none -mx-1 my-1 h-px", className)}
{...props}
/>
)
}
function SelectScrollUpButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollUpButton>) {
return (
<SelectPrimitive.ScrollUpButton
data-slot="select-scroll-up-button"
className={cn(
"flex cursor-default items-center justify-center py-1",
className
)}
{...props}
>
<ChevronUpIcon className="size-4" />
</SelectPrimitive.ScrollUpButton>
)
}
function SelectScrollDownButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollDownButton>) {
return (
<SelectPrimitive.ScrollDownButton
data-slot="select-scroll-down-button"
className={cn(
"flex cursor-default items-center justify-center py-1",
className
)}
{...props}
>
<ChevronDownIcon className="size-4" />
</SelectPrimitive.ScrollDownButton>
)
}
export {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectScrollDownButton,
SelectScrollUpButton,
SelectSeparator,
SelectTrigger,
SelectValue,
}

View File

@ -0,0 +1,64 @@
import * as React from "react"
import * as TabsPrimitive from "@radix-ui/react-tabs"
import { cn } from "@/lib/utils"
function Tabs({
className,
...props
}: React.ComponentProps<typeof TabsPrimitive.Root>) {
return (
<TabsPrimitive.Root
data-slot="tabs"
className={cn("flex flex-col gap-2", className)}
{...props}
/>
)
}
function TabsList({
className,
...props
}: React.ComponentProps<typeof TabsPrimitive.List>) {
return (
<TabsPrimitive.List
data-slot="tabs-list"
className={cn(
"bg-muted text-muted-foreground inline-flex h-9 w-fit items-center justify-center rounded-lg p-[3px]",
className
)}
{...props}
/>
)
}
function TabsTrigger({
className,
...props
}: React.ComponentProps<typeof TabsPrimitive.Trigger>) {
return (
<TabsPrimitive.Trigger
data-slot="tabs-trigger"
className={cn(
"data-[state=active]:bg-background dark:data-[state=active]:text-foreground focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:outline-ring dark:data-[state=active]:border-input dark:data-[state=active]:bg-input/30 text-foreground dark:text-muted-foreground inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-2 py-1 text-sm font-medium whitespace-nowrap transition-[color,box-shadow] focus-visible:ring-[3px] focus-visible:outline-1 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:shadow-sm [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
/>
)
}
function TabsContent({
className,
...props
}: React.ComponentProps<typeof TabsPrimitive.Content>) {
return (
<TabsPrimitive.Content
data-slot="tabs-content"
className={cn("flex-1 outline-none", className)}
{...props}
/>
)
}
export { Tabs, TabsList, TabsTrigger, TabsContent }

View File

@ -0,0 +1,19 @@
import * as React from "react";
import { cn } from "@/lib/utils";
function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
return (
<textarea
data-slot="textarea"
className={cn(
"border-input placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 dark:hover:bg-input/50 flex min-h-[80px] w-full rounded-md border bg-transparent px-3 py-2 text-sm shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
className
)}
{...props}
/>
);
}
export { Textarea };

View File

@ -0,0 +1,464 @@
import { useMemo, useState } from "react";
import { Controller, useForm } from "react-hook-form";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { ApiError, api } from "@/lib/api";
import { cn } from "@/lib/utils";
import type { JsonObject } from "@/components/json-fields";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Checkbox } from "@/components/ui/checkbox";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { SchemaAwareJsonField } from "@/components/schema-builder";
import { getSchema } from "@/schemas/loader";
import { buildDefaultValue } from "@/schemas/utils";
export interface Profile {
profile_id: string;
name: string;
description: string | null;
criteria: JsonObject;
created_at: string;
is_active: boolean;
}
interface CreateProfilePayload {
profile_id?: string;
name: string;
description?: string | null;
criteria: JsonObject;
created_at?: string;
is_active?: boolean;
}
interface ProfileFormValues {
profile_id: string;
name: string;
description: string;
criteria: JsonObject;
created_at: string;
is_active: boolean;
}
function getErrorMessage(error: unknown) {
if (error instanceof ApiError) {
if (typeof error.payload === "string" && error.payload.trim() !== "") {
return error.payload;
}
if (error.payload && typeof error.payload === "object") {
try {
return JSON.stringify(error.payload);
} catch (stringifyError) {
return (stringifyError as Error).message;
}
}
return `Requete rejetee (${error.status}).`;
}
if (error instanceof Error) {
return error.message;
}
return "Erreur inconnue.";
}
function transformPayload(values: ProfileFormValues): CreateProfilePayload {
const payload: CreateProfilePayload = {
name: values.name.trim(),
criteria: values.criteria,
is_active: values.is_active,
};
const profileId = values.profile_id.trim();
if (profileId) {
payload.profile_id = profileId;
}
const description = values.description.trim();
if (description) {
payload.description = description;
}
if (!description && values.description === "") {
payload.description = undefined;
}
if (values.created_at) {
const isoDate = new Date(values.created_at);
if (!Number.isNaN(isoDate.getTime())) {
payload.created_at = isoDate.toISOString();
}
}
return payload;
}
const profileSchema = getSchema("profile");
function createDefaultValues(): ProfileFormValues {
return {
profile_id: "",
name: "",
description: "",
criteria: buildDefaultValue(profileSchema) as JsonObject,
created_at: "",
is_active: true,
};
}
export function ProfilesTab() {
const [criteriaValid, setCriteriaValid] = useState(true);
const [editingProfileId, setEditingProfileId] = useState<string | null>(null);
const [statusMessage, setStatusMessage] = useState<string | null>(null);
const queryClient = useQueryClient();
const initialValues = useMemo(() => createDefaultValues(), []);
const form = useForm<ProfileFormValues>({
defaultValues: initialValues,
mode: "onBlur",
});
const profilesQuery = useQuery({
queryKey: ["profiles"],
queryFn: () => api.get<Profile[]>("/profiles"),
});
const createProfileMutation = useMutation({
mutationFn: (values: ProfileFormValues) =>
api.post<CreateProfilePayload, Profile>("/profiles", transformPayload(values)),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["profiles"] });
setCriteriaValid(true);
setEditingProfileId(null);
form.reset(createDefaultValues());
setStatusMessage("Profil cree avec succes.");
},
});
const updateProfileMutation = useMutation({
mutationFn: ({ id, data }: { id: string; data: ProfileFormValues }) => {
const payload = transformPayload(data);
const { profile_id: _ignored, ...body } = payload;
return api.put<Omit<CreateProfilePayload, "profile_id">, Profile>(
`/profiles/${id}`,
body
);
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["profiles"] });
setEditingProfileId(null);
setCriteriaValid(true);
form.reset(createDefaultValues());
setStatusMessage("Profil mis a jour.");
},
});
const deleteProfileMutation = useMutation({
mutationFn: (id: string) => api.delete(`/profiles/${id}`),
onSuccess: (_, id) => {
if (editingProfileId === id) {
setEditingProfileId(null);
setCriteriaValid(true);
form.reset(createDefaultValues());
}
queryClient.invalidateQueries({ queryKey: ["profiles"] });
setStatusMessage("Profil supprime.");
},
});
const onSubmit = form.handleSubmit(async (values) => {
setStatusMessage(null);
if (editingProfileId) {
await updateProfileMutation.mutateAsync({ id: editingProfileId, data: values });
} else {
await createProfileMutation.mutateAsync(values);
}
});
const formError =
(createProfileMutation.isError && getErrorMessage(createProfileMutation.error)) ||
(updateProfileMutation.isError && getErrorMessage(updateProfileMutation.error)) ||
null;
const profiles = useMemo(() => profilesQuery.data ?? [], [profilesQuery.data]);
return (
<div className="space-y-6">
<Card>
<CardHeader>
<CardTitle>Creer un profil</CardTitle>
<CardDescription>
Renseignez les informations du profil ainsi que les criteres JSON a utiliser.
</CardDescription>
</CardHeader>
<CardContent>
<form className="space-y-6" onSubmit={onSubmit}>
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-1">
<label className="text-sm font-medium" htmlFor="profile_id">
Identifiant (optionnel)
</label>
<Input
id="profile_id"
placeholder="UUID personnalise"
disabled={Boolean(editingProfileId)}
{...form.register("profile_id")}
/>
</div>
<div className="space-y-1">
<label className="text-sm font-medium" htmlFor="name">
Nom
</label>
<Input
id="name"
aria-invalid={Boolean(form.formState.errors.name)}
placeholder="Profil premium"
{...form.register("name", { required: "Le nom est obligatoire." })}
/>
{form.formState.errors.name ? (
<p className="text-destructive text-xs">
{form.formState.errors.name.message}
</p>
) : null}
</div>
<div className="space-y-1 md:col-span-2">
<label className="text-sm font-medium" htmlFor="description">
Description
</label>
<Textarea
id="description"
rows={3}
placeholder="Description optionnelle du profil"
{...form.register("description")}
/>
</div>
<div className="space-y-1">
<label className="text-sm font-medium" htmlFor="created_at">
Date de creation (optionnelle)
</label>
<Input
id="created_at"
type="datetime-local"
step="1"
{...form.register("created_at")}
/>
<p className="text-muted-foreground text-xs">
Laisse vide pour laisser le serveur definir la date actuelle.
</p>
</div>
<Controller
control={form.control}
name="is_active"
render={({ field }) => (
<div className="flex flex-col justify-end gap-2">
<div className="flex items-center gap-2">
<Checkbox
id="is_active"
checked={field.value}
onCheckedChange={(checked) => field.onChange(Boolean(checked))}
/>
<label htmlFor="is_active" className="text-sm font-medium">
Activer ce profil
</label>
</div>
<p className="text-muted-foreground text-xs">
Decochez pour creer le profil en statut inactif.
</p>
</div>
)}
/>
</div>
<Controller
control={form.control}
name="criteria"
render={({ field }) => (
<SchemaAwareJsonField
schema={profileSchema}
value={field.value}
onChange={(next) => field.onChange(next)}
label="Criteres"
description="Contraintes de selection structurees."
onValidationChange={(payload) => setCriteriaValid(payload.valid)}
/>
)}
/>
<div className="flex flex-wrap items-center gap-3">
<Button
type="submit"
disabled={(editingProfileId ? updateProfileMutation.isPending : createProfileMutation.isPending) || !criteriaValid}
>
{editingProfileId
? updateProfileMutation.isPending
? "Mise a jour..."
: "Mettre a jour le profil"
: createProfileMutation.isPending
? "Creation..."
: "Creer le profil"}
</Button>
{statusMessage ? (
<p className="text-emerald-600 text-sm">{statusMessage}</p>
) : null}
{formError ? (
<p className="text-destructive text-sm">{formError}</p>
) : null}
{!criteriaValid ? (
<p className="text-destructive text-sm">Corrigez le schema des criteres avant l'envoi.</p>
) : null}
{editingProfileId ? (
<Button
type="button"
variant="outline"
onClick={() => {
setStatusMessage(null);
setEditingProfileId(null);
setCriteriaValid(true);
form.reset(createDefaultValues());
}}
>
Annuler la modification
</Button>
) : null}
</div>
</form>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Profils existants</CardTitle>
<CardDescription>
{profilesQuery.isLoading
? "Chargement des profils..."
: profiles.length === 0
? "Aucun profil enregistre."
: "Liste recue depuis l'API."}
</CardDescription>
</CardHeader>
<CardContent className="overflow-x-auto">
{profilesQuery.isError ? (
<p className="text-destructive text-sm">
{getErrorMessage(profilesQuery.error)}
</p>
) : (
<table className="min-w-full table-fixed border-collapse text-sm">
<thead>
<tr className="text-left">
<th className="border-b px-3 py-2 font-semibold">ID</th>
<th className="border-b px-3 py-2 font-semibold">Nom</th>
<th className="border-b px-3 py-2 font-semibold">Description</th>
<th className="border-b px-3 py-2 font-semibold">Criteres</th>
<th className="border-b px-3 py-2 font-semibold">Actif</th>
<th className="border-b px-3 py-2 font-semibold">Cree le</th>
<th className="border-b px-3 py-2 font-semibold">Actions</th>
</tr>
</thead>
<tbody>
{profiles.map((profile) => (
<tr key={profile.profile_id} className="align-top">
<td className="border-b px-3 py-2 font-mono text-xs">
{profile.profile_id}
</td>
<td className="border-b px-3 py-2">{profile.name}</td>
<td className="border-b px-3 py-2 text-muted-foreground">
{profile.description ?? "-"}
</td>
<td className="border-b px-3 py-2">
<pre className="max-h-40 overflow-auto rounded bg-muted/50 p-2 text-xs">
{JSON.stringify(profile.criteria, null, 2)}
</pre>
</td>
<td className="border-b px-3 py-2">
<span
className={cn(
"inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-xs font-medium",
profile.is_active
? "bg-emerald-100 text-emerald-700"
: "bg-gray-200 text-gray-600"
)}
>
{profile.is_active ? "Oui" : "Non"}
</span>
</td>
<td className="border-b px-3 py-2 text-muted-foreground">
{new Date(profile.created_at).toLocaleString()}
</td>
<td className="border-b px-3 py-2">
<div className="flex flex-wrap gap-2">
<Button
type="button"
variant="outline"
size="sm"
onClick={() => {
setStatusMessage(null);
setEditingProfileId(profile.profile_id);
setCriteriaValid(true);
const createdAtDate = profile.created_at
? new Date(profile.created_at)
: null;
const createdAtInput =
createdAtDate && !Number.isNaN(createdAtDate.getTime())
? createdAtDate.toISOString().slice(0, 16)
: "";
form.reset({
profile_id: profile.profile_id,
name: profile.name,
description: profile.description ?? "",
criteria: profile.criteria,
created_at: createdAtInput,
is_active: profile.is_active,
});
}}
>
Modifier
</Button>
<Button
type="button"
variant="destructive"
size="sm"
disabled={deleteProfileMutation.isPending}
onClick={() => {
if (
window.confirm(
"Confirmez la suppression de ce profil ?"
)
) {
deleteProfileMutation.mutate(profile.profile_id);
}
}}
>
Supprimer
</Button>
</div>
</td>
</tr>
))}
</tbody>
</table>
)}
</CardContent>
</Card>
</div>
);
}

View File

@ -0,0 +1,603 @@
import { useMemo, useState } from "react";
import { Controller, useForm } from "react-hook-form";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { ApiError, api } from "@/lib/api";
import { cn } from "@/lib/utils";
import type { JsonObject } from "@/components/json-fields";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Checkbox } from "@/components/ui/checkbox";
import { Input } from "@/components/ui/input";
import { SchemaAwareJsonField } from "@/components/schema-builder";
import { getSchema } from "@/schemas/loader";
import { buildDefaultValue } from "@/schemas/utils";
export interface Scraper {
id: string;
params: string | null;
frequency: string | null;
task_name: string | null;
property_types: string | null;
last_seen_days: number | null;
first_seen_days: number | null;
page_size: number | null;
max_pages: number | null;
enabled: number | null;
enrich_llm: number | null;
only_match: number | null;
}
interface ScraperFormValues {
id: string;
params: JsonObject;
frequency: string;
task_name: string;
property_types: string;
last_seen_days: string;
first_seen_days: string;
page_size: string;
max_pages: string;
enabled: boolean;
enrich_llm: boolean;
only_match: boolean;
}
interface CreateScraperPayload {
id: string;
params?: string | null;
frequency?: string | null;
task_name?: string | null;
property_types?: string | null;
last_seen_days?: number | null;
first_seen_days?: number | null;
page_size?: number | null;
max_pages?: number | null;
enabled?: number | null;
enrich_llm?: number | null;
only_match?: number | null;
}
const scraperSchema = getSchema("scraper");
function getErrorMessage(error: unknown) {
if (error instanceof ApiError) {
if (typeof error.payload === "string" && error.payload.trim() !== "") {
return error.payload;
}
if (error.payload && typeof error.payload === "object") {
try {
return JSON.stringify(error.payload);
} catch (stringifyError) {
return (stringifyError as Error).message;
}
}
return `Requete rejetee (${error.status}).`;
}
if (error instanceof Error) {
return error.message;
}
return "Erreur inconnue.";
}
function parseInteger(value: string) {
if (value.trim() === "") {
return undefined;
}
const parsed = Number.parseInt(value, 10);
if (Number.isNaN(parsed)) {
return undefined;
}
return parsed;
}
function transformPayload(values: ScraperFormValues): CreateScraperPayload {
const payload: CreateScraperPayload = {
id: values.id.trim(),
};
const paramsObject = values.params ?? {};
if (Object.keys(paramsObject).length > 0) {
payload.params = JSON.stringify(paramsObject);
}
const frequency = values.frequency.trim();
if (frequency) {
payload.frequency = frequency;
}
const taskName = values.task_name.trim();
if (taskName) {
payload.task_name = taskName;
}
const propertyTypes = values.property_types.trim();
if (propertyTypes) {
payload.property_types = propertyTypes;
}
const lastSeen = parseInteger(values.last_seen_days);
if (lastSeen !== undefined) {
payload.last_seen_days = lastSeen;
}
const firstSeen = parseInteger(values.first_seen_days);
if (firstSeen !== undefined) {
payload.first_seen_days = firstSeen;
}
const pageSize = parseInteger(values.page_size);
if (pageSize !== undefined) {
payload.page_size = pageSize;
}
const maxPages = parseInteger(values.max_pages);
if (maxPages !== undefined) {
payload.max_pages = maxPages;
}
payload.enabled = values.enabled ? 1 : 0;
payload.enrich_llm = values.enrich_llm ? 1 : 0;
payload.only_match = values.only_match ? 1 : 0;
return payload;
}
function createDefaultScraperValues(): ScraperFormValues {
return {
id: "",
params: buildDefaultValue(scraperSchema) as JsonObject,
frequency: "",
task_name: "",
property_types: "",
last_seen_days: "",
first_seen_days: "",
page_size: "",
max_pages: "",
enabled: true,
enrich_llm: false,
only_match: false,
};
}
function formatParamsDisplay(params: string | null) {
if (!params) {
return null;
}
try {
return JSON.stringify(JSON.parse(params), null, 2);
} catch (parseError) {
return params;
}
}
export function ScrapersTab() {
const [paramsValid, setParamsValid] = useState(true);
const [editingScraperId, setEditingScraperId] = useState<string | null>(null);
const [statusMessage, setStatusMessage] = useState<string | null>(null);
const queryClient = useQueryClient();
const initialValues = useMemo(() => createDefaultScraperValues(), []);
const form = useForm<ScraperFormValues>({
defaultValues: initialValues,
mode: "onBlur",
});
const scrapersQuery = useQuery({
queryKey: ["scrapers"],
queryFn: () => api.get<Scraper[]>("/scrapers"),
});
const createScraperMutation = useMutation({
mutationFn: (values: ScraperFormValues) =>
api.post<CreateScraperPayload, Scraper>("/scrapers", transformPayload(values)),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["scrapers"] });
setParamsValid(true);
setEditingScraperId(null);
form.reset(createDefaultScraperValues());
setStatusMessage("Scraper cree avec succes.");
},
});
const updateScraperMutation = useMutation({
mutationFn: ({ id, data }: { id: string; data: ScraperFormValues }) => {
const payload = transformPayload(data);
const { id: _ignored, ...body } = payload;
return api.put<Omit<CreateScraperPayload, "id">, Scraper>(`/scrapers/${id}`, body);
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["scrapers"] });
setEditingScraperId(null);
setParamsValid(true);
form.reset(createDefaultScraperValues());
setStatusMessage("Scraper mis a jour.");
},
});
const deleteScraperMutation = useMutation({
mutationFn: (id: string) => api.delete(`/scrapers/${id}`),
onSuccess: (_, id) => {
if (editingScraperId === id) {
setEditingScraperId(null);
setParamsValid(true);
form.reset(createDefaultScraperValues());
}
queryClient.invalidateQueries({ queryKey: ["scrapers"] });
setStatusMessage("Scraper supprime.");
},
});
const onSubmit = form.handleSubmit(async (values) => {
setStatusMessage(null);
if (editingScraperId) {
await updateScraperMutation.mutateAsync({ id: editingScraperId, data: values });
} else {
await createScraperMutation.mutateAsync(values);
}
});
const formError =
(createScraperMutation.isError && getErrorMessage(createScraperMutation.error)) ||
(updateScraperMutation.isError && getErrorMessage(updateScraperMutation.error)) ||
null;
const scrapers = useMemo(() => scrapersQuery.data ?? [], [scrapersQuery.data]);
return (
<div className="space-y-6">
<Card>
<CardHeader>
<CardTitle>Creer un scraper</CardTitle>
<CardDescription>
Definissez l'identifiant du scraper, la configuration et les parametres d'execution.
</CardDescription>
</CardHeader>
<CardContent>
<form className="space-y-6" onSubmit={onSubmit}>
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-1 md:col-span-2">
<label className="text-sm font-medium" htmlFor="scraper_id">
Identifiant
</label>
<Input
id="scraper_id"
placeholder="ex: scraper_immobilier"
aria-invalid={Boolean(form.formState.errors.id)}
disabled={Boolean(editingScraperId)}
{...form.register("id", { required: "L'identifiant est obligatoire." })}
/>
{form.formState.errors.id ? (
<p className="text-destructive text-xs">
{form.formState.errors.id.message}
</p>
) : null}
</div>
<div className="space-y-1">
<label className="text-sm font-medium" htmlFor="frequency">
Frequence
</label>
<Input
id="frequency"
placeholder="0 7 * * *"
{...form.register("frequency")}
/>
</div>
<div className="space-y-1">
<label className="text-sm font-medium" htmlFor="task_name">
Nom de tache
</label>
<Input
id="task_name"
placeholder="Nom interne"
{...form.register("task_name")}
/>
</div>
<div className="space-y-1">
<label className="text-sm font-medium" htmlFor="property_types">
Types de biens
</label>
<Input
id="property_types"
placeholder="maison,appartement"
{...form.register("property_types")}
/>
</div>
<div className="grid gap-3 md:grid-cols-2">
<div className="space-y-1">
<label className="text-sm font-medium" htmlFor="last_seen_days">
Derniere vue (jours)
</label>
<Input id="last_seen_days" type="number" min="0" {...form.register("last_seen_days")} />
</div>
<div className="space-y-1">
<label className="text-sm font-medium" htmlFor="first_seen_days">
Premiere vue (jours)
</label>
<Input id="first_seen_days" type="number" min="0" {...form.register("first_seen_days")} />
</div>
<div className="space-y-1">
<label className="text-sm font-medium" htmlFor="page_size">
Taille de page
</label>
<Input id="page_size" type="number" min="1" {...form.register("page_size")} />
</div>
<div className="space-y-1">
<label className="text-sm font-medium" htmlFor="max_pages">
Pages maximum
</label>
<Input id="max_pages" type="number" min="1" {...form.register("max_pages")} />
</div>
</div>
<div className="grid gap-2 md:col-span-2 md:grid-cols-3">
<Controller
control={form.control}
name="enabled"
render={({ field }) => (
<label className="flex items-center gap-2 text-sm font-medium">
<Checkbox
checked={field.value}
onCheckedChange={(checked) => field.onChange(Boolean(checked))}
/>
Activer
</label>
)}
/>
<Controller
control={form.control}
name="enrich_llm"
render={({ field }) => (
<label className="flex items-center gap-2 text-sm font-medium">
<Checkbox
checked={field.value}
onCheckedChange={(checked) => field.onChange(Boolean(checked))}
/>
Enrichir via LLM
</label>
)}
/>
<Controller
control={form.control}
name="only_match"
render={({ field }) => (
<label className="flex items-center gap-2 text-sm font-medium">
<Checkbox
checked={field.value}
onCheckedChange={(checked) => field.onChange(Boolean(checked))}
/>
Mode strict
</label>
)}
/>
</div>
</div>
<Controller
control={form.control}
name="params"
render={({ field }) => (
<SchemaAwareJsonField
schema={scraperSchema}
value={field.value}
onChange={(next) => field.onChange(next)}
label="Parametres"
description="Structure schemaisee conformement au schema scraper."
onValidationChange={(payload) => setParamsValid(payload.valid)}
/>
)}
/>
<div className="flex flex-wrap items-center gap-3">
<Button
type="submit"
disabled={(editingScraperId ? updateScraperMutation.isPending : createScraperMutation.isPending) || !paramsValid}
>
{editingScraperId
? updateScraperMutation.isPending
? "Mise a jour..."
: "Mettre a jour le scraper"
: createScraperMutation.isPending
? "Creation..."
: "Creer le scraper"}
</Button>
{statusMessage ? (
<p className="text-emerald-600 text-sm">{statusMessage}</p>
) : null}
{formError ? (
<p className="text-destructive text-sm">{formError}</p>
) : null}
{!paramsValid ? (
<p className="text-destructive text-sm">Corrigez les erreurs du schema avant l'envoi.</p>
) : null}
{editingScraperId ? (
<Button
type="button"
variant="outline"
onClick={() => {
setStatusMessage(null);
setEditingScraperId(null);
setParamsValid(true);
form.reset(createDefaultScraperValues());
}}
>
Annuler la modification
</Button>
) : null}
</div>
</form>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Scrapers existants</CardTitle>
<CardDescription>
{scrapersQuery.isLoading
? "Chargement des scrapers..."
: scrapers.length === 0
? "Aucun scraper enregistre."
: "Liste recue depuis l'API."}
</CardDescription>
</CardHeader>
<CardContent className="overflow-x-auto">
{scrapersQuery.isError ? (
<p className="text-destructive text-sm">
{getErrorMessage(scrapersQuery.error)}
</p>
) : (
<table className="min-w-full table-fixed border-collapse text-sm">
<thead>
<tr className="text-left">
<th className="border-b px-3 py-2 font-semibold">ID</th>
<th className="border-b px-3 py-2 font-semibold">Frequence</th>
<th className="border-b px-3 py-2 font-semibold">Tache</th>
<th className="border-b px-3 py-2 font-semibold">Parametres</th>
<th className="border-b px-3 py-2 font-semibold">Etat</th>
<th className="border-b px-3 py-2 font-semibold">Limites</th>
<th className="border-b px-3 py-2 font-semibold">Actions</th>
</tr>
</thead>
<tbody>
{scrapers.map((scraper) => {
const paramsDisplay = formatParamsDisplay(scraper.params);
return (
<tr key={scraper.id} className="align-top">
<td className="border-b px-3 py-2 font-mono text-xs">{scraper.id}</td>
<td className="border-b px-3 py-2 text-muted-foreground">
{scraper.frequency ?? "-"}
</td>
<td className="border-b px-3 py-2">{scraper.task_name ?? "-"}</td>
<td className="border-b px-3 py-2">
{paramsDisplay ? (
<pre className="max-h-40 overflow-auto rounded bg-muted/50 p-2 text-xs">
{paramsDisplay}
</pre>
) : (
<span className="text-muted-foreground">-</span>
)}
</td>
<td className="border-b px-3 py-2">
<div className="space-y-1 text-xs">
<span
className={cn(
"inline-flex items-center gap-1 rounded-full px-2 py-0.5 font-medium",
scraper.enabled
? "bg-emerald-100 text-emerald-700"
: "bg-gray-200 text-gray-600"
)}
>
Actif: {scraper.enabled ? "Oui" : "Non"}
</span>
<span className="block text-muted-foreground">
LLM: {scraper.enrich_llm ? "Oui" : "Non"}
</span>
<span className="block text-muted-foreground">
Strict: {scraper.only_match ? "Oui" : "Non"}
</span>
</div>
</td>
<td className="border-b px-3 py-2 text-xs text-muted-foreground">
<div className="space-y-1">
<span>Derniere vue: {scraper.last_seen_days ?? "-"}</span>
<span>Premiere vue: {scraper.first_seen_days ?? "-"}</span>
<span>Page size: {scraper.page_size ?? "-"}</span>
<span>Max pages: {scraper.max_pages ?? "-"}</span>
</div>
</td>
<td className="border-b px-3 py-2">
<div className="flex flex-wrap gap-2">
<Button
type="button"
variant="outline"
size="sm"
onClick={() => {
setStatusMessage(null);
setEditingScraperId(scraper.id);
setParamsValid(true);
let parsedParams: JsonObject = buildDefaultValue(scraperSchema) as JsonObject;
if (scraper.params) {
try {
const raw = JSON.parse(scraper.params) as JsonObject;
parsedParams = raw && typeof raw === "object" ? raw : parsedParams;
} catch (error) {
parsedParams = buildDefaultValue(scraperSchema) as JsonObject;
}
}
form.reset({
id: scraper.id,
params: parsedParams,
frequency: scraper.frequency ?? "",
task_name: scraper.task_name ?? "",
property_types: scraper.property_types ?? "",
last_seen_days: scraper.last_seen_days?.toString() ?? "",
first_seen_days: scraper.first_seen_days?.toString() ?? "",
page_size: scraper.page_size?.toString() ?? "",
max_pages: scraper.max_pages?.toString() ?? "",
enabled: Boolean(scraper.enabled),
enrich_llm: Boolean(scraper.enrich_llm),
only_match: Boolean(scraper.only_match),
});
}}
>
Modifier
</Button>
<Button
type="button"
variant="destructive"
size="sm"
disabled={deleteScraperMutation.isPending}
onClick={() => {
if (
window.confirm(
"Confirmez la suppression de ce scraper ?"
)
) {
deleteScraperMutation.mutate(scraper.id);
}
}}
>
Supprimer
</Button>
</div>
</td>
</tr>
);
})}
</tbody>
</table>
)}
</CardContent>
</Card>
</div>
);
}

120
frontend/src/index.css Normal file
View File

@ -0,0 +1,120 @@
@import "tailwindcss";
@import "tw-animate-css";
@custom-variant dark (&:is(.dark *));
@theme inline {
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-card: var(--card);
--color-card-foreground: var(--card-foreground);
--color-popover: var(--popover);
--color-popover-foreground: var(--popover-foreground);
--color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground);
--color-secondary: var(--secondary);
--color-secondary-foreground: var(--secondary-foreground);
--color-muted: var(--muted);
--color-muted-foreground: var(--muted-foreground);
--color-accent: var(--accent);
--color-accent-foreground: var(--accent-foreground);
--color-destructive: var(--destructive);
--color-border: var(--border);
--color-input: var(--input);
--color-ring: var(--ring);
--color-chart-1: var(--chart-1);
--color-chart-2: var(--chart-2);
--color-chart-3: var(--chart-3);
--color-chart-4: var(--chart-4);
--color-chart-5: var(--chart-5);
--color-sidebar: var(--sidebar);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-ring: var(--sidebar-ring);
}
:root {
--radius: 0.625rem;
--background: oklch(1 0 0);
--foreground: oklch(0.145 0 0);
--card: oklch(1 0 0);
--card-foreground: oklch(0.145 0 0);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.145 0 0);
--primary: oklch(0.205 0 0);
--primary-foreground: oklch(0.985 0 0);
--secondary: oklch(0.97 0 0);
--secondary-foreground: oklch(0.205 0 0);
--muted: oklch(0.97 0 0);
--muted-foreground: oklch(0.556 0 0);
--accent: oklch(0.97 0 0);
--accent-foreground: oklch(0.205 0 0);
--destructive: oklch(0.577 0.245 27.325);
--border: oklch(0.922 0 0);
--input: oklch(0.922 0 0);
--ring: oklch(0.708 0 0);
--chart-1: oklch(0.646 0.222 41.116);
--chart-2: oklch(0.6 0.118 184.704);
--chart-3: oklch(0.398 0.07 227.392);
--chart-4: oklch(0.828 0.189 84.429);
--chart-5: oklch(0.769 0.188 70.08);
--sidebar: oklch(0.985 0 0);
--sidebar-foreground: oklch(0.145 0 0);
--sidebar-primary: oklch(0.205 0 0);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.97 0 0);
--sidebar-accent-foreground: oklch(0.205 0 0);
--sidebar-border: oklch(0.922 0 0);
--sidebar-ring: oklch(0.708 0 0);
}
.dark {
--background: oklch(0.145 0 0);
--foreground: oklch(0.985 0 0);
--card: oklch(0.205 0 0);
--card-foreground: oklch(0.985 0 0);
--popover: oklch(0.205 0 0);
--popover-foreground: oklch(0.985 0 0);
--primary: oklch(0.922 0 0);
--primary-foreground: oklch(0.205 0 0);
--secondary: oklch(0.269 0 0);
--secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.269 0 0);
--muted-foreground: oklch(0.708 0 0);
--accent: oklch(0.269 0 0);
--accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.704 0.191 22.216);
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%);
--ring: oklch(0.556 0 0);
--chart-1: oklch(0.488 0.243 264.376);
--chart-2: oklch(0.696 0.17 162.48);
--chart-3: oklch(0.769 0.188 70.08);
--chart-4: oklch(0.627 0.265 303.9);
--chart-5: oklch(0.645 0.246 16.439);
--sidebar: oklch(0.205 0 0);
--sidebar-foreground: oklch(0.985 0 0);
--sidebar-primary: oklch(0.488 0.243 264.376);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.269 0 0);
--sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.556 0 0);
}
@layer base {
* {
@apply border-border outline-ring/50;
}
body {
@apply bg-background text-foreground;
}
}

148
frontend/src/lib/api.ts Normal file
View File

@ -0,0 +1,148 @@
const DEFAULT_BASE_URL =
import.meta.env.VITE_API_BASE_URL ?? "http://localhost:8000";
const SESSION_TOKEN_STORAGE_KEY = "managinator-session-token";
type HttpMethod = "GET" | "POST" | "PUT" | "PATCH" | "DELETE";
export interface ApiRequestOptions {
query?: Record<string, string | number | boolean | null | undefined>;
headers?: HeadersInit;
}
export class ApiError extends Error {
public readonly status: number;
public readonly payload?: unknown;
constructor(status: number, message: string, payload?: unknown) {
super(message);
this.status = status;
this.payload = payload;
}
}
let sessionToken: string | null = null;
if (typeof window !== "undefined") {
sessionToken = window.localStorage.getItem(SESSION_TOKEN_STORAGE_KEY);
}
function buildUrl(
path: string,
query?: ApiRequestOptions["query"],
baseUrl = DEFAULT_BASE_URL,
) {
const url = new URL(path, baseUrl);
if (query) {
Object.entries(query).forEach(([key, value]) => {
if (value !== undefined && value !== null && value !== "") {
url.searchParams.set(key, String(value));
}
});
}
return url.toString();
}
async function request<TResponse>(
method: HttpMethod,
path: string,
body?: unknown,
options?: ApiRequestOptions,
): Promise<TResponse> {
const url = buildUrl(path, options?.query);
const headers: HeadersInit = {
...(body !== undefined ? { "Content-Type": "application/json" } : {}),
...(sessionToken
? {
"X-Session-Token": sessionToken,
Authorization: `Bearer ${sessionToken}`,
}
: {}),
...options?.headers,
};
const response = await fetch(url, {
method,
headers,
body: body !== undefined ? JSON.stringify(body) : undefined,
});
if (!response.ok) {
let errorPayload: unknown;
const contentType = response.headers.get("content-type") ?? "";
try {
if (contentType.includes("application/json")) {
errorPayload = await response.json();
} else {
errorPayload = await response.text();
}
} catch (error) {
errorPayload = { parseError: String(error) };
}
throw new ApiError(response.status, response.statusText, errorPayload);
}
if (response.status === 204) {
return undefined as TResponse;
}
const contentType = response.headers.get("content-type") ?? "";
if (contentType.includes("application/json")) {
return (await response.json()) as TResponse;
}
return (await response.text()) as TResponse;
}
export const api = {
get: <TResponse>(path: string, options?: ApiRequestOptions) =>
request<TResponse>("GET", path, undefined, options),
post: <TBody extends object, TResponse>(
path: string,
body: TBody,
options?: ApiRequestOptions,
) => request<TResponse>("POST", path, body, options),
put: <TBody extends object, TResponse>(
path: string,
body: TBody,
options?: ApiRequestOptions,
) => request<TResponse>("PUT", path, body, options),
patch: <TBody extends object, TResponse>(
path: string,
body: TBody,
options?: ApiRequestOptions,
) => request<TResponse>("PATCH", path, body, options),
delete: <TResponse>(path: string, options?: ApiRequestOptions) =>
request<TResponse>("DELETE", path, undefined, options),
};
export function setSessionToken(token: string | null) {
sessionToken = token && token.length > 0 ? token : null;
if (typeof window !== "undefined") {
if (sessionToken) {
window.localStorage.setItem(SESSION_TOKEN_STORAGE_KEY, sessionToken);
} else {
window.localStorage.removeItem(SESSION_TOKEN_STORAGE_KEY);
}
}
}
export function getSessionToken() {
return sessionToken;
}
export function clearSessionToken() {
setSessionToken(null);
}
export const apiConfig = {
defaultBaseUrl: DEFAULT_BASE_URL,
sessionStorageKey: SESSION_TOKEN_STORAGE_KEY,
};

View File

@ -0,0 +1,6 @@
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}

16
frontend/src/main.tsx Normal file
View File

@ -0,0 +1,16 @@
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import "./index.css";
import App from "./App.tsx";
const queryClient = new QueryClient();
createRoot(document.getElementById("root")!).render(
<StrictMode>
<QueryClientProvider client={queryClient}>
<App />
</QueryClientProvider>
</StrictMode>
);

View File

@ -0,0 +1,321 @@
# LLM JSON Generation Guide
This note explains how to instruct a language model (LLM) to generate JSON payloads compatible with the Managinator frontend. The guide lists every field exposed by the scraper and profile builders, including types and allowed values.
## General Output Rules
- Produce **pure JSON** responses. Do not wrap the answer in Markdown, explanations, or comments.
- Use snake_case property names exactly as defined in the tables below. Skip unknown keys instead of inventing structure.
- Omit optional fields when the information is missing. Avoid sending `null` unless the API explicitly documents it.
- Booleans must be literal `true` or `false`. Never send strings such as "yes" or "no".
- Numbers must remain numbers. Do not quote numeric values.
- Dates must use ISO-8601 with timezone (example: `"2024-01-17T09:30:00Z"`). If a date is unknown, omit the field.
- Range objects use `{ "min": value, "max": value }`. Either key may be left out when the bound is unnecessary.
- Arrays should only appear when intentional. Prefer omitting the field to sending empty arrays without meaning.
- Enumerations are case sensitive. Use only the literals documented in the "Allowed Values" column.
- Never include trailing commas, comments, or additional prose in the JSON reply.
## Scraper Parameters Schema
All scraper parameters are optional. Start from the root object and include only the branches that matter for the request at hand.
### Field Reference
| Path | Type | Allowed Values |
| --- | --- | --- |
| scraper | object | |
| scraper.adverts | array<object> | |
| scraper.adverts[] | object | |
| scraper.adverts[].firstSeenAt | range<date> | |
| scraper.adverts[].flxId | array<string> | |
| scraper.adverts[].isOnline | boolean | |
| scraper.adverts[].lastSeenAt | range<date> | |
| scraper.adverts[].location | object | |
| scraper.adverts[].location.city | string | |
| scraper.adverts[].location.cityCoordinate | object | |
| scraper.adverts[].location.cityCoordinate.location | object | |
| scraper.adverts[].location.cityCoordinate.location.lon | number | |
| scraper.adverts[].location.cityCoordinate.location.lat | number | |
| scraper.adverts[].location.department | string | |
| scraper.adverts[].location.inseeCode | string | |
| scraper.adverts[].location.irisCode | string | |
| scraper.adverts[].location.locationCoordinate | object | |
| scraper.adverts[].location.locationCoordinate.location | object | |
| scraper.adverts[].location.locationCoordinate.location.lon | number | |
| scraper.adverts[].location.locationCoordinate.location.lat | number | |
| scraper.adverts[].location.postalCode | string | |
| scraper.adverts[].price | object | |
| scraper.adverts[].price.currency | enum[] | CURRENCY_EUR, CURRENCY_USD |
| scraper.adverts[].price.initial | object | |
| scraper.adverts[].price.initial.source | object | |
| scraper.adverts[].price.initial.source.flxId | string | |
| scraper.adverts[].price.initial.source.url | string | |
| scraper.adverts[].price.initial.source.website | string | |
| scraper.adverts[].price.initial.value | range<number> | |
| scraper.adverts[].price.initial.valuePerArea | range<number> | |
| scraper.adverts[].price.isAuction | boolean | |
| scraper.adverts[].price.latest | object | |
| scraper.adverts[].price.latest.source | object | |
| scraper.adverts[].price.latest.source.flxId | string | |
| scraper.adverts[].price.latest.source.url | string | |
| scraper.adverts[].price.latest.source.website | string | |
| scraper.adverts[].price.latest.value | range<number> | |
| scraper.adverts[].price.latest.valuePerArea | range<number> | |
| scraper.adverts[].price.scope | enum[] | PRICING_ONE_OFF, PRICING_MENSUAL |
| scraper.adverts[].price.warrantyDeposit | range<number> | |
| scraper.adverts[].price.variation | array<object> | |
| scraper.adverts[].price.variation[] | object | |
| scraper.adverts[].price.variation[].sinceLastModified | range<number> | |
| scraper.adverts[].price.variation[].sincePublished | range<number> | |
| scraper.adverts[].source | object | |
| scraper.adverts[].source.flxId | string | |
| scraper.adverts[].source.url | string | |
| scraper.adverts[].source.website | string | |
| scraper.adverts[].isPro | boolean | |
| scraper.adverts[].seller | array<object> | |
| scraper.adverts[].seller[] | object | |
| scraper.adverts[].seller[].flxId | string | |
| scraper.adverts[].seller[].name | string | |
| scraper.adverts[].seller[].siren | string | |
| scraper.adverts[].seller[].type | enum | SELLER_TYPE_UNKNOWN, SELLER_TYPE_AGENCY, SELLER_TYPE_NETWORK |
| scraper.adverts[].hasAnomaly | boolean | |
| scraper.adverts[].isExclusive | boolean | |
| scraper.habitation | object | |
| scraper.habitation.bathroomCount | range<number> | |
| scraper.habitation.bedroomCount | range<number> | |
| scraper.habitation.characteristics | object | |
| scraper.habitation.characteristics.hasAlarm | boolean | |
| scraper.habitation.characteristics.hasBalcony | boolean | |
| scraper.habitation.characteristics.hasCellar | boolean | |
| scraper.habitation.characteristics.hasConcierge | boolean | |
| scraper.habitation.characteristics.hasDigicode | boolean | |
| scraper.habitation.characteristics.hasFireplace | boolean | |
| scraper.habitation.characteristics.hasGarage | boolean | |
| scraper.habitation.characteristics.hasGarden | boolean | |
| scraper.habitation.characteristics.hasGrenier | boolean | |
| scraper.habitation.characteristics.hasInterphone | boolean | |
| scraper.habitation.characteristics.hasJacuzzi | boolean | |
| scraper.habitation.characteristics.hasLand | boolean | |
| scraper.habitation.characteristics.hasLift | boolean | |
| scraper.habitation.characteristics.hasMezzanine | boolean | |
| scraper.habitation.characteristics.hasParking | boolean | |
| scraper.habitation.characteristics.hasPool | boolean | |
| scraper.habitation.characteristics.hasTerrace | boolean | |
| scraper.habitation.characteristics.hasVisAVis | boolean | |
| scraper.habitation.characteristics.isPeaceful | boolean | |
| scraper.habitation.climate | object | |
| scraper.habitation.climate.epcClimate | enum[] | GREENHOUSE_CLASSIFICATION_UNKNOWN, GREENHOUSE_CLASSIFICATION_G, GREENHOUSE_CLASSIFICATION_F, GREENHOUSE_CLASSIFICATION_E, GREENHOUSE_CLASSIFICATION_D, GREENHOUSE_CLASSIFICATION_C, GREENHOUSE_CLASSIFICATION_B, GREENHOUSE_CLASSIFICATION_A, GREENHOUSE_CLASSIFICATION_NC |
| scraper.habitation.climate.epcClimateScore | range<number> | |
| scraper.habitation.climate.epcEnergy | enum[] | ENERGY_CLASSIFICATION_UNKNOWN, ENERGY_CLASSIFICATION_G, ENERGY_CLASSIFICATION_F, ENERGY_CLASSIFICATION_E, ENERGY_CLASSIFICATION_D, ENERGY_CLASSIFICATION_C, ENERGY_CLASSIFICATION_B, ENERGY_CLASSIFICATION_A, ENERGY_CLASSIFICATION_NC |
| scraper.habitation.climate.epcEnergyScore | range<number> | |
| scraper.habitation.climate.epcClimateDate | range<date> | |
| scraper.habitation.climate.epcEnergyDate | range<date> | |
| scraper.habitation.features | object | |
| scraper.habitation.features.exposure | enum[] | EXPOSURE_UNKNOWN, EXPOSURE_NORTH, EXPOSURE_NORTH_EAST, EXPOSURE_EAST, EXPOSURE_SOUTH_EAST, EXPOSURE_SOUTH, EXPOSURE_SOUTH_WEST, EXPOSURE_WEST, EXPOSURE_NORTH_WEST |
| scraper.habitation.features.floor | range<number> | |
| scraper.habitation.features.lastFloor | range<number> | |
| scraper.habitation.features.orientation | enum[] | ORIENTATION_UNKNOWN, ORIENTATION_SINGLE, ORIENTATION_DOUBLE, ORIENTATION_TRIPLE, ORIENTATION_FULL |
| scraper.habitation.features.propertyFloor | range<number> | |
| scraper.habitation.features.propertyTotalFloor | range<number> | |
| scraper.habitation.roomCount | range<number> | |
| scraper.habitation.surface | object | |
| scraper.habitation.surface.balconies | range<number> | |
| scraper.habitation.surface.floorSpace | range<number> | |
| scraper.habitation.surface.gardens | range<number> | |
| scraper.habitation.surface.groundFloor | range<number> | |
| scraper.habitation.surface.kitchen | range<number> | |
| scraper.habitation.surface.livingSpace | range<number> | |
| scraper.habitation.surface.livingroom | range<number> | |
| scraper.habitation.surface.terraces | range<number> | |
| scraper.habitation.surface.total | range<number> | |
| scraper.habitation.type | enum[] | PROPERTY_TYPE_UNKNOWN, PROPERTY_TYPE_APARTMENT, PROPERTY_TYPE_HOUSE, PROPERTY_TYPE_OTHER, PROPERTY_TYPE_NEW, PROPERTY_TYPE_RENOVATED, PROPERTY_TYPE_OFFICE, PROPERTY_TYPE_COMMERCIAL, PROPERTY_TYPE_BUILDING, PROPERTY_TYPE_PARKING, PROPERTY_TYPE_LAND, PROPERTY_TYPE_FARM, PROPERTY_TYPE_CASTLE, PROPERTY_TYPE_LOFT, PROPERTY_TYPE_PENICHE, PROPERTY_TYPE_STUDIO, PROPERTY_TYPE_TOWNHOUSE, PROPERTY_TYPE_MANOR, PROPERTY_TYPE_RIAD, PROPERTY_TYPE_VILLA, PROPERTY_TYPE_HOTEL, PROPERTY_TYPE_BARE_OWNERSHIP, PROPERTY_TYPE_BOURGEOIS, PROPERTY_TYPE_MEULIERE, PROPERTY_TYPE_BASTIDE, PROPERTY_TYPE_BARN, PROPERTY_TYPE_MILL, PROPERTY_TYPE_CHALET, PROPERTY_TYPE_CABIN, PROPERTY_TYPE_GITE, PROPERTY_TYPE_MOBILE_HOME, PROPERTY_TYPE_RANCH, PROPERTY_TYPE_MAS, PROPERTY_TYPE_FISHERY, PROPERTY_TYPE_TRADITIONAL, PROPERTY_TYPE_CONTEMPORARY, PROPERTY_TYPE_ANCIENT, PROPERTY_TYPE_HERITAGE_LISTED, PROPERTY_TYPE_BUNGALOW |
| scraper.habitation.wcCount | range<number> | |
| scraper.isUrgent | boolean | |
| scraper.land | object | |
| scraper.land.canConstruct | boolean | |
| scraper.land.isServiced | boolean | |
| scraper.land.surface | range<number> | |
| scraper.land.surfaceConstructable | range<number> | |
| scraper.land.type | enum[] | LAND_UNKNOWN, LAND_BUILDING_PLOT, LAND_AGRICULTURAL, LAND_VINEYARD, LAND_INDUSTRIAL, LAND_POND, LAND_FOREST |
| scraper.land.haveBuildingPermit | boolean | |
| scraper.land.haveElectricity | boolean | |
| scraper.land.haveTelecom | boolean | |
| scraper.land.haveWater | boolean | |
| scraper.location | array<object> | |
| scraper.location[] | object | |
| scraper.location[].city | string | |
| scraper.location[].cityCoordinate | object | |
| scraper.location[].cityCoordinate.location | object | |
| scraper.location[].cityCoordinate.location.lon | number | |
| scraper.location[].cityCoordinate.location.lat | number | |
| scraper.location[].department | string | |
| scraper.location[].inseeCode | string | |
| scraper.location[].irisCode | string | |
| scraper.location[].locationCoordinate | object | |
| scraper.location[].locationCoordinate.location | object | |
| scraper.location[].locationCoordinate.location.lon | number | |
| scraper.location[].locationCoordinate.location.lat | number | |
| scraper.location[].postalCode | string | |
| scraper.meta | object | |
| scraper.meta.firstSeenAt | range<date> | |
| scraper.meta.isTotallyOffline | boolean | |
| scraper.meta.lastPublishedAt | range<date> | |
| scraper.meta.lastSeenAt | range<date> | |
| scraper.meta.lastUpdatedAt | range<date> | |
| scraper.parking | object | |
| scraper.parking.count | range<number> | |
| scraper.parking.numberOfCars | range<number> | |
| scraper.parking.surface | range<number> | |
| scraper.parking.type | enum[] | PARKING_UNKNOWN, PARKING_GARAGE, PARKING_PARKING |
| scraper.price | object | |
| scraper.price.currency | enum[] | CURRENCY_EUR, CURRENCY_USD |
| scraper.price.initial | object | |
| scraper.price.initial.source | object | |
| scraper.price.initial.source.flxId | string | |
| scraper.price.initial.source.url | string | |
| scraper.price.initial.source.website | string | |
| scraper.price.initial.value | range<number> | |
| scraper.price.initial.valuePerArea | range<number> | |
| scraper.price.isAuction | boolean | |
| scraper.price.latest | object | |
| scraper.price.latest.source | object | |
| scraper.price.latest.source.flxId | string | |
| scraper.price.latest.source.url | string | |
| scraper.price.latest.source.website | string | |
| scraper.price.latest.value | range<number> | |
| scraper.price.latest.valuePerArea | range<number> | |
| scraper.price.scope | enum[] | PRICING_ONE_OFF, PRICING_MENSUAL |
| scraper.price.warrantyDeposit | range<number> | |
| scraper.price.variation | array<object> | |
| scraper.price.variation[] | object | |
| scraper.price.variation[].sinceLastModified | range<number> | |
| scraper.price.variation[].sincePublished | range<number> | |
| scraper.process | enum[] | PROCESS_UNKNOWN, PROCESS_AVAILABLE_ON_MARKET, PROCESS_UNDER_COMPROMISE, PROCESS_RENTED_SOLD, PROCESS_REMOVED, PROCESS_RESERVED, PROCESS_ARCHIVED |
| scraper.tags | array<string> | |
| scraper.type | enum[] | CLASS_UNKNOWN, CLASS_HOUSE, CLASS_FLAT, CLASS_PROGRAM, CLASS_SHOP, CLASS_PREMISES, CLASS_OFFICE, CLASS_LAND, CLASS_BUILDING, CLASS_PARKING |
| scraper.hasAnomaly | boolean | |
| scraper.offer | array<object> | |
| scraper.offer[] | object | |
| scraper.offer[].isCurrentlyOccupied | boolean | |
| scraper.offer[].renting | object | |
| scraper.offer[].renting.isColocation | boolean | |
| scraper.offer[].renting.isLongTerm | boolean | |
| scraper.offer[].renting.isShortTerm | boolean | |
| scraper.offer[].renting.isSubLease | boolean | |
| scraper.offer[].type | enum[] | OFFER_UNKNOWN, OFFER_BUY, OFFER_RENT, OFFER_BUSINESS_TAKE_OVER, OFFER_LEASE_BACK, OFFER_LIFE_ANNUITY_SALE, OFFER_HOLIDAYS |
### Additional Notes
- When providing coordinates, keep latitude and longitude as numeric values.
- Range objects permit providing only `min` or only `max`. Omit the unused side instead of setting it to zero.
- For boolean feature flags, include the key only when you want to enforce a specific value.
## Profile Criteria Schema
Profile criteria are expressed as an object that follows the schema below. As with scraper parameters, include only the keys that matter for the current persona.
### Field Reference
| Path | Type | Allowed Values | Description |
| --- | --- | --- | --- |
| profile.transaction_type | enum | sale, rent, viager | Desired transaction type. |
| profile.min_price | number | | Minimum acceptable price in euros. |
| profile.max_price | number | | Maximum acceptable price in euros. |
| profile.min_size | number | | Minimum habitable surface in square meters. |
| profile.max_size | number | | Maximum habitable surface in square meters. |
| profile.min_bedrooms | number | | Minimum required bedroom count. |
| profile.max_bedrooms | number | | Maximum tolerated bedroom count. |
| profile.min_bathrooms | number | | Minimum required bathroom count. |
| profile.property_type | array<string> | | List of desired property categories (apartment, house, etc.). |
| profile.dpe_classes | enum[] | A, B, C, D, E, F, G, NC | Accepted energy classes. |
| profile.city | string | | Single target city name. |
| profile.cities | array<string> | | List of acceptable cities. |
| profile.postal_code | string | | Target postal code. |
| profile.code_insee | string | | Target INSEE code. |
| profile.target_yield | number | | Desired yield percentage (e.g. 5.2 for 5.2%). |
| profile.require_address | boolean | | Whether a fully geocoded address is required. |
| profile.characteristics | array<object> | | Boolean feature groups to apply. |
| profile.characteristics.type | enum | any, all, none | Evaluation mode for the group. |
| profile.characteristics.description | string | | Optional comment describing the group. |
| profile.characteristics.items | enum[] | has_alarm, has_balcony, has_cellar, has_lift, has_pool, has_garage, has_garden, has_terrace, has_parking, has_fireplace, has_mezzanine, has_concierge, has_digicode, has_interphone, has_jacuzzi, has_land, has_grenier, has_vis_a_vis, is_peaceful, has_two_doors_at_entrance, is_squatted | Boolean features that must be enforced. |
| profile.poi_proximity | array<object> | | Constraints linked to points of interest (POI). |
| profile.poi_proximity.poi_category | string | | Target POI category (SKI_LIFT, FOOD, SCHOOL, etc.). |
| profile.poi_proximity.max_walk_time_minutes | number | | Maximum walking time to the POI. |
| profile.poi_proximity.max_car_time_minutes | number | | Maximum driving time to the POI. |
| profile.poi_proximity.max_transport_time_minutes | number | | Maximum public transport time to the POI. |
| profile.poi_proximity.max_bike_time_minutes | number | | Maximum biking time to the POI. |
| profile.poi_proximity.max_distance_meters | number | | Maximum straight-line distance to the POI (meters). |
| profile.poi_proximity.lift_type | string | | Required lift subtype when poi_category is SKI_LIFT. |
| profile.poi_proximity.domain_min_altitude | number | | Minimum altitude for the associated ski domain. |
| profile.poi_proximity.min_rating | number | | Minimum rating required for the POI. |
| profile.vision_requirements | object | | Qualitative constraints assessed from listing photos. |
| profile.vision_requirements.description | string | | Optional overview of visual expectations. |
| profile.vision_requirements.kitchen | object | | Main kitchen expectations. |
| profile.vision_requirements.kitchen.description | string | | Additional comment for the kitchen requirement. |
| profile.vision_requirements.kitchen.size | enum | Small, Medium, Large | Target kitchen size. |
| profile.vision_requirements.kitchen.open | boolean | | Whether an open kitchen is required. |
| profile.vision_requirements.kitchen.appliances | string | | Expected appliances level or list. |
| profile.vision_requirements.kitchen.weight | number | | Weight applied to the kitchen requirement. |
| profile.vision_requirements.kitchen.missing_penalty | number | | Penalty applied if kitchen data is missing. |
| profile.vision_requirements.bathroom | object | | Main bathroom expectations. |
| profile.vision_requirements.bathroom.description | string | | Additional comment for the bathroom requirement. |
| profile.vision_requirements.bathroom.size | enum | Small, Medium, Large | Target bathroom size. |
| profile.vision_requirements.bathroom.appliances | string | | Desired bathroom equipment. |
| profile.vision_requirements.bathroom.weight | number | | Weight applied to the bathroom requirement. |
| profile.vision_requirements.bathroom.missing_penalty | number | | Penalty applied if bathroom data is missing. |
| profile.vision_requirements.living_room | object | | Living room expectations. |
| profile.vision_requirements.living_room.description | string | | Additional comment for the living room requirement. |
| profile.vision_requirements.living_room.size | enum | Small, Medium, Large | Target living room size. |
| profile.vision_requirements.living_room.weight | number | | Weight applied to the living room requirement. |
| profile.vision_requirements.living_room.missing_penalty | number | | Penalty applied if living room data is missing. |
| profile.vision_requirements.bedrooms | object | | Bedroom detection expectations. |
| profile.vision_requirements.bedrooms.description | string | | Additional comment for the bedroom requirement. |
| profile.vision_requirements.bedrooms.min_count | number | | Minimum number of bedrooms detected. |
| profile.vision_requirements.bedrooms.weight | number | | Weight applied to the bedroom requirement. |
| profile.vision_requirements.bedrooms.missing_penalty | number | | Penalty applied if bedroom data is missing. |
| profile.vision_requirements.outdoor | object | | Outdoor space expectations. |
| profile.vision_requirements.outdoor.description | string | | Additional comment for the outdoor requirement. |
| profile.vision_requirements.outdoor.required | boolean | | Whether an outdoor space is mandatory. |
| profile.vision_requirements.outdoor.types | array<string> | | Accepted outdoor types (Balcony, Large Terrace, etc.). |
| profile.vision_requirements.outdoor.weight | number | | Weight applied to the outdoor requirement. |
| profile.vision_requirements.outdoor.missing_penalty | number | | Penalty applied if outdoor data is missing. |
| profile.vision_requirements.note | string | | Free-form note to contextualise visual expectations. |
### Example Profile Payload
```json
{
"transaction_type": "rent",
"min_price": 750,
"max_price": 1200,
"cities": ["Grenoble", "Meylan"],
"characteristics": [
{
"type": "all",
"items": ["has_balcony", "has_parking"]
}
],
"vision_requirements": {
"kitchen": {
"size": "Medium",
"open": true,
"weight": 0.4
},
"outdoor": {
"required": true,
"types": ["Balcony", "Terrace"],
"weight": 0.3
}
}
}
```
## Prompting Tips
1. Remind the LLM that the answer must be JSON only.
2. Quote the relevant table entries (scraper or profile) when asking for specific fields.
3. Map narrative requirements to explicit schema keys before requesting the payload.
4. Mention when optional branches should be omitted versus included.
5. Encourage the model to double-check enum spelling and numeric formats.
Following this guide, an LLM can reliably fill the structured forms used by the Managinator frontend.

View File

@ -0,0 +1,21 @@
import scraperSchemaSource from "../../scraper-schema.json";
import { normalizeRootSchema } from "./normalize";
import { profileSchema } from "./profile-schema";
import type { SchemaDefinition } from "./types";
type SchemaRegistry = {
scraper: SchemaDefinition;
profile: SchemaDefinition;
};
const registry: SchemaRegistry = {
scraper: normalizeRootSchema(scraperSchemaSource),
profile: profileSchema,
};
export type SchemaName = keyof SchemaRegistry;
export function getSchema(name: SchemaName): SchemaDefinition {
return registry[name];
}

View File

@ -0,0 +1,183 @@
import type {
PrimitiveType,
SchemaArrayNode,
SchemaDefinition,
SchemaEnumNode,
SchemaNode,
SchemaObjectField,
SchemaObjectNode,
SchemaPrimitiveNode,
SchemaRangeNode,
} from "./types";
const PRIMITIVE_TYPES: PrimitiveType[] = ["string", "number", "boolean", "date"];
const RANGE_KEYS = new Set(["min", "max"]);
const MULTI_VALUE_HINTS = [
/s$/i,
/List$/i,
/Types$/i,
/Options$/i,
/Values$/i,
];
function isPrimitiveLiteral(value: string): value is PrimitiveType {
return PRIMITIVE_TYPES.includes(value as PrimitiveType);
}
function isEnumUnionLiteral(value: string) {
return value.includes("|");
}
function normalizeEnumUnion(value: string) {
return value
.split("|")
.map((part) => part.trim())
.filter(Boolean);
}
function inferEnumMultiplicity(key: string | undefined) {
if (!key) {
return false;
}
return MULTI_VALUE_HINTS.some((pattern) => pattern.test(key));
}
function createPrimitiveNode(type: PrimitiveType, overrides?: Partial<SchemaPrimitiveNode>): SchemaPrimitiveNode {
return {
kind: "primitive",
type,
required: false,
...overrides,
};
}
function createEnumNode(
options: string[],
key?: string,
overrides?: Partial<SchemaEnumNode>
): SchemaEnumNode {
return {
kind: "enum",
options,
multiple: inferEnumMultiplicity(key),
required: false,
...overrides,
};
}
function createRangeNode(valueType: PrimitiveType, overrides?: Partial<SchemaRangeNode>): SchemaRangeNode {
return {
kind: "range",
valueType,
required: false,
...overrides,
};
}
function createArrayNode(element: SchemaNode, overrides?: Partial<SchemaArrayNode>): SchemaArrayNode {
return {
kind: "array",
element,
required: false,
...overrides,
};
}
function createObjectNode(fields: SchemaObjectField[], overrides?: Partial<SchemaObjectNode>): SchemaObjectNode {
return {
kind: "object",
fields,
required: false,
...overrides,
};
}
interface NormalizeContext {
key?: string;
}
export function normalizeSchema(raw: unknown, context: NormalizeContext = {}): SchemaNode {
if (typeof raw === "string") {
const literal = raw.trim();
if (isPrimitiveLiteral(literal)) {
return createPrimitiveNode(literal);
}
if (isEnumUnionLiteral(literal)) {
return createEnumNode(normalizeEnumUnion(literal), context.key);
}
return createPrimitiveNode("string");
}
if (Array.isArray(raw)) {
if (raw.length === 0) {
return createArrayNode(createPrimitiveNode("string"));
}
const allStrings = raw.every((value) => typeof value === "string");
if (allStrings) {
return createEnumNode(raw.map((value) => value.trim()).filter(Boolean), context.key);
}
if (raw.length === 1 && typeof raw[0] === "string") {
const literal = raw[0].trim();
if (isPrimitiveLiteral(literal)) {
return createArrayNode(createPrimitiveNode(literal));
}
if (isEnumUnionLiteral(literal)) {
return createArrayNode(createEnumNode(normalizeEnumUnion(literal), context.key));
}
return createArrayNode(createPrimitiveNode("string"));
}
if (raw.length === 1 && typeof raw[0] === "object" && raw[0] !== null) {
return createArrayNode(normalizeSchema(raw[0], context));
}
return createArrayNode(createPrimitiveNode("string"));
}
if (raw && typeof raw === "object") {
const entries = Object.entries(raw as Record<string, unknown>);
if (entries.length > 0 && entries.every(([key]) => RANGE_KEYS.has(key))) {
const firstEntry = entries[0];
const value = firstEntry?.[1];
if (typeof value === "string" && isPrimitiveLiteral(value.trim())) {
return createRangeNode(value.trim() as PrimitiveType);
}
return createRangeNode("number");
}
const fields: SchemaObjectField[] = entries.map(([key, value]) => ({
key,
schema: normalizeSchema(value, { key }),
required: false,
}));
return createObjectNode(fields);
}
return createPrimitiveNode("string");
}
export function normalizeRootSchema(raw: unknown): SchemaDefinition {
const schema = normalizeSchema(raw);
if (schema.kind !== "object") {
throw new Error("Root schema must be an object definition.");
}
return schema;
}

View File

@ -0,0 +1,375 @@
import type { SchemaDefinition, SchemaObjectNode } from "./types";
const characteristicOptions = [
"has_alarm",
"has_balcony",
"has_cellar",
"has_lift",
"has_pool",
"has_garage",
"has_garden",
"has_terrace",
"has_parking",
"has_fireplace",
"has_mezzanine",
"has_concierge",
"has_digicode",
"has_interphone",
"has_jacuzzi",
"has_land",
"has_grenier",
"has_vis_a_vis",
"is_peaceful",
"has_two_doors_at_entrance",
"is_squatted",
];
const visionSizeOptions = ["Small", "Medium", "Large"];
const transactionOptions = ["sale", "rent", "viager"];
const dpeClassOptions = ["A", "B", "C", "D", "E", "F", "G", "NC"];
const characteristicsSchema: SchemaObjectNode = {
kind: "object",
required: false,
fields: [
{
key: "type",
schema: {
kind: "enum",
options: ["any", "all", "none"],
required: false,
label: "Evaluation mode",
description: "Defines how the boolean flags are evaluated.",
},
},
{
key: "description",
schema: {
kind: "primitive",
type: "string",
required: false,
label: "Description",
},
},
{
key: "items",
schema: {
kind: "array",
required: false,
element: {
kind: "enum",
options: characteristicOptions,
required: false,
},
label: "Feature identifiers",
description: "List of boolean feature identifiers to evaluate.",
},
},
],
};
const poiConstraintSchema: SchemaObjectNode = {
kind: "object",
required: false,
fields: [
{
key: "poi_category",
schema: {
kind: "primitive",
type: "string",
required: false,
label: "POI category",
},
},
{
key: "max_walk_time_minutes",
schema: { kind: "primitive", type: "number", required: false },
},
{
key: "max_car_time_minutes",
schema: { kind: "primitive", type: "number", required: false },
},
{
key: "max_transport_time_minutes",
schema: { kind: "primitive", type: "number", required: false },
},
{
key: "max_bike_time_minutes",
schema: { kind: "primitive", type: "number", required: false },
},
{
key: "max_distance_meters",
schema: { kind: "primitive", type: "number", required: false },
},
{
key: "lift_type",
schema: { kind: "primitive", type: "string", required: false },
},
{
key: "domain_min_altitude",
schema: { kind: "primitive", type: "number", required: false },
},
{
key: "min_rating",
schema: { kind: "primitive", type: "number", required: false },
},
],
};
const visionWeightFields: SchemaObjectNode["fields"] = [
{
key: "weight",
schema: {
kind: "primitive",
type: "number",
required: false,
},
},
{
key: "missing_penalty",
schema: {
kind: "primitive",
type: "number",
required: false,
},
},
];
export const profileSchema: SchemaDefinition = {
kind: "object",
required: false,
fields: [
{
key: "transaction_type",
schema: {
kind: "enum",
options: transactionOptions,
required: false,
label: "Transaction type",
description: "Desired transaction type.",
},
},
{
key: "min_price",
schema: { kind: "primitive", type: "number", required: false },
},
{
key: "max_price",
schema: { kind: "primitive", type: "number", required: false },
},
{
key: "min_size",
schema: { kind: "primitive", type: "number", required: false },
},
{
key: "max_size",
schema: { kind: "primitive", type: "number", required: false },
},
{
key: "min_bedrooms",
schema: { kind: "primitive", type: "number", required: false },
},
{
key: "max_bedrooms",
schema: { kind: "primitive", type: "number", required: false },
},
{
key: "min_bathrooms",
schema: { kind: "primitive", type: "number", required: false },
},
{
key: "property_type",
schema: {
kind: "array",
required: false,
element: { kind: "primitive", type: "string", required: false },
description: "List of property types (apartment, house, etc.).",
},
},
{
key: "dpe_classes",
schema: {
kind: "enum",
options: dpeClassOptions,
multiple: true,
required: false,
description: "Accepted energy classes.",
},
},
{
key: "city",
schema: { kind: "primitive", type: "string", required: false },
},
{
key: "cities",
schema: {
kind: "array",
required: false,
element: { kind: "primitive", type: "string", required: false },
},
},
{
key: "postal_code",
schema: { kind: "primitive", type: "string", required: false },
},
{
key: "code_insee",
schema: { kind: "primitive", type: "string", required: false },
},
{
key: "target_yield",
schema: { kind: "primitive", type: "number", required: false },
},
{
key: "require_address",
schema: { kind: "primitive", type: "boolean", required: false },
},
{
key: "characteristics",
schema: {
kind: "array",
required: false,
element: characteristicsSchema,
description: "Boolean feature groups to enforce.",
},
},
{
key: "poi_proximity",
schema: {
kind: "array",
required: false,
element: poiConstraintSchema,
description: "Proximity constraints relative to points of interest.",
},
},
{
key: "vision_requirements",
schema: {
kind: "object",
required: false,
fields: [
{
key: "description",
schema: { kind: "primitive", type: "string", required: false },
},
{
key: "kitchen",
schema: {
kind: "object",
required: false,
fields: [
{
key: "description",
schema: { kind: "primitive", type: "string", required: false },
},
{
key: "size",
schema: { kind: "enum", options: visionSizeOptions, required: false },
},
{
key: "open",
schema: { kind: "primitive", type: "boolean", required: false },
},
{
key: "appliances",
schema: { kind: "primitive", type: "string", required: false },
},
...visionWeightFields,
],
},
},
{
key: "bathroom",
schema: {
kind: "object",
required: false,
fields: [
{
key: "description",
schema: { kind: "primitive", type: "string", required: false },
},
{
key: "size",
schema: { kind: "enum", options: visionSizeOptions, required: false },
},
{
key: "appliances",
schema: { kind: "primitive", type: "string", required: false },
},
...visionWeightFields,
],
},
},
{
key: "living_room",
schema: {
kind: "object",
required: false,
fields: [
{
key: "description",
schema: { kind: "primitive", type: "string", required: false },
},
{
key: "size",
schema: { kind: "enum", options: visionSizeOptions, required: false },
},
...visionWeightFields,
],
},
},
{
key: "bedrooms",
schema: {
kind: "object",
required: false,
fields: [
{
key: "description",
schema: { kind: "primitive", type: "string", required: false },
},
{
key: "min_count",
schema: { kind: "primitive", type: "number", required: false },
},
...visionWeightFields,
],
},
},
{
key: "outdoor",
schema: {
kind: "object",
required: false,
fields: [
{
key: "description",
schema: { kind: "primitive", type: "string", required: false },
},
{
key: "required",
schema: { kind: "primitive", type: "boolean", required: false },
},
{
key: "types",
schema: {
kind: "array",
required: false,
element: { kind: "primitive", type: "string", required: false },
},
},
...visionWeightFields,
],
},
},
{
key: "note",
schema: { kind: "primitive", type: "string", required: false },
},
],
},
},
],
};

View File

@ -0,0 +1,58 @@
export type PrimitiveType = "string" | "number" | "boolean" | "date";
export interface SchemaBaseNode {
label?: string;
description?: string;
required?: boolean;
defaultValue?: unknown;
}
export interface SchemaPrimitiveNode extends SchemaBaseNode {
kind: "primitive";
type: PrimitiveType;
}
export interface SchemaEnumNode extends SchemaBaseNode {
kind: "enum";
options: string[];
multiple?: boolean;
}
export interface SchemaRangeNode extends SchemaBaseNode {
kind: "range";
valueType: PrimitiveType;
}
export interface SchemaArrayNode extends SchemaBaseNode {
kind: "array";
element: SchemaNode;
minItems?: number;
maxItems?: number;
}
export interface SchemaObjectField {
key: string;
schema: SchemaNode;
required?: boolean;
description?: string;
}
export interface SchemaObjectNode extends SchemaBaseNode {
kind: "object";
fields: SchemaObjectField[];
}
export type SchemaNode =
| SchemaPrimitiveNode
| SchemaEnumNode
| SchemaRangeNode
| SchemaArrayNode
| SchemaObjectNode;
export interface SchemaValidationError {
path: string;
message: string;
}
export type SchemaDefinition = SchemaObjectNode;

View File

@ -0,0 +1,136 @@
import type {
SchemaArrayNode,
SchemaDefinition,
SchemaEnumNode,
SchemaNode,
SchemaObjectNode,
SchemaPrimitiveNode,
SchemaRangeNode,
} from "./types";
export function buildDefaultValue(schema: SchemaNode): unknown {
switch (schema.kind) {
case "primitive":
return buildPrimitiveDefault(schema);
case "enum":
return buildEnumDefault(schema);
case "range":
return buildRangeDefault(schema);
case "array":
return buildArrayDefault(schema);
case "object":
return buildObjectDefault(schema);
default:
return null;
}
}
function buildPrimitiveDefault(schema: SchemaPrimitiveNode) {
if (schema.defaultValue !== undefined) {
return schema.defaultValue;
}
switch (schema.type) {
case "string":
return "";
case "number":
return null;
case "boolean":
return false;
case "date":
return "";
default:
return null;
}
}
function buildEnumDefault(schema: SchemaEnumNode) {
if (schema.multiple) {
return [];
}
return schema.options[0] ?? "";
}
function buildRangeDefault(schema: SchemaRangeNode) {
return {
min: buildPrimitiveDefault({ kind: "primitive", type: schema.valueType }),
max: buildPrimitiveDefault({ kind: "primitive", type: schema.valueType }),
};
}
function buildArrayDefault(schema: SchemaArrayNode) {
return [];
}
function buildObjectDefault(schema: SchemaObjectNode) {
return schema.fields.reduce<Record<string, unknown>>((accumulator, field) => {
accumulator[field.key] = buildDefaultValue(field.schema);
return accumulator;
}, {});
}
export function cloneWithDefaults(schema: SchemaDefinition, value: unknown) {
if (!value || typeof value !== "object") {
return buildDefaultValue(schema);
}
return mergeDefaults(schema, value as Record<string, unknown>);
}
function mergeDefaults(schema: SchemaNode, value: unknown): unknown {
switch (schema.kind) {
case "primitive":
case "enum":
return value;
case "range":
return mergeRangeDefaults(schema, value);
case "array":
return mergeArrayDefaults(schema, value);
case "object":
return mergeObjectDefaults(schema, value);
default:
return value;
}
}
function mergeRangeDefaults(schema: SchemaRangeNode, value: unknown) {
if (!value || typeof value !== "object") {
return buildRangeDefault(schema);
}
const record = value as Record<string, unknown>;
return {
min:
record.min !== undefined
? record.min
: buildPrimitiveDefault({ kind: "primitive", type: schema.valueType }),
max:
record.max !== undefined
? record.max
: buildPrimitiveDefault({ kind: "primitive", type: schema.valueType }),
};
}
function mergeArrayDefaults(schema: SchemaArrayNode, value: unknown) {
if (!Array.isArray(value)) {
return [];
}
return value.map((item) => mergeDefaults(schema.element, item));
}
function mergeObjectDefaults(schema: SchemaObjectNode, value: unknown) {
if (!value || typeof value !== "object") {
return buildObjectDefault(schema);
}
const record = value as Record<string, unknown>;
return schema.fields.reduce<Record<string, unknown>>((accumulator, field) => {
accumulator[field.key] = mergeDefaults(field.schema, record[field.key]);
return accumulator;
}, {});
}

View File

@ -0,0 +1,32 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "ES2022",
"useDefineForClassFields": true,
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext",
"types": ["vite/client"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true,
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
}
},
"include": ["src"]
}

13
frontend/tsconfig.json Normal file
View File

@ -0,0 +1,13 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
],
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
}
}
}

View File

@ -0,0 +1,26 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "ES2023",
"lib": ["ES2023"],
"module": "ESNext",
"types": ["node"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["vite.config.ts"]
}

14
frontend/vite.config.ts Normal file
View File

@ -0,0 +1,14 @@
import path from "path";
import tailwindcss from "@tailwindcss/vite";
import react from "@vitejs/plugin-react";
import { defineConfig } from "vite";
// https://vite.dev/config/
export default defineConfig({
plugins: [react(), tailwindcss()],
resolve: {
alias: {
"@": path.resolve(__dirname, "./src"),
},
},
});