init
This commit is contained in:
commit
07ac6c422f
7
backend/.env
Normal file
7
backend/.env
Normal 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
486
backend/api_summary.json
Normal 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
723
backend/app.py
Normal 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
4
backend/requirements.txt
Normal 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
353
backend/search_schema.json
Normal 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
24
frontend/.gitignore
vendored
Normal 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
75
frontend/README.md
Normal 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
1289
frontend/api-schema.json
Normal file
File diff suppressed because it is too large
Load Diff
680
frontend/bun.lock
Normal file
680
frontend/bun.lock
Normal 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
22
frontend/components.json
Normal 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
23
frontend/eslint.config.js
Normal 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
13
frontend/index.html
Normal 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
47
frontend/package.json
Normal 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
1
frontend/public/vite.svg
Normal 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 |
353
frontend/scraper-schema.json
Normal file
353
frontend/scraper-schema.json
Normal 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
91
frontend/src/App.tsx
Normal 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;
|
||||
26
frontend/src/ScraperPage.tsx
Normal file
26
frontend/src/ScraperPage.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
1
frontend/src/assets/react.svg
Normal file
1
frontend/src/assets/react.svg
Normal 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 |
392
frontend/src/components/json-fields.tsx
Normal file
392
frontend/src/components/json-fields.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
129
frontend/src/components/schema-builder/SchemaAwareJsonField.tsx
Normal file
129
frontend/src/components/schema-builder/SchemaAwareJsonField.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
43
frontend/src/components/schema-builder/SchemaBuilder.tsx
Normal file
43
frontend/src/components/schema-builder/SchemaBuilder.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
||||
25
frontend/src/components/schema-builder/SchemaFieldLabel.tsx
Normal file
25
frontend/src/components/schema-builder/SchemaFieldLabel.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
||||
342
frontend/src/components/schema-builder/SchemaFieldRenderer.tsx
Normal file
342
frontend/src/components/schema-builder/SchemaFieldRenderer.tsx
Normal 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>;
|
||||
}
|
||||
3
frontend/src/components/schema-builder/index.ts
Normal file
3
frontend/src/components/schema-builder/index.ts
Normal file
@ -0,0 +1,3 @@
|
||||
export { SchemaBuilder } from "./SchemaBuilder";
|
||||
export { SchemaAwareJsonField } from "./SchemaAwareJsonField";
|
||||
|
||||
181
frontend/src/components/schema-builder/useSchemaValidation.ts
Normal file
181
frontend/src/components/schema-builder/useSchemaValidation.ts
Normal 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}]`;
|
||||
}
|
||||
60
frontend/src/components/ui/button.tsx
Normal file
60
frontend/src/components/ui/button.tsx
Normal 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 }
|
||||
92
frontend/src/components/ui/card.tsx
Normal file
92
frontend/src/components/ui/card.tsx
Normal 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,
|
||||
}
|
||||
32
frontend/src/components/ui/checkbox.tsx
Normal file
32
frontend/src/components/ui/checkbox.tsx
Normal 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 }
|
||||
21
frontend/src/components/ui/input.tsx
Normal file
21
frontend/src/components/ui/input.tsx
Normal 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 }
|
||||
185
frontend/src/components/ui/select.tsx
Normal file
185
frontend/src/components/ui/select.tsx
Normal 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,
|
||||
}
|
||||
64
frontend/src/components/ui/tabs.tsx
Normal file
64
frontend/src/components/ui/tabs.tsx
Normal 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 }
|
||||
19
frontend/src/components/ui/textarea.tsx
Normal file
19
frontend/src/components/ui/textarea.tsx
Normal 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 };
|
||||
|
||||
464
frontend/src/features/profiles/ProfilesTab.tsx
Normal file
464
frontend/src/features/profiles/ProfilesTab.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
603
frontend/src/features/scrapers/ScrapersTab.tsx
Normal file
603
frontend/src/features/scrapers/ScrapersTab.tsx
Normal 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
120
frontend/src/index.css
Normal 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
148
frontend/src/lib/api.ts
Normal 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,
|
||||
};
|
||||
6
frontend/src/lib/utils.ts
Normal file
6
frontend/src/lib/utils.ts
Normal file
@ -0,0 +1,6 @@
|
||||
import { clsx, type ClassValue } from "clsx"
|
||||
import { twMerge } from "tailwind-merge"
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs))
|
||||
}
|
||||
16
frontend/src/main.tsx
Normal file
16
frontend/src/main.tsx
Normal 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>
|
||||
);
|
||||
321
frontend/src/schemas/llm-json-guide.md
Normal file
321
frontend/src/schemas/llm-json-guide.md
Normal 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.
|
||||
21
frontend/src/schemas/loader.ts
Normal file
21
frontend/src/schemas/loader.ts
Normal 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];
|
||||
}
|
||||
183
frontend/src/schemas/normalize.ts
Normal file
183
frontend/src/schemas/normalize.ts
Normal 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;
|
||||
}
|
||||
375
frontend/src/schemas/profile-schema.ts
Normal file
375
frontend/src/schemas/profile-schema.ts
Normal 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 },
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
58
frontend/src/schemas/types.ts
Normal file
58
frontend/src/schemas/types.ts
Normal 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;
|
||||
|
||||
136
frontend/src/schemas/utils.ts
Normal file
136
frontend/src/schemas/utils.ts
Normal 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;
|
||||
}, {});
|
||||
}
|
||||
|
||||
32
frontend/tsconfig.app.json
Normal file
32
frontend/tsconfig.app.json
Normal 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
13
frontend/tsconfig.json
Normal file
@ -0,0 +1,13 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.app.json" },
|
||||
{ "path": "./tsconfig.node.json" }
|
||||
],
|
||||
"compilerOptions": {
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
}
|
||||
}
|
||||
26
frontend/tsconfig.node.json
Normal file
26
frontend/tsconfig.node.json
Normal 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
14
frontend/vite.config.ts
Normal 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"),
|
||||
},
|
||||
},
|
||||
});
|
||||
Loading…
x
Reference in New Issue
Block a user