mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
feat: implement Files page with persistent storage and version tracking
Three-panel file manager (nav, list, details) modeled after Stirling-PDF. - Backend: user_files table, /api/v1/files/* CRUD routes, file storage helpers, thumbnail generation via Sharp, recursive CTE version chains - Frontend: FilesNav, FileList, FileDetails, FileUploadArea components, Zustand store, mobile layout with bottom sheet - Integration: tool-factory auto-saves results as new versions when fileId is provided, "Open File" loads file into tool processing flow - Search, bulk select/delete/download, version badges, tool chain tags
This commit is contained in:
@@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE `users` ADD `team` text NOT NULL DEFAULT 'Default';
|
||||||
@@ -57,12 +57,8 @@
|
|||||||
"name": "api_keys_user_id_users_id_fk",
|
"name": "api_keys_user_id_users_id_fk",
|
||||||
"tableFrom": "api_keys",
|
"tableFrom": "api_keys",
|
||||||
"tableTo": "users",
|
"tableTo": "users",
|
||||||
"columnsFrom": [
|
"columnsFrom": ["user_id"],
|
||||||
"user_id"
|
"columnsTo": ["id"],
|
||||||
],
|
|
||||||
"columnsTo": [
|
|
||||||
"id"
|
|
||||||
],
|
|
||||||
"onDelete": "cascade",
|
"onDelete": "cascade",
|
||||||
"onUpdate": "no action"
|
"onUpdate": "no action"
|
||||||
}
|
}
|
||||||
@@ -191,12 +187,8 @@
|
|||||||
"name": "sessions_user_id_users_id_fk",
|
"name": "sessions_user_id_users_id_fk",
|
||||||
"tableFrom": "sessions",
|
"tableFrom": "sessions",
|
||||||
"tableTo": "users",
|
"tableTo": "users",
|
||||||
"columnsFrom": [
|
"columnsFrom": ["user_id"],
|
||||||
"user_id"
|
"columnsTo": ["id"],
|
||||||
],
|
|
||||||
"columnsTo": [
|
|
||||||
"id"
|
|
||||||
],
|
|
||||||
"onDelete": "cascade",
|
"onDelete": "cascade",
|
||||||
"onUpdate": "no action"
|
"onUpdate": "no action"
|
||||||
}
|
}
|
||||||
@@ -294,9 +286,7 @@
|
|||||||
"indexes": {
|
"indexes": {
|
||||||
"users_username_unique": {
|
"users_username_unique": {
|
||||||
"name": "users_username_unique",
|
"name": "users_username_unique",
|
||||||
"columns": [
|
"columns": ["username"],
|
||||||
"username"
|
|
||||||
],
|
|
||||||
"isUnique": true
|
"isUnique": true
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -57,12 +57,8 @@
|
|||||||
"name": "api_keys_user_id_users_id_fk",
|
"name": "api_keys_user_id_users_id_fk",
|
||||||
"tableFrom": "api_keys",
|
"tableFrom": "api_keys",
|
||||||
"tableTo": "users",
|
"tableTo": "users",
|
||||||
"columnsFrom": [
|
"columnsFrom": ["user_id"],
|
||||||
"user_id"
|
"columnsTo": ["id"],
|
||||||
],
|
|
||||||
"columnsTo": [
|
|
||||||
"id"
|
|
||||||
],
|
|
||||||
"onDelete": "cascade",
|
"onDelete": "cascade",
|
||||||
"onUpdate": "no action"
|
"onUpdate": "no action"
|
||||||
}
|
}
|
||||||
@@ -236,12 +232,8 @@
|
|||||||
"name": "sessions_user_id_users_id_fk",
|
"name": "sessions_user_id_users_id_fk",
|
||||||
"tableFrom": "sessions",
|
"tableFrom": "sessions",
|
||||||
"tableTo": "users",
|
"tableTo": "users",
|
||||||
"columnsFrom": [
|
"columnsFrom": ["user_id"],
|
||||||
"user_id"
|
"columnsTo": ["id"],
|
||||||
],
|
|
||||||
"columnsTo": [
|
|
||||||
"id"
|
|
||||||
],
|
|
||||||
"onDelete": "cascade",
|
"onDelete": "cascade",
|
||||||
"onUpdate": "no action"
|
"onUpdate": "no action"
|
||||||
}
|
}
|
||||||
@@ -339,9 +331,7 @@
|
|||||||
"indexes": {
|
"indexes": {
|
||||||
"users_username_unique": {
|
"users_username_unique": {
|
||||||
"name": "users_username_unique",
|
"name": "users_username_unique",
|
||||||
"columns": [
|
"columns": ["username"],
|
||||||
"username"
|
|
||||||
],
|
|
||||||
"isUnique": true
|
"isUnique": true
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -64,12 +64,8 @@
|
|||||||
"name": "api_keys_user_id_users_id_fk",
|
"name": "api_keys_user_id_users_id_fk",
|
||||||
"tableFrom": "api_keys",
|
"tableFrom": "api_keys",
|
||||||
"tableTo": "users",
|
"tableTo": "users",
|
||||||
"columnsFrom": [
|
"columnsFrom": ["user_id"],
|
||||||
"user_id"
|
"columnsTo": ["id"],
|
||||||
],
|
|
||||||
"columnsTo": [
|
|
||||||
"id"
|
|
||||||
],
|
|
||||||
"onDelete": "cascade",
|
"onDelete": "cascade",
|
||||||
"onUpdate": "no action"
|
"onUpdate": "no action"
|
||||||
}
|
}
|
||||||
@@ -212,12 +208,8 @@
|
|||||||
"name": "pipelines_user_id_users_id_fk",
|
"name": "pipelines_user_id_users_id_fk",
|
||||||
"tableFrom": "pipelines",
|
"tableFrom": "pipelines",
|
||||||
"tableTo": "users",
|
"tableTo": "users",
|
||||||
"columnsFrom": [
|
"columnsFrom": ["user_id"],
|
||||||
"user_id"
|
"columnsTo": ["id"],
|
||||||
],
|
|
||||||
"columnsTo": [
|
|
||||||
"id"
|
|
||||||
],
|
|
||||||
"onDelete": "cascade",
|
"onDelete": "cascade",
|
||||||
"onUpdate": "no action"
|
"onUpdate": "no action"
|
||||||
}
|
}
|
||||||
@@ -264,12 +256,8 @@
|
|||||||
"name": "sessions_user_id_users_id_fk",
|
"name": "sessions_user_id_users_id_fk",
|
||||||
"tableFrom": "sessions",
|
"tableFrom": "sessions",
|
||||||
"tableTo": "users",
|
"tableTo": "users",
|
||||||
"columnsFrom": [
|
"columnsFrom": ["user_id"],
|
||||||
"user_id"
|
"columnsTo": ["id"],
|
||||||
],
|
|
||||||
"columnsTo": [
|
|
||||||
"id"
|
|
||||||
],
|
|
||||||
"onDelete": "cascade",
|
"onDelete": "cascade",
|
||||||
"onUpdate": "no action"
|
"onUpdate": "no action"
|
||||||
}
|
}
|
||||||
@@ -367,9 +355,7 @@
|
|||||||
"indexes": {
|
"indexes": {
|
||||||
"users_username_unique": {
|
"users_username_unique": {
|
||||||
"name": "users_username_unique",
|
"name": "users_username_unique",
|
||||||
"columns": [
|
"columns": ["username"],
|
||||||
"username"
|
|
||||||
],
|
|
||||||
"isUnique": true
|
"isUnique": true
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -0,0 +1,386 @@
|
|||||||
|
{
|
||||||
|
"version": "6",
|
||||||
|
"dialect": "sqlite",
|
||||||
|
"id": "c1a2b3d4-e5f6-7890-abcd-ef1234567890",
|
||||||
|
"prevId": "b73098db-4ef1-44e2-834f-9114a4da4e77",
|
||||||
|
"tables": {
|
||||||
|
"api_keys": {
|
||||||
|
"name": "api_keys",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"user_id": {
|
||||||
|
"name": "user_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"key_hash": {
|
||||||
|
"name": "key_hash",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"key_prefix": {
|
||||||
|
"name": "key_prefix",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"name": {
|
||||||
|
"name": "name",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": "'Default API Key'"
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"last_used_at": {
|
||||||
|
"name": "last_used_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {
|
||||||
|
"api_keys_user_id_users_id_fk": {
|
||||||
|
"name": "api_keys_user_id_users_id_fk",
|
||||||
|
"tableFrom": "api_keys",
|
||||||
|
"tableTo": "users",
|
||||||
|
"columnsFrom": ["user_id"],
|
||||||
|
"columnsTo": ["id"],
|
||||||
|
"onDelete": "cascade",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraints": {}
|
||||||
|
},
|
||||||
|
"jobs": {
|
||||||
|
"name": "jobs",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"type": {
|
||||||
|
"name": "type",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"status": {
|
||||||
|
"name": "status",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": "'queued'"
|
||||||
|
},
|
||||||
|
"progress": {
|
||||||
|
"name": "progress",
|
||||||
|
"type": "real",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": 0
|
||||||
|
},
|
||||||
|
"input_files": {
|
||||||
|
"name": "input_files",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"output_path": {
|
||||||
|
"name": "output_path",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"settings": {
|
||||||
|
"name": "settings",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"error": {
|
||||||
|
"name": "error",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"completed_at": {
|
||||||
|
"name": "completed_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraints": {}
|
||||||
|
},
|
||||||
|
"pipelines": {
|
||||||
|
"name": "pipelines",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"user_id": {
|
||||||
|
"name": "user_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"name": {
|
||||||
|
"name": "name",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"description": {
|
||||||
|
"name": "description",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"steps": {
|
||||||
|
"name": "steps",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {
|
||||||
|
"pipelines_user_id_users_id_fk": {
|
||||||
|
"name": "pipelines_user_id_users_id_fk",
|
||||||
|
"tableFrom": "pipelines",
|
||||||
|
"tableTo": "users",
|
||||||
|
"columnsFrom": ["user_id"],
|
||||||
|
"columnsTo": ["id"],
|
||||||
|
"onDelete": "cascade",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraints": {}
|
||||||
|
},
|
||||||
|
"sessions": {
|
||||||
|
"name": "sessions",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"user_id": {
|
||||||
|
"name": "user_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"expires_at": {
|
||||||
|
"name": "expires_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {
|
||||||
|
"sessions_user_id_users_id_fk": {
|
||||||
|
"name": "sessions_user_id_users_id_fk",
|
||||||
|
"tableFrom": "sessions",
|
||||||
|
"tableTo": "users",
|
||||||
|
"columnsFrom": ["user_id"],
|
||||||
|
"columnsTo": ["id"],
|
||||||
|
"onDelete": "cascade",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraints": {}
|
||||||
|
},
|
||||||
|
"settings": {
|
||||||
|
"name": "settings",
|
||||||
|
"columns": {
|
||||||
|
"key": {
|
||||||
|
"name": "key",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"value": {
|
||||||
|
"name": "value",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"updated_at": {
|
||||||
|
"name": "updated_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraints": {}
|
||||||
|
},
|
||||||
|
"users": {
|
||||||
|
"name": "users",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"username": {
|
||||||
|
"name": "username",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"password_hash": {
|
||||||
|
"name": "password_hash",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"role": {
|
||||||
|
"name": "role",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": "'user'"
|
||||||
|
},
|
||||||
|
"must_change_password": {
|
||||||
|
"name": "must_change_password",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": true
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"updated_at": {
|
||||||
|
"name": "updated_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"team": {
|
||||||
|
"name": "team",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": "'Default'"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {
|
||||||
|
"users_username_unique": {
|
||||||
|
"name": "users_username_unique",
|
||||||
|
"columns": ["username"],
|
||||||
|
"isUnique": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"foreignKeys": {},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraints": {}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"views": {},
|
||||||
|
"enums": {},
|
||||||
|
"_meta": {
|
||||||
|
"schemas": {},
|
||||||
|
"tables": {},
|
||||||
|
"columns": {}
|
||||||
|
},
|
||||||
|
"internal": {
|
||||||
|
"indexes": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -64,12 +64,8 @@
|
|||||||
"name": "api_keys_user_id_users_id_fk",
|
"name": "api_keys_user_id_users_id_fk",
|
||||||
"tableFrom": "api_keys",
|
"tableFrom": "api_keys",
|
||||||
"tableTo": "users",
|
"tableTo": "users",
|
||||||
"columnsFrom": [
|
"columnsFrom": ["user_id"],
|
||||||
"user_id"
|
"columnsTo": ["id"],
|
||||||
],
|
|
||||||
"columnsTo": [
|
|
||||||
"id"
|
|
||||||
],
|
|
||||||
"onDelete": "cascade",
|
"onDelete": "cascade",
|
||||||
"onUpdate": "no action"
|
"onUpdate": "no action"
|
||||||
}
|
}
|
||||||
@@ -212,12 +208,8 @@
|
|||||||
"name": "pipelines_user_id_users_id_fk",
|
"name": "pipelines_user_id_users_id_fk",
|
||||||
"tableFrom": "pipelines",
|
"tableFrom": "pipelines",
|
||||||
"tableTo": "users",
|
"tableTo": "users",
|
||||||
"columnsFrom": [
|
"columnsFrom": ["user_id"],
|
||||||
"user_id"
|
"columnsTo": ["id"],
|
||||||
],
|
|
||||||
"columnsTo": [
|
|
||||||
"id"
|
|
||||||
],
|
|
||||||
"onDelete": "cascade",
|
"onDelete": "cascade",
|
||||||
"onUpdate": "no action"
|
"onUpdate": "no action"
|
||||||
}
|
}
|
||||||
@@ -264,12 +256,8 @@
|
|||||||
"name": "sessions_user_id_users_id_fk",
|
"name": "sessions_user_id_users_id_fk",
|
||||||
"tableFrom": "sessions",
|
"tableFrom": "sessions",
|
||||||
"tableTo": "users",
|
"tableTo": "users",
|
||||||
"columnsFrom": [
|
"columnsFrom": ["user_id"],
|
||||||
"user_id"
|
"columnsTo": ["id"],
|
||||||
],
|
|
||||||
"columnsTo": [
|
|
||||||
"id"
|
|
||||||
],
|
|
||||||
"onDelete": "cascade",
|
"onDelete": "cascade",
|
||||||
"onUpdate": "no action"
|
"onUpdate": "no action"
|
||||||
}
|
}
|
||||||
@@ -404,12 +392,8 @@
|
|||||||
"name": "user_files_user_id_users_id_fk",
|
"name": "user_files_user_id_users_id_fk",
|
||||||
"tableFrom": "user_files",
|
"tableFrom": "user_files",
|
||||||
"tableTo": "users",
|
"tableTo": "users",
|
||||||
"columnsFrom": [
|
"columnsFrom": ["user_id"],
|
||||||
"user_id"
|
"columnsTo": ["id"],
|
||||||
],
|
|
||||||
"columnsTo": [
|
|
||||||
"id"
|
|
||||||
],
|
|
||||||
"onDelete": "cascade",
|
"onDelete": "cascade",
|
||||||
"onUpdate": "no action"
|
"onUpdate": "no action"
|
||||||
}
|
}
|
||||||
@@ -484,9 +468,7 @@
|
|||||||
"indexes": {
|
"indexes": {
|
||||||
"users_username_unique": {
|
"users_username_unique": {
|
||||||
"name": "users_username_unique",
|
"name": "users_username_unique",
|
||||||
"columns": [
|
"columns": ["username"],
|
||||||
"username"
|
|
||||||
],
|
|
||||||
"isUnique": true
|
"isUnique": true
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
|
import { mkdirSync } from "node:fs";
|
||||||
|
import { dirname } from "node:path";
|
||||||
import Database from "better-sqlite3";
|
import Database from "better-sqlite3";
|
||||||
import { drizzle } from "drizzle-orm/better-sqlite3";
|
import { drizzle } from "drizzle-orm/better-sqlite3";
|
||||||
import { env } from "../config.js";
|
import { env } from "../config.js";
|
||||||
import * as schema from "./schema.js";
|
import * as schema from "./schema.js";
|
||||||
import { mkdirSync } from "node:fs";
|
|
||||||
import { dirname } from "node:path";
|
|
||||||
|
|
||||||
// Ensure data directory exists
|
// Ensure data directory exists
|
||||||
mkdirSync(dirname(env.DB_PATH), { recursive: true });
|
mkdirSync(dirname(env.DB_PATH), { recursive: true });
|
||||||
@@ -17,4 +17,4 @@ sqlite.pragma("synchronous = NORMAL");
|
|||||||
sqlite.pragma("foreign_keys = ON");
|
sqlite.pragma("foreign_keys = ON");
|
||||||
|
|
||||||
export const db = drizzle(sqlite, { schema });
|
export const db = drizzle(sqlite, { schema });
|
||||||
export { schema };
|
export { schema, sqlite };
|
||||||
|
|||||||
+18
-12
@@ -1,22 +1,23 @@
|
|||||||
import Fastify from "fastify";
|
|
||||||
import cors from "@fastify/cors";
|
import cors from "@fastify/cors";
|
||||||
import rateLimit from "@fastify/rate-limit";
|
import rateLimit from "@fastify/rate-limit";
|
||||||
import { env } from "./config.js";
|
|
||||||
import { APP_VERSION } from "@stirling-image/shared";
|
import { APP_VERSION } from "@stirling-image/shared";
|
||||||
|
import Fastify from "fastify";
|
||||||
|
import { env } from "./config.js";
|
||||||
|
import { db, schema } from "./db/index.js";
|
||||||
import { runMigrations } from "./db/migrate.js";
|
import { runMigrations } from "./db/migrate.js";
|
||||||
import { ensureDefaultAdmin, authRoutes, authMiddleware } from "./plugins/auth.js";
|
|
||||||
import { registerUpload } from "./plugins/upload.js";
|
|
||||||
import { registerStatic } from "./plugins/static.js";
|
|
||||||
import { startCleanupCron } from "./lib/cleanup.js";
|
import { startCleanupCron } from "./lib/cleanup.js";
|
||||||
import { fileRoutes } from "./routes/files.js";
|
import { authMiddleware, authRoutes, ensureDefaultAdmin } from "./plugins/auth.js";
|
||||||
import { registerToolRoutes } from "./routes/tools/index.js";
|
import { registerStatic } from "./plugins/static.js";
|
||||||
|
import { registerUpload } from "./plugins/upload.js";
|
||||||
|
import { apiKeyRoutes } from "./routes/api-keys.js";
|
||||||
import { registerBatchRoutes } from "./routes/batch.js";
|
import { registerBatchRoutes } from "./routes/batch.js";
|
||||||
|
import { fileRoutes } from "./routes/files.js";
|
||||||
import { registerPipelineRoutes } from "./routes/pipeline.js";
|
import { registerPipelineRoutes } from "./routes/pipeline.js";
|
||||||
import { registerProgressRoutes } from "./routes/progress.js";
|
import { registerProgressRoutes } from "./routes/progress.js";
|
||||||
import { apiKeyRoutes } from "./routes/api-keys.js";
|
|
||||||
import { settingsRoutes } from "./routes/settings.js";
|
import { settingsRoutes } from "./routes/settings.js";
|
||||||
|
import { teamsRoutes } from "./routes/teams.js";
|
||||||
|
import { registerToolRoutes } from "./routes/tools/index.js";
|
||||||
import { userFileRoutes } from "./routes/user-files.js";
|
import { userFileRoutes } from "./routes/user-files.js";
|
||||||
import { db, schema } from "./db/index.js";
|
|
||||||
|
|
||||||
// Run before anything else
|
// Run before anything else
|
||||||
runMigrations();
|
runMigrations();
|
||||||
@@ -34,7 +35,7 @@ const app = Fastify({
|
|||||||
await app.register(cors, {
|
await app.register(cors, {
|
||||||
origin: env.CORS_ORIGIN
|
origin: env.CORS_ORIGIN
|
||||||
? env.CORS_ORIGIN.split(",").map((s) => s.trim())
|
? env.CORS_ORIGIN.split(",").map((s) => s.trim())
|
||||||
: process.env.NODE_ENV === "production" ? false : true,
|
: process.env.NODE_ENV !== "production",
|
||||||
});
|
});
|
||||||
|
|
||||||
// Security headers
|
// Security headers
|
||||||
@@ -91,17 +92,22 @@ await apiKeyRoutes(app);
|
|||||||
// Settings routes
|
// Settings routes
|
||||||
await settingsRoutes(app);
|
await settingsRoutes(app);
|
||||||
|
|
||||||
|
// Teams routes
|
||||||
|
await teamsRoutes(app);
|
||||||
|
|
||||||
// Health check
|
// Health check
|
||||||
app.get("/api/v1/health", async () => {
|
app.get("/api/v1/health", async () => {
|
||||||
let dbOk = false;
|
let dbOk = false;
|
||||||
try {
|
try {
|
||||||
db.select().from(schema.settings).limit(1).all();
|
db.select().from(schema.settings).limit(1).all();
|
||||||
dbOk = true;
|
dbOk = true;
|
||||||
} catch { /* db unreachable */ }
|
} catch {
|
||||||
|
/* db unreachable */
|
||||||
|
}
|
||||||
return {
|
return {
|
||||||
status: dbOk ? "healthy" : "degraded",
|
status: dbOk ? "healthy" : "degraded",
|
||||||
version: APP_VERSION,
|
version: APP_VERSION,
|
||||||
uptime: process.uptime().toFixed(0) + "s",
|
uptime: `${process.uptime().toFixed(0)}s`,
|
||||||
storage: { mode: env.STORAGE_MODE, available: "N/A" },
|
storage: { mode: env.STORAGE_MODE, available: "N/A" },
|
||||||
database: dbOk ? "ok" : "error",
|
database: dbOk ? "ok" : "error",
|
||||||
queue: { active: 0, pending: 0 },
|
queue: { active: 0, pending: 0 },
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { mkdir, writeFile, unlink } from "node:fs/promises";
|
|
||||||
import { join, extname } from "node:path";
|
|
||||||
import { randomUUID } from "node:crypto";
|
import { randomUUID } from "node:crypto";
|
||||||
|
import { mkdir, unlink, writeFile } from "node:fs/promises";
|
||||||
|
import { extname, join } from "node:path";
|
||||||
import { env } from "../config.js";
|
import { env } from "../config.js";
|
||||||
|
|
||||||
let storageReady = false;
|
let storageReady = false;
|
||||||
|
|||||||
@@ -1,15 +1,15 @@
|
|||||||
import { randomUUID } from "node:crypto";
|
import { randomUUID } from "node:crypto";
|
||||||
import { writeFile } from "node:fs/promises";
|
import { writeFile } from "node:fs/promises";
|
||||||
import { join } from "node:path";
|
import { join } from "node:path";
|
||||||
import type { FastifyInstance, FastifyRequest, FastifyReply } from "fastify";
|
|
||||||
import { z } from "zod";
|
|
||||||
import { eq } from "drizzle-orm";
|
import { eq } from "drizzle-orm";
|
||||||
|
import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
|
||||||
import sharp from "sharp";
|
import sharp from "sharp";
|
||||||
import { createWorkspace } from "../lib/workspace.js";
|
import type { z } from "zod";
|
||||||
|
import { db, schema } from "../db/index.js";
|
||||||
|
import { autoOrient } from "../lib/auto-orient.js";
|
||||||
import { validateImageBuffer } from "../lib/file-validation.js";
|
import { validateImageBuffer } from "../lib/file-validation.js";
|
||||||
import { sanitizeFilename } from "../lib/filename.js";
|
import { sanitizeFilename } from "../lib/filename.js";
|
||||||
import { autoOrient } from "../lib/auto-orient.js";
|
import { createWorkspace } from "../lib/workspace.js";
|
||||||
import { db, schema } from "../db/index.js";
|
|
||||||
|
|
||||||
export interface ToolRouteConfig<T> {
|
export interface ToolRouteConfig<T> {
|
||||||
/** Unique tool identifier, used as the URL path segment. */
|
/** Unique tool identifier, used as the URL path segment. */
|
||||||
@@ -54,10 +54,7 @@ export function getToolConfig(toolId: string): ToolRouteConfig<any> | undefined
|
|||||||
* - Error handling
|
* - Error handling
|
||||||
* - Response formatting
|
* - Response formatting
|
||||||
*/
|
*/
|
||||||
export function createToolRoute<T>(
|
export function createToolRoute<T>(app: FastifyInstance, config: ToolRouteConfig<T>): void {
|
||||||
app: FastifyInstance,
|
|
||||||
config: ToolRouteConfig<T>,
|
|
||||||
): void {
|
|
||||||
// Register in the tool registry for batch processing
|
// Register in the tool registry for batch processing
|
||||||
toolRegistry.set(config.toolId, config);
|
toolRegistry.set(config.toolId, config);
|
||||||
|
|
||||||
@@ -101,17 +98,13 @@ export function createToolRoute<T>(
|
|||||||
|
|
||||||
// Require a file
|
// Require a file
|
||||||
if (!fileBuffer || fileBuffer.length === 0) {
|
if (!fileBuffer || fileBuffer.length === 0) {
|
||||||
return reply
|
return reply.status(400).send({ error: "No image file provided" });
|
||||||
.status(400)
|
|
||||||
.send({ error: "No image file provided" });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate the uploaded image
|
// Validate the uploaded image
|
||||||
const validation = await validateImageBuffer(fileBuffer);
|
const validation = await validateImageBuffer(fileBuffer);
|
||||||
if (!validation.valid) {
|
if (!validation.valid) {
|
||||||
return reply
|
return reply.status(400).send({ error: `Invalid image: ${validation.reason}` });
|
||||||
.status(400)
|
|
||||||
.send({ error: `Invalid image: ${validation.reason}` });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Parse and validate settings
|
// Parse and validate settings
|
||||||
@@ -162,9 +155,7 @@ export function createToolRoute<T>(
|
|||||||
.get();
|
.get();
|
||||||
if (parent) {
|
if (parent) {
|
||||||
const newVersion = parent.version + 1;
|
const newVersion = parent.version + 1;
|
||||||
const parentChain: string[] = parent.toolChain
|
const parentChain: string[] = parent.toolChain ? JSON.parse(parent.toolChain) : [];
|
||||||
? JSON.parse(parent.toolChain)
|
|
||||||
: [];
|
|
||||||
const newToolChain = [...parentChain, config.toolId];
|
const newToolChain = [...parentChain, config.toolId];
|
||||||
const storedName = await saveFile(result.buffer, result.filename);
|
const storedName = await saveFile(result.buffer, result.filename);
|
||||||
// Get image dimensions from the processed output
|
// Get image dimensions from the processed output
|
||||||
|
|||||||
+172
-213
@@ -12,14 +12,14 @@
|
|||||||
import { randomUUID } from "node:crypto";
|
import { randomUUID } from "node:crypto";
|
||||||
import { createReadStream } from "node:fs";
|
import { createReadStream } from "node:fs";
|
||||||
import { extname } from "node:path";
|
import { extname } from "node:path";
|
||||||
import type { FastifyInstance, FastifyRequest, FastifyReply } from "fastify";
|
import { and, desc, eq, like, sql } from "drizzle-orm";
|
||||||
import { eq, desc, like, and, sql } from "drizzle-orm";
|
import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
|
||||||
import sharp from "sharp";
|
import sharp from "sharp";
|
||||||
|
import { db, schema, sqlite } from "../db/index.js";
|
||||||
|
import { deleteStoredFile, getStoredFilePath, saveFile } from "../lib/file-storage.js";
|
||||||
import { validateImageBuffer } from "../lib/file-validation.js";
|
import { validateImageBuffer } from "../lib/file-validation.js";
|
||||||
import { sanitizeFilename } from "../lib/filename.js";
|
import { sanitizeFilename } from "../lib/filename.js";
|
||||||
import { saveFile, deleteStoredFile, getStoredFilePath } from "../lib/file-storage.js";
|
|
||||||
import { getAuthUser } from "../plugins/auth.js";
|
import { getAuthUser } from "../plugins/auth.js";
|
||||||
import { db, schema } from "../db/index.js";
|
|
||||||
|
|
||||||
// ── Helpers ────────────────────────────────────────────────────────
|
// ── Helpers ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -62,7 +62,7 @@ function serializeFile(row: typeof schema.userFiles.$inferSelect) {
|
|||||||
height: row.height,
|
height: row.height,
|
||||||
version: row.version,
|
version: row.version,
|
||||||
parentId: row.parentId,
|
parentId: row.parentId,
|
||||||
toolChain: row.toolChain ? JSON.parse(row.toolChain) : null,
|
toolChain: row.toolChain ? JSON.parse(row.toolChain) : [],
|
||||||
createdAt: row.createdAt.toISOString(),
|
createdAt: row.createdAt.toISOString(),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -144,76 +144,69 @@ export async function userFileRoutes(app: FastifyInstance): Promise<void> {
|
|||||||
* Multipart form with one or more image file parts.
|
* Multipart form with one or more image file parts.
|
||||||
* Validates each (magic bytes + dimensions), stores to disk, creates DB record.
|
* Validates each (magic bytes + dimensions), stores to disk, creates DB record.
|
||||||
*/
|
*/
|
||||||
app.post(
|
app.post("/api/v1/files/upload", async (request: FastifyRequest, reply: FastifyReply) => {
|
||||||
"/api/v1/files/upload",
|
const user = getAuthUser(request);
|
||||||
async (request: FastifyRequest, reply: FastifyReply) => {
|
const userId = user?.id ?? null;
|
||||||
const user = getAuthUser(request);
|
|
||||||
const userId = user?.id ?? null;
|
|
||||||
|
|
||||||
const created: ReturnType<typeof serializeFile>[] = [];
|
const created: ReturnType<typeof serializeFile>[] = [];
|
||||||
|
|
||||||
const parts = request.parts();
|
const parts = request.parts();
|
||||||
|
|
||||||
for await (const part of parts) {
|
for await (const part of parts) {
|
||||||
if (part.type !== "file") continue;
|
if (part.type !== "file") continue;
|
||||||
|
|
||||||
// Consume the stream into a buffer
|
// Consume the stream into a buffer
|
||||||
const chunks: Buffer[] = [];
|
const chunks: Buffer[] = [];
|
||||||
for await (const chunk of part.file) {
|
for await (const chunk of part.file) {
|
||||||
chunks.push(chunk);
|
chunks.push(chunk);
|
||||||
}
|
}
|
||||||
const buffer = Buffer.concat(chunks);
|
const buffer = Buffer.concat(chunks);
|
||||||
|
|
||||||
if (buffer.length === 0) continue;
|
if (buffer.length === 0) continue;
|
||||||
|
|
||||||
// Validate image
|
// Validate image
|
||||||
const validation = await validateImageBuffer(buffer);
|
const validation = await validateImageBuffer(buffer);
|
||||||
if (!validation.valid) {
|
if (!validation.valid) {
|
||||||
return reply.status(400).send({
|
return reply.status(400).send({
|
||||||
error: `Invalid file "${part.filename}": ${validation.reason}`,
|
error: `Invalid file "${part.filename}": ${validation.reason}`,
|
||||||
});
|
});
|
||||||
}
|
|
||||||
|
|
||||||
const safeName = sanitizeFilename(part.filename ?? "upload");
|
|
||||||
const mimeType = formatToMime(validation.format);
|
|
||||||
|
|
||||||
// Persist to disk
|
|
||||||
const storedName = await saveFile(buffer, safeName);
|
|
||||||
|
|
||||||
// Create DB record
|
|
||||||
const id = randomUUID();
|
|
||||||
db.insert(schema.userFiles)
|
|
||||||
.values({
|
|
||||||
id,
|
|
||||||
userId,
|
|
||||||
originalName: safeName,
|
|
||||||
storedName,
|
|
||||||
mimeType,
|
|
||||||
size: buffer.length,
|
|
||||||
width: validation.width,
|
|
||||||
height: validation.height,
|
|
||||||
version: 1,
|
|
||||||
parentId: null,
|
|
||||||
toolChain: null,
|
|
||||||
})
|
|
||||||
.run();
|
|
||||||
|
|
||||||
const row = db
|
|
||||||
.select()
|
|
||||||
.from(schema.userFiles)
|
|
||||||
.where(eq(schema.userFiles.id, id))
|
|
||||||
.get();
|
|
||||||
|
|
||||||
if (row) created.push(serializeFile(row));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (created.length === 0) {
|
const safeName = sanitizeFilename(part.filename ?? "upload");
|
||||||
return reply.status(400).send({ error: "No valid files uploaded" });
|
const mimeType = formatToMime(validation.format);
|
||||||
}
|
|
||||||
|
|
||||||
return reply.status(201).send({ files: created });
|
// Persist to disk
|
||||||
},
|
const storedName = await saveFile(buffer, safeName);
|
||||||
);
|
|
||||||
|
// Create DB record
|
||||||
|
const id = randomUUID();
|
||||||
|
db.insert(schema.userFiles)
|
||||||
|
.values({
|
||||||
|
id,
|
||||||
|
userId,
|
||||||
|
originalName: safeName,
|
||||||
|
storedName,
|
||||||
|
mimeType,
|
||||||
|
size: buffer.length,
|
||||||
|
width: validation.width,
|
||||||
|
height: validation.height,
|
||||||
|
version: 1,
|
||||||
|
parentId: null,
|
||||||
|
toolChain: null,
|
||||||
|
})
|
||||||
|
.run();
|
||||||
|
|
||||||
|
const row = db.select().from(schema.userFiles).where(eq(schema.userFiles.id, id)).get();
|
||||||
|
|
||||||
|
if (row) created.push(serializeFile(row));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (created.length === 0) {
|
||||||
|
return reply.status(400).send({ error: "No valid files uploaded" });
|
||||||
|
}
|
||||||
|
|
||||||
|
return reply.status(201).send({ files: created });
|
||||||
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* GET /api/v1/files/:id
|
* GET /api/v1/files/:id
|
||||||
@@ -223,17 +216,10 @@ export async function userFileRoutes(app: FastifyInstance): Promise<void> {
|
|||||||
*/
|
*/
|
||||||
app.get(
|
app.get(
|
||||||
"/api/v1/files/:id",
|
"/api/v1/files/:id",
|
||||||
async (
|
async (request: FastifyRequest<{ Params: { id: string } }>, reply: FastifyReply) => {
|
||||||
request: FastifyRequest<{ Params: { id: string } }>,
|
|
||||||
reply: FastifyReply,
|
|
||||||
) => {
|
|
||||||
const { id } = request.params;
|
const { id } = request.params;
|
||||||
|
|
||||||
const file = db
|
const file = db.select().from(schema.userFiles).where(eq(schema.userFiles.id, id)).get();
|
||||||
.select()
|
|
||||||
.from(schema.userFiles)
|
|
||||||
.where(eq(schema.userFiles.id, id))
|
|
||||||
.get();
|
|
||||||
|
|
||||||
if (!file) {
|
if (!file) {
|
||||||
return reply.status(404).send({ error: "File not found" });
|
return reply.status(404).send({ error: "File not found" });
|
||||||
@@ -254,11 +240,11 @@ export async function userFileRoutes(app: FastifyInstance): Promise<void> {
|
|||||||
created_at: number;
|
created_at: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
const chainRows = db.all<ChainRow>(
|
const chainRows = sqlite
|
||||||
sql`
|
.prepare(`
|
||||||
WITH RECURSIVE
|
WITH RECURSIVE
|
||||||
ancestors(id, parent_id) AS (
|
ancestors(id, parent_id) AS (
|
||||||
SELECT id, parent_id FROM user_files WHERE id = ${id}
|
SELECT id, parent_id FROM user_files WHERE id = ?
|
||||||
UNION ALL
|
UNION ALL
|
||||||
SELECT uf.id, uf.parent_id FROM user_files uf
|
SELECT uf.id, uf.parent_id FROM user_files uf
|
||||||
INNER JOIN ancestors a ON uf.id = a.parent_id
|
INNER JOIN ancestors a ON uf.id = a.parent_id
|
||||||
@@ -277,8 +263,8 @@ export async function userFileRoutes(app: FastifyInstance): Promise<void> {
|
|||||||
INNER JOIN chain c ON child.parent_id = c.id
|
INNER JOIN chain c ON child.parent_id = c.id
|
||||||
)
|
)
|
||||||
SELECT * FROM chain ORDER BY version ASC
|
SELECT * FROM chain ORDER BY version ASC
|
||||||
`,
|
`)
|
||||||
);
|
.all(id) as ChainRow[];
|
||||||
|
|
||||||
const versions = chainRows.map((r) => ({
|
const versions = chainRows.map((r) => ({
|
||||||
id: r.id,
|
id: r.id,
|
||||||
@@ -289,7 +275,7 @@ export async function userFileRoutes(app: FastifyInstance): Promise<void> {
|
|||||||
height: r.height,
|
height: r.height,
|
||||||
version: r.version,
|
version: r.version,
|
||||||
parentId: r.parent_id,
|
parentId: r.parent_id,
|
||||||
toolChain: r.tool_chain ? JSON.parse(r.tool_chain) : null,
|
toolChain: r.tool_chain ? JSON.parse(r.tool_chain) : [],
|
||||||
createdAt: new Date(r.created_at * 1000).toISOString(),
|
createdAt: new Date(r.created_at * 1000).toISOString(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
@@ -307,17 +293,10 @@ export async function userFileRoutes(app: FastifyInstance): Promise<void> {
|
|||||||
*/
|
*/
|
||||||
app.get(
|
app.get(
|
||||||
"/api/v1/files/:id/download",
|
"/api/v1/files/:id/download",
|
||||||
async (
|
async (request: FastifyRequest<{ Params: { id: string } }>, reply: FastifyReply) => {
|
||||||
request: FastifyRequest<{ Params: { id: string } }>,
|
|
||||||
reply: FastifyReply,
|
|
||||||
) => {
|
|
||||||
const { id } = request.params;
|
const { id } = request.params;
|
||||||
|
|
||||||
const file = db
|
const file = db.select().from(schema.userFiles).where(eq(schema.userFiles.id, id)).get();
|
||||||
.select()
|
|
||||||
.from(schema.userFiles)
|
|
||||||
.where(eq(schema.userFiles.id, id))
|
|
||||||
.get();
|
|
||||||
|
|
||||||
if (!file) {
|
if (!file) {
|
||||||
return reply.status(404).send({ error: "File not found" });
|
return reply.status(404).send({ error: "File not found" });
|
||||||
@@ -347,17 +326,10 @@ export async function userFileRoutes(app: FastifyInstance): Promise<void> {
|
|||||||
*/
|
*/
|
||||||
app.get(
|
app.get(
|
||||||
"/api/v1/files/:id/thumbnail",
|
"/api/v1/files/:id/thumbnail",
|
||||||
async (
|
async (request: FastifyRequest<{ Params: { id: string } }>, reply: FastifyReply) => {
|
||||||
request: FastifyRequest<{ Params: { id: string } }>,
|
|
||||||
reply: FastifyReply,
|
|
||||||
) => {
|
|
||||||
const { id } = request.params;
|
const { id } = request.params;
|
||||||
|
|
||||||
const file = db
|
const file = db.select().from(schema.userFiles).where(eq(schema.userFiles.id, id)).get();
|
||||||
.select()
|
|
||||||
.from(schema.userFiles)
|
|
||||||
.where(eq(schema.userFiles.id, id))
|
|
||||||
.get();
|
|
||||||
|
|
||||||
if (!file) {
|
if (!file) {
|
||||||
return reply.status(404).send({ error: "File not found" });
|
return reply.status(404).send({ error: "File not found" });
|
||||||
@@ -387,35 +359,35 @@ export async function userFileRoutes(app: FastifyInstance): Promise<void> {
|
|||||||
* Bulk delete. Body: { ids: string[] }
|
* Bulk delete. Body: { ids: string[] }
|
||||||
* For each id, deletes the entire version chain (all ancestors and descendants).
|
* For each id, deletes the entire version chain (all ancestors and descendants).
|
||||||
*/
|
*/
|
||||||
app.delete(
|
app.delete("/api/v1/files", async (request: FastifyRequest, reply: FastifyReply) => {
|
||||||
"/api/v1/files",
|
const body = request.body as { ids?: unknown } | null;
|
||||||
async (request: FastifyRequest, reply: FastifyReply) => {
|
|
||||||
const body = request.body as { ids?: unknown } | null;
|
|
||||||
|
|
||||||
if (!Array.isArray(body?.ids) || body.ids.length === 0) {
|
if (!Array.isArray(body?.ids) || body.ids.length === 0) {
|
||||||
return reply.status(400).send({ error: "ids must be a non-empty array" });
|
return reply.status(400).send({ error: "ids must be a non-empty array" });
|
||||||
}
|
}
|
||||||
|
|
||||||
const ids = body.ids.filter((id): id is string => typeof id === "string");
|
const ids = body.ids.filter((id): id is string => typeof id === "string");
|
||||||
if (ids.length === 0) {
|
if (ids.length === 0) {
|
||||||
return reply.status(400).send({ error: "ids must contain string values" });
|
return reply.status(400).send({ error: "ids must contain string values" });
|
||||||
}
|
}
|
||||||
|
|
||||||
let deletedCount = 0;
|
let deletedCount = 0;
|
||||||
|
|
||||||
interface DeleteChainRow { id: string; stored_name: string }
|
interface DeleteChainRow {
|
||||||
|
id: string;
|
||||||
|
stored_name: string;
|
||||||
|
}
|
||||||
|
|
||||||
for (const id of ids) {
|
for (const id of ids) {
|
||||||
// Collect all files in the chain using a recursive CTE
|
// Collect all files in the chain using a recursive CTE
|
||||||
const chainRows = db.all<DeleteChainRow>(
|
const chainRows = sqlite
|
||||||
sql`
|
.prepare(`
|
||||||
WITH RECURSIVE chain(id, stored_name) AS (
|
WITH RECURSIVE chain(id, stored_name) AS (
|
||||||
-- Anchor: find the root of this chain
|
|
||||||
SELECT f.id, f.stored_name
|
SELECT f.id, f.stored_name
|
||||||
FROM user_files f
|
FROM user_files f
|
||||||
WHERE f.id = (
|
WHERE f.id = (
|
||||||
WITH RECURSIVE ancestors(id, parent_id) AS (
|
WITH RECURSIVE ancestors(id, parent_id) AS (
|
||||||
SELECT id, parent_id FROM user_files WHERE id = ${id}
|
SELECT id, parent_id FROM user_files WHERE id = ?
|
||||||
UNION ALL
|
UNION ALL
|
||||||
SELECT uf.id, uf.parent_id FROM user_files uf
|
SELECT uf.id, uf.parent_id FROM user_files uf
|
||||||
INNER JOIN ancestors a ON uf.id = a.parent_id
|
INNER JOIN ancestors a ON uf.id = a.parent_id
|
||||||
@@ -423,27 +395,23 @@ export async function userFileRoutes(app: FastifyInstance): Promise<void> {
|
|||||||
SELECT id FROM ancestors WHERE parent_id IS NULL LIMIT 1
|
SELECT id FROM ancestors WHERE parent_id IS NULL LIMIT 1
|
||||||
)
|
)
|
||||||
UNION ALL
|
UNION ALL
|
||||||
-- Recurse: all descendants
|
|
||||||
SELECT child.id, child.stored_name
|
SELECT child.id, child.stored_name
|
||||||
FROM user_files child
|
FROM user_files child
|
||||||
INNER JOIN chain c ON child.parent_id = c.id
|
INNER JOIN chain c ON child.parent_id = c.id
|
||||||
)
|
)
|
||||||
SELECT id, stored_name FROM chain
|
SELECT id, stored_name FROM chain
|
||||||
`,
|
`)
|
||||||
);
|
.all(id) as DeleteChainRow[];
|
||||||
|
|
||||||
for (const row of chainRows) {
|
for (const row of chainRows) {
|
||||||
await deleteStoredFile(row.stored_name);
|
await deleteStoredFile(row.stored_name);
|
||||||
db.delete(schema.userFiles)
|
db.delete(schema.userFiles).where(eq(schema.userFiles.id, row.id)).run();
|
||||||
.where(eq(schema.userFiles.id, row.id))
|
deletedCount++;
|
||||||
.run();
|
|
||||||
deletedCount++;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return reply.send({ deleted: deletedCount });
|
return reply.send({ deleted: deletedCount });
|
||||||
},
|
});
|
||||||
);
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* POST /api/v1/files/save-result
|
* POST /api/v1/files/save-result
|
||||||
@@ -454,105 +422,96 @@ export async function userFileRoutes(app: FastifyInstance): Promise<void> {
|
|||||||
* parentId — id of the parent user file record
|
* parentId — id of the parent user file record
|
||||||
* toolId — the tool that produced this result
|
* toolId — the tool that produced this result
|
||||||
*/
|
*/
|
||||||
app.post(
|
app.post("/api/v1/files/save-result", async (request: FastifyRequest, reply: FastifyReply) => {
|
||||||
"/api/v1/files/save-result",
|
const user = getAuthUser(request);
|
||||||
async (request: FastifyRequest, reply: FastifyReply) => {
|
const userId = user?.id ?? null;
|
||||||
const user = getAuthUser(request);
|
|
||||||
const userId = user?.id ?? null;
|
|
||||||
|
|
||||||
let fileBuffer: Buffer | null = null;
|
let fileBuffer: Buffer | null = null;
|
||||||
let filename = "result";
|
let filename = "result";
|
||||||
let parentId: string | null = null;
|
let parentId: string | null = null;
|
||||||
let toolId: string | null = null;
|
let toolId: string | null = null;
|
||||||
|
|
||||||
const parts = request.parts();
|
const parts = request.parts();
|
||||||
for await (const part of parts) {
|
for await (const part of parts) {
|
||||||
if (part.type === "file") {
|
if (part.type === "file") {
|
||||||
const chunks: Buffer[] = [];
|
const chunks: Buffer[] = [];
|
||||||
for await (const chunk of part.file) {
|
for await (const chunk of part.file) {
|
||||||
chunks.push(chunk);
|
chunks.push(chunk);
|
||||||
}
|
|
||||||
fileBuffer = Buffer.concat(chunks);
|
|
||||||
filename = sanitizeFilename(part.filename ?? "result");
|
|
||||||
} else if (part.fieldname === "parentId") {
|
|
||||||
parentId = (part.value as string).trim() || null;
|
|
||||||
} else if (part.fieldname === "toolId") {
|
|
||||||
toolId = (part.value as string).trim() || null;
|
|
||||||
}
|
}
|
||||||
|
fileBuffer = Buffer.concat(chunks);
|
||||||
|
filename = sanitizeFilename(part.filename ?? "result");
|
||||||
|
} else if (part.fieldname === "parentId") {
|
||||||
|
parentId = (part.value as string).trim() || null;
|
||||||
|
} else if (part.fieldname === "toolId") {
|
||||||
|
toolId = (part.value as string).trim() || null;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (!fileBuffer || fileBuffer.length === 0) {
|
if (!fileBuffer || fileBuffer.length === 0) {
|
||||||
return reply.status(400).send({ error: "No file provided" });
|
return reply.status(400).send({ error: "No file provided" });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!parentId) {
|
if (!parentId) {
|
||||||
return reply.status(400).send({ error: "parentId is required" });
|
return reply.status(400).send({ error: "parentId is required" });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate the image
|
// Validate the image
|
||||||
const validation = await validateImageBuffer(fileBuffer);
|
const validation = await validateImageBuffer(fileBuffer);
|
||||||
if (!validation.valid) {
|
if (!validation.valid) {
|
||||||
return reply.status(400).send({
|
return reply.status(400).send({
|
||||||
error: `Invalid file: ${validation.reason}`,
|
error: `Invalid file: ${validation.reason}`,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Look up the parent to compute the next version and carry forward the tool chain
|
// Look up the parent to compute the next version and carry forward the tool chain
|
||||||
const parent = db
|
const parent = db
|
||||||
.select()
|
.select()
|
||||||
.from(schema.userFiles)
|
.from(schema.userFiles)
|
||||||
.where(eq(schema.userFiles.id, parentId))
|
.where(eq(schema.userFiles.id, parentId))
|
||||||
.get();
|
.get();
|
||||||
|
|
||||||
if (!parent) {
|
if (!parent) {
|
||||||
return reply.status(404).send({ error: "Parent file not found" });
|
return reply.status(404).send({ error: "Parent file not found" });
|
||||||
}
|
}
|
||||||
|
|
||||||
const nextVersion = parent.version + 1;
|
const nextVersion = parent.version + 1;
|
||||||
|
|
||||||
// Build the tool chain: append the new toolId to the parent's chain
|
// Build the tool chain: append the new toolId to the parent's chain
|
||||||
const existingChain: string[] = parent.toolChain
|
const existingChain: string[] = parent.toolChain ? JSON.parse(parent.toolChain) : [];
|
||||||
? JSON.parse(parent.toolChain)
|
const newChain = toolId ? [...existingChain, toolId] : existingChain;
|
||||||
: [];
|
|
||||||
const newChain = toolId ? [...existingChain, toolId] : existingChain;
|
|
||||||
|
|
||||||
// Determine the original filename (preserve parent's name, update extension)
|
// Determine the original filename (preserve parent's name, update extension)
|
||||||
const ext = extname(filename) || extname(parent.originalName);
|
const ext = extname(filename) || extname(parent.originalName);
|
||||||
const baseName = parent.originalName.replace(/\.[^.]+$/, "");
|
const baseName = parent.originalName.replace(/\.[^.]+$/, "");
|
||||||
const resultName = `${baseName}${ext}`;
|
const resultName = `${baseName}${ext}`;
|
||||||
|
|
||||||
const mimeType = formatToMime(validation.format) || extToMime(ext);
|
const mimeType = formatToMime(validation.format) || extToMime(ext);
|
||||||
|
|
||||||
// Persist to disk
|
// Persist to disk
|
||||||
const storedName = await saveFile(fileBuffer, resultName);
|
const storedName = await saveFile(fileBuffer, resultName);
|
||||||
|
|
||||||
// Create DB record
|
// Create DB record
|
||||||
const id = randomUUID();
|
const id = randomUUID();
|
||||||
db.insert(schema.userFiles)
|
db.insert(schema.userFiles)
|
||||||
.values({
|
.values({
|
||||||
id,
|
id,
|
||||||
userId,
|
userId,
|
||||||
originalName: resultName,
|
originalName: resultName,
|
||||||
storedName,
|
storedName,
|
||||||
mimeType,
|
mimeType,
|
||||||
size: fileBuffer.length,
|
size: fileBuffer.length,
|
||||||
width: validation.width,
|
width: validation.width,
|
||||||
height: validation.height,
|
height: validation.height,
|
||||||
version: nextVersion,
|
version: nextVersion,
|
||||||
parentId,
|
parentId,
|
||||||
toolChain: JSON.stringify(newChain),
|
toolChain: JSON.stringify(newChain),
|
||||||
})
|
})
|
||||||
.run();
|
.run();
|
||||||
|
|
||||||
const row = db
|
const row = db.select().from(schema.userFiles).where(eq(schema.userFiles.id, id)).get();
|
||||||
.select()
|
|
||||||
.from(schema.userFiles)
|
|
||||||
.where(eq(schema.userFiles.id, id))
|
|
||||||
.get();
|
|
||||||
|
|
||||||
return reply.status(201).send({ file: row ? serializeFile(row) : null });
|
return reply.status(201).send({ file: row ? serializeFile(row) : null });
|
||||||
},
|
});
|
||||||
);
|
|
||||||
|
|
||||||
app.log.info("User file routes registered");
|
app.log.info("User file routes registered");
|
||||||
}
|
}
|
||||||
|
|||||||
+12
-12
@@ -1,13 +1,13 @@
|
|||||||
import { Component, type ErrorInfo, type ReactNode } from "react";
|
import { Component, type ErrorInfo, type ReactNode } from "react";
|
||||||
import { BrowserRouter, Routes, Route, Navigate, useLocation } from "react-router-dom";
|
import { BrowserRouter, Navigate, Route, Routes, useLocation } from "react-router-dom";
|
||||||
import { HomePage } from "./pages/home-page";
|
import { KeyboardShortcutProvider } from "./components/common/keyboard-shortcut-provider";
|
||||||
import { LoginPage } from "./pages/login-page";
|
import { useAuth } from "./hooks/use-auth";
|
||||||
import { ToolPage } from "./pages/tool-page";
|
|
||||||
import { AutomatePage } from "./pages/automate-page";
|
import { AutomatePage } from "./pages/automate-page";
|
||||||
import { FilesPage } from "./pages/files-page";
|
import { FilesPage } from "./pages/files-page";
|
||||||
import { FullscreenGridPage } from "./pages/fullscreen-grid-page";
|
import { FullscreenGridPage } from "./pages/fullscreen-grid-page";
|
||||||
import { KeyboardShortcutProvider } from "./components/common/keyboard-shortcut-provider";
|
import { HomePage } from "./pages/home-page";
|
||||||
import { useAuth } from "./hooks/use-auth";
|
import { LoginPage } from "./pages/login-page";
|
||||||
|
import { ToolPage } from "./pages/tool-page";
|
||||||
|
|
||||||
class ErrorBoundary extends Component<
|
class ErrorBoundary extends Component<
|
||||||
{ children: ReactNode },
|
{ children: ReactNode },
|
||||||
@@ -86,12 +86,12 @@ export function App() {
|
|||||||
<KeyboardShortcutProvider>
|
<KeyboardShortcutProvider>
|
||||||
<AuthGuard>
|
<AuthGuard>
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/login" element={<LoginPage />} />
|
<Route path="/login" element={<LoginPage />} />
|
||||||
<Route path="/automate" element={<AutomatePage />} />
|
<Route path="/automate" element={<AutomatePage />} />
|
||||||
<Route path="/files" element={<FilesPage />} />
|
<Route path="/files" element={<FilesPage />} />
|
||||||
<Route path="/fullscreen" element={<FullscreenGridPage />} />
|
<Route path="/fullscreen" element={<FullscreenGridPage />} />
|
||||||
<Route path="/:toolId" element={<ToolPage />} />
|
<Route path="/:toolId" element={<ToolPage />} />
|
||||||
<Route path="/" element={<HomePage />} />
|
<Route path="/" element={<HomePage />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
</AuthGuard>
|
</AuthGuard>
|
||||||
</KeyboardShortcutProvider>
|
</KeyboardShortcutProvider>
|
||||||
|
|||||||
@@ -1,16 +1,16 @@
|
|||||||
|
import { TOOLS } from "@stirling-image/shared";
|
||||||
|
import { FileImage } from "lucide-react";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
import { FileImage } from "lucide-react";
|
|
||||||
import { TOOLS } from "@stirling-image/shared";
|
|
||||||
import {
|
import {
|
||||||
apiGetFileDetails,
|
apiGetFileDetails,
|
||||||
getFileThumbnailUrl,
|
|
||||||
getFileDownloadUrl,
|
getFileDownloadUrl,
|
||||||
|
getFileThumbnailUrl,
|
||||||
type UserFileDetail,
|
type UserFileDetail,
|
||||||
} from "@/lib/api";
|
} from "@/lib/api";
|
||||||
import { useFilesPageStore } from "@/stores/files-page-store";
|
|
||||||
import { useFileStore } from "@/stores/file-store";
|
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
import { useFileStore } from "@/stores/file-store";
|
||||||
|
import { useFilesPageStore } from "@/stores/files-page-store";
|
||||||
|
|
||||||
function toolName(toolId: string): string {
|
function toolName(toolId: string): string {
|
||||||
return TOOLS.find((t) => t.id === toolId)?.name ?? toolId;
|
return TOOLS.find((t) => t.id === toolId)?.name ?? toolId;
|
||||||
@@ -124,23 +124,20 @@ export function FileDetails({ mobile = false }: FileDetailsProps) {
|
|||||||
</div>
|
</div>
|
||||||
<div className="divide-y divide-border">
|
<div className="divide-y divide-border">
|
||||||
<DetailRow label="Name" value={details.originalName} />
|
<DetailRow label="Name" value={details.originalName} />
|
||||||
<DetailRow label="Format" value={details.mimeType.replace("image/", "").toUpperCase()} />
|
<DetailRow
|
||||||
|
label="Format"
|
||||||
|
value={details.mimeType.replace("image/", "").toUpperCase()}
|
||||||
|
/>
|
||||||
<DetailRow label="Size" value={formatSize(details.size)} />
|
<DetailRow label="Size" value={formatSize(details.size)} />
|
||||||
<DetailRow
|
<DetailRow
|
||||||
label="Dimensions"
|
label="Dimensions"
|
||||||
value={
|
value={details.width && details.height ? `${details.width} × ${details.height}` : "—"}
|
||||||
details.width && details.height
|
|
||||||
? `${details.width} × ${details.height}`
|
|
||||||
: "—"
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
<DetailRow label="Version" value={`V${details.version}`} />
|
<DetailRow label="Version" value={`V${details.version}`} />
|
||||||
<DetailRow
|
<DetailRow
|
||||||
label="Tools Used"
|
label="Tools Used"
|
||||||
value={
|
value={
|
||||||
details.toolChain.length > 0
|
details.toolChain.length > 0 ? details.toolChain.map(toolName).join(", ") : "None"
|
||||||
? details.toolChain.map(toolName).join(", ")
|
|
||||||
: "None"
|
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -150,6 +147,7 @@ export function FileDetails({ mobile = false }: FileDetailsProps) {
|
|||||||
{/* Open File button */}
|
{/* Open File button */}
|
||||||
<div className={cn("border-t border-border", mobile ? "pt-3" : "pt-4")}>
|
<div className={cn("border-t border-border", mobile ? "pt-3" : "pt-4")}>
|
||||||
<button
|
<button
|
||||||
|
type="button"
|
||||||
onClick={handleOpenFile}
|
onClick={handleOpenFile}
|
||||||
className="w-full px-4 py-2 bg-primary text-primary-foreground text-sm font-medium rounded-lg hover:bg-primary/90 transition-colors"
|
className="w-full px-4 py-2 bg-primary text-primary-foreground text-sm font-medium rounded-lg hover:bg-primary/90 transition-colors"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { TOOLS } from "@stirling-image/shared";
|
import { TOOLS } from "@stirling-image/shared";
|
||||||
import { cn } from "@/lib/utils";
|
|
||||||
import type { UserFile } from "@/lib/api";
|
import type { UserFile } from "@/lib/api";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
import { useFilesPageStore } from "@/stores/files-page-store";
|
import { useFilesPageStore } from "@/stores/files-page-store";
|
||||||
|
|
||||||
function formatSize(bytes: number): string {
|
function formatSize(bytes: number): string {
|
||||||
@@ -32,7 +32,13 @@ export function FileListItem({ file }: FileListItemProps) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
|
role="option"
|
||||||
|
aria-selected={isSelected}
|
||||||
|
tabIndex={0}
|
||||||
onClick={() => selectFile(file.id)}
|
onClick={() => selectFile(file.id)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter" || e.key === " ") selectFile(file.id);
|
||||||
|
}}
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex items-center gap-3 px-3 py-2 rounded-lg cursor-pointer transition-colors",
|
"flex items-center gap-3 px-3 py-2 rounded-lg cursor-pointer transition-colors",
|
||||||
isSelected
|
isSelected
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
|
import { Download, Search, Trash2 } from "lucide-react";
|
||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import { Search, Trash2, Download } from "lucide-react";
|
|
||||||
import { useFilesPageStore } from "@/stores/files-page-store";
|
|
||||||
import { getFileDownloadUrl } from "@/lib/api";
|
import { getFileDownloadUrl } from "@/lib/api";
|
||||||
|
import { useFilesPageStore } from "@/stores/files-page-store";
|
||||||
import { FileListItem } from "./file-list-item";
|
import { FileListItem } from "./file-list-item";
|
||||||
|
|
||||||
export function FileList() {
|
export function FileList() {
|
||||||
@@ -77,6 +77,7 @@ export function FileList() {
|
|||||||
{someChecked && (
|
{someChecked && (
|
||||||
<>
|
<>
|
||||||
<button
|
<button
|
||||||
|
type="button"
|
||||||
onClick={deleteChecked}
|
onClick={deleteChecked}
|
||||||
className="flex items-center gap-1 px-2 py-1 text-xs text-destructive hover:bg-destructive/10 rounded-lg transition-colors"
|
className="flex items-center gap-1 px-2 py-1 text-xs text-destructive hover:bg-destructive/10 rounded-lg transition-colors"
|
||||||
>
|
>
|
||||||
@@ -84,6 +85,7 @@ export function FileList() {
|
|||||||
Delete
|
Delete
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
|
type="button"
|
||||||
onClick={handleBulkDownload}
|
onClick={handleBulkDownload}
|
||||||
className="flex items-center gap-1 px-2 py-1 text-xs text-foreground hover:bg-muted rounded-lg transition-colors"
|
className="flex items-center gap-1 px-2 py-1 text-xs text-foreground hover:bg-muted rounded-lg transition-colors"
|
||||||
>
|
>
|
||||||
@@ -111,9 +113,7 @@ export function FileList() {
|
|||||||
<p className="text-sm text-muted-foreground">No files found</p>
|
<p className="text-sm text-muted-foreground">No files found</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{!loading && !error && files.map((file) => (
|
{!loading && !error && files.map((file) => <FileListItem key={file.id} file={file} />)}
|
||||||
<FileListItem key={file.id} file={file} />
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,12 +1,11 @@
|
|||||||
import { useRef, useState } from "react";
|
|
||||||
import { Upload } from "lucide-react";
|
import { Upload } from "lucide-react";
|
||||||
|
import { useState } from "react";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { useFilesPageStore } from "@/stores/files-page-store";
|
import { useFilesPageStore } from "@/stores/files-page-store";
|
||||||
|
|
||||||
export function FileUploadArea() {
|
export function FileUploadArea() {
|
||||||
const { uploadFiles, loading } = useFilesPageStore();
|
const { uploadFiles, loading } = useFilesPageStore();
|
||||||
const [dragging, setDragging] = useState(false);
|
const [dragging, setDragging] = useState(false);
|
||||||
const inputRef = useRef<HTMLInputElement>(null);
|
|
||||||
|
|
||||||
function handleDragOver(e: React.DragEvent) {
|
function handleDragOver(e: React.DragEvent) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@@ -20,9 +19,7 @@ export function FileUploadArea() {
|
|||||||
function handleDrop(e: React.DragEvent) {
|
function handleDrop(e: React.DragEvent) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setDragging(false);
|
setDragging(false);
|
||||||
const files = Array.from(e.dataTransfer.files).filter((f) =>
|
const files = Array.from(e.dataTransfer.files).filter((f) => f.type.startsWith("image/"));
|
||||||
f.type.startsWith("image/"),
|
|
||||||
);
|
|
||||||
if (files.length > 0) uploadFiles(files);
|
if (files.length > 0) uploadFiles(files);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -35,11 +32,10 @@ export function FileUploadArea() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex-1 flex items-center justify-center p-8">
|
<div className="flex-1 flex items-center justify-center p-8">
|
||||||
<div
|
<label
|
||||||
onDragOver={handleDragOver}
|
onDragOver={handleDragOver}
|
||||||
onDragLeave={handleDragLeave}
|
onDragLeave={handleDragLeave}
|
||||||
onDrop={handleDrop}
|
onDrop={handleDrop}
|
||||||
onClick={() => !loading && inputRef.current?.click()}
|
|
||||||
className={cn(
|
className={cn(
|
||||||
"w-full max-w-lg flex flex-col items-center justify-center gap-4 p-12 rounded-xl border-2 border-dashed transition-colors cursor-pointer",
|
"w-full max-w-lg flex flex-col items-center justify-center gap-4 p-12 rounded-xl border-2 border-dashed transition-colors cursor-pointer",
|
||||||
dragging
|
dragging
|
||||||
@@ -57,19 +53,16 @@ export function FileUploadArea() {
|
|||||||
<p className="text-sm font-medium text-foreground">
|
<p className="text-sm font-medium text-foreground">
|
||||||
{loading ? "Uploading..." : "Drop images here"}
|
{loading ? "Uploading..." : "Drop images here"}
|
||||||
</p>
|
</p>
|
||||||
<p className="text-xs text-muted-foreground mt-1">
|
<p className="text-xs text-muted-foreground mt-1">or click to select files</p>
|
||||||
or click to select files
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
<input
|
<input
|
||||||
ref={inputRef}
|
|
||||||
type="file"
|
type="file"
|
||||||
accept="image/*"
|
accept="image/*"
|
||||||
multiple
|
multiple
|
||||||
className="hidden"
|
className="hidden"
|
||||||
onChange={handleInputChange}
|
onChange={handleInputChange}
|
||||||
/>
|
/>
|
||||||
</div>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Clock, Upload, Cloud } from "lucide-react";
|
import { Clock, Cloud, Upload } from "lucide-react";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { useFilesPageStore } from "@/stores/files-page-store";
|
import { useFilesPageStore } from "@/stores/files-page-store";
|
||||||
|
|
||||||
@@ -16,6 +16,7 @@ export function FilesNav() {
|
|||||||
{items.map((item) => (
|
{items.map((item) => (
|
||||||
<button
|
<button
|
||||||
key={item.id}
|
key={item.id}
|
||||||
|
type="button"
|
||||||
onClick={() => setActiveTab(item.id)}
|
onClick={() => setActiveTab(item.id)}
|
||||||
className={cn(
|
className={cn(
|
||||||
"w-full flex items-center gap-2 px-3 py-2 rounded-lg text-sm transition-colors",
|
"w-full flex items-center gap-2 px-3 py-2 rounded-lg text-sm transition-colors",
|
||||||
|
|||||||
@@ -1,21 +1,14 @@
|
|||||||
|
import { FolderOpen, LayoutGrid, Menu, Settings as SettingsIcon, Workflow, X } from "lucide-react";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { Link } from "react-router-dom";
|
import { Link } from "react-router-dom";
|
||||||
import {
|
|
||||||
LayoutGrid,
|
|
||||||
Workflow,
|
|
||||||
FolderOpen,
|
|
||||||
Settings as SettingsIcon,
|
|
||||||
Menu,
|
|
||||||
X,
|
|
||||||
} from "lucide-react";
|
|
||||||
import { Sidebar } from "./sidebar";
|
|
||||||
import { ToolPanel } from "./tool-panel";
|
|
||||||
import { Footer } from "./footer";
|
|
||||||
import { Dropzone } from "../common/dropzone";
|
|
||||||
import { SettingsDialog } from "../settings/settings-dialog";
|
|
||||||
import { HelpDialog } from "../help/help-dialog";
|
|
||||||
import { useMobile } from "@/hooks/use-mobile";
|
import { useMobile } from "@/hooks/use-mobile";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
import { Dropzone } from "../common/dropzone";
|
||||||
|
import { HelpDialog } from "../help/help-dialog";
|
||||||
|
import { SettingsDialog } from "../settings/settings-dialog";
|
||||||
|
import { Footer } from "./footer";
|
||||||
|
import { Sidebar } from "./sidebar";
|
||||||
|
import { ToolPanel } from "./tool-panel";
|
||||||
|
|
||||||
interface AppLayoutProps {
|
interface AppLayoutProps {
|
||||||
children?: React.ReactNode;
|
children?: React.ReactNode;
|
||||||
@@ -33,7 +26,10 @@ export function AppLayout({ children, showToolPanel = true, onFiles }: AppLayout
|
|||||||
<div className="flex h-screen bg-background text-foreground overflow-hidden">
|
<div className="flex h-screen bg-background text-foreground overflow-hidden">
|
||||||
{/* Desktop sidebar */}
|
{/* Desktop sidebar */}
|
||||||
{!isMobile && (
|
{!isMobile && (
|
||||||
<Sidebar onSettingsClick={() => setSettingsOpen(true)} onHelpClick={() => setHelpOpen(true)} />
|
<Sidebar
|
||||||
|
onSettingsClick={() => setSettingsOpen(true)}
|
||||||
|
onHelpClick={() => setHelpOpen(true)}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Mobile sidebar overlay */}
|
{/* Mobile sidebar overlay */}
|
||||||
@@ -89,12 +85,7 @@ export function AppLayout({ children, showToolPanel = true, onFiles }: AppLayout
|
|||||||
|
|
||||||
{showToolPanel && !isMobile && <ToolPanel />}
|
{showToolPanel && !isMobile && <ToolPanel />}
|
||||||
|
|
||||||
<main
|
<main className={cn("flex-1 flex flex-col overflow-hidden", isMobile && "pt-12 pb-16")}>
|
||||||
className={cn(
|
|
||||||
"flex-1 flex flex-col overflow-hidden",
|
|
||||||
isMobile && "pt-12 pb-16"
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<div className="flex-1 overflow-y-auto p-6 flex items-center justify-center">
|
<div className="flex-1 overflow-y-auto p-6 flex items-center justify-center">
|
||||||
{children || <Dropzone onFiles={onFiles} accept="image/*" />}
|
{children || <Dropzone onFiles={onFiles} accept="image/*" />}
|
||||||
</div>
|
</div>
|
||||||
@@ -124,16 +115,10 @@ export function AppLayout({ children, showToolPanel = true, onFiles }: AppLayout
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Settings dialog */}
|
{/* Settings dialog */}
|
||||||
<SettingsDialog
|
<SettingsDialog open={settingsOpen} onClose={() => setSettingsOpen(false)} />
|
||||||
open={settingsOpen}
|
|
||||||
onClose={() => setSettingsOpen(false)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Help dialog */}
|
{/* Help dialog */}
|
||||||
<HelpDialog
|
<HelpDialog open={helpOpen} onClose={() => setHelpOpen(false)} />
|
||||||
open={helpOpen}
|
|
||||||
onClose={() => setHelpOpen(false)}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,14 +1,7 @@
|
|||||||
|
import type { LucideIcon } from "lucide-react";
|
||||||
|
import { FolderOpen, Grid3x3, HelpCircle, LayoutGrid, Settings, Workflow } from "lucide-react";
|
||||||
import { Link, useLocation } from "react-router-dom";
|
import { Link, useLocation } from "react-router-dom";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import {
|
|
||||||
LayoutGrid,
|
|
||||||
Workflow,
|
|
||||||
HelpCircle,
|
|
||||||
Settings,
|
|
||||||
Grid3x3,
|
|
||||||
FolderOpen,
|
|
||||||
} from "lucide-react";
|
|
||||||
import type { LucideIcon } from "lucide-react";
|
|
||||||
|
|
||||||
interface SidebarItem {
|
interface SidebarItem {
|
||||||
icon: LucideIcon;
|
icon: LucideIcon;
|
||||||
@@ -45,7 +38,7 @@ export function Sidebar({ onSettingsClick, onHelpClick, expanded = false }: Side
|
|||||||
"flex items-center gap-3 px-4 py-2.5 rounded-lg cursor-pointer transition-colors",
|
"flex items-center gap-3 px-4 py-2.5 rounded-lg cursor-pointer transition-colors",
|
||||||
isActive
|
isActive
|
||||||
? "bg-primary text-primary-foreground"
|
? "bg-primary text-primary-foreground"
|
||||||
: "text-muted-foreground hover:bg-muted hover:text-foreground"
|
: "text-muted-foreground hover:bg-muted hover:text-foreground",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<item.icon className="h-5 w-5" />
|
<item.icon className="h-5 w-5" />
|
||||||
@@ -57,7 +50,7 @@ export function Sidebar({ onSettingsClick, onHelpClick, expanded = false }: Side
|
|||||||
"flex flex-col items-center gap-1 p-2 rounded-lg cursor-pointer transition-colors",
|
"flex flex-col items-center gap-1 p-2 rounded-lg cursor-pointer transition-colors",
|
||||||
isActive
|
isActive
|
||||||
? "bg-primary text-primary-foreground"
|
? "bg-primary text-primary-foreground"
|
||||||
: "text-muted-foreground hover:bg-muted hover:text-foreground"
|
: "text-muted-foreground hover:bg-muted hover:text-foreground",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<item.icon className="h-6 w-6" />
|
<item.icon className="h-6 w-6" />
|
||||||
@@ -89,9 +82,7 @@ export function Sidebar({ onSettingsClick, onHelpClick, expanded = false }: Side
|
|||||||
if (expanded) {
|
if (expanded) {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col p-3 gap-1">
|
<div className="flex flex-col p-3 gap-1">
|
||||||
{topItems.map((item) =>
|
{topItems.map((item) => renderItem(item, location.pathname === item.href))}
|
||||||
renderItem(item, location.pathname === item.href)
|
|
||||||
)}
|
|
||||||
<div className="border-t border-border my-2" />
|
<div className="border-t border-border my-2" />
|
||||||
{bottomItems.map((item) => renderItem(item, false))}
|
{bottomItems.map((item) => renderItem(item, false))}
|
||||||
</div>
|
</div>
|
||||||
@@ -101,9 +92,7 @@ export function Sidebar({ onSettingsClick, onHelpClick, expanded = false }: Side
|
|||||||
return (
|
return (
|
||||||
<aside className="flex flex-col items-center w-16 bg-sidebar border-r border-border py-3 gap-1 shrink-0">
|
<aside className="flex flex-col items-center w-16 bg-sidebar border-r border-border py-3 gap-1 shrink-0">
|
||||||
<div className="flex flex-col gap-1 flex-1">
|
<div className="flex flex-col gap-1 flex-1">
|
||||||
{topItems.map((item) =>
|
{topItems.map((item) => renderItem(item, location.pathname === item.href))}
|
||||||
renderItem(item, location.pathname === item.href)
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
<div className="border-t border-border w-10 my-2" />
|
<div className="border-t border-border w-10 my-2" />
|
||||||
<div className="flex flex-col gap-1">
|
<div className="flex flex-col gap-1">
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useCallback, useState, useRef, useEffect } from "react";
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
import { useFileStore } from "@/stores/file-store";
|
import { useFileStore } from "@/stores/file-store";
|
||||||
|
|
||||||
function getToken(): string {
|
function getToken(): string {
|
||||||
@@ -29,7 +29,11 @@ const IDLE_PROGRESS: ToolProgress = {
|
|||||||
// AI tools that go through Python/bridge.ts and can emit SSE progress.
|
// AI tools that go through Python/bridge.ts and can emit SSE progress.
|
||||||
// smart-crop is category "ai" but uses Sharp (no Python), so it's excluded.
|
// smart-crop is category "ai" but uses Sharp (no Python), so it's excluded.
|
||||||
const AI_PYTHON_TOOLS = new Set([
|
const AI_PYTHON_TOOLS = new Set([
|
||||||
"remove-background", "upscale", "blur-faces", "erase-object", "ocr",
|
"remove-background",
|
||||||
|
"upscale",
|
||||||
|
"blur-faces",
|
||||||
|
"erase-object",
|
||||||
|
"ocr",
|
||||||
]);
|
]);
|
||||||
|
|
||||||
export function useToolProcessor(toolId: string) {
|
export function useToolProcessor(toolId: string) {
|
||||||
@@ -89,9 +93,7 @@ export function useToolProcessor(toolId: string) {
|
|||||||
// For AI tools, open SSE before uploading
|
// For AI tools, open SSE before uploading
|
||||||
if (isAiTool) {
|
if (isAiTool) {
|
||||||
try {
|
try {
|
||||||
const es = new EventSource(
|
const es = new EventSource(`/api/v1/jobs/${clientJobId}/progress`);
|
||||||
`/api/v1/jobs/${clientJobId}/progress`,
|
|
||||||
);
|
|
||||||
eventSourceRef.current = es;
|
eventSourceRef.current = es;
|
||||||
|
|
||||||
es.onmessage = (event) => {
|
es.onmessage = (event) => {
|
||||||
@@ -251,7 +253,8 @@ export function useToolProcessor(toolId: string) {
|
|||||||
try {
|
try {
|
||||||
const data = JSON.parse(event.data);
|
const data = JSON.parse(event.data);
|
||||||
if (data.type === "batch") {
|
if (data.type === "batch") {
|
||||||
const pct = data.totalFiles > 0 ? 15 + (data.completedFiles / data.totalFiles) * 85 : 15;
|
const pct =
|
||||||
|
data.totalFiles > 0 ? 15 + (data.completedFiles / data.totalFiles) * 85 : 15;
|
||||||
setProgress((prev) => ({
|
setProgress((prev) => ({
|
||||||
...prev,
|
...prev,
|
||||||
phase: "processing",
|
phase: "processing",
|
||||||
@@ -261,10 +264,17 @@ export function useToolProcessor(toolId: string) {
|
|||||||
: `Processing ${data.completedFiles}/${data.totalFiles}`,
|
: `Processing ${data.completedFiles}/${data.totalFiles}`,
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
} catch { /* ignore malformed SSE */ }
|
} catch {
|
||||||
|
/* ignore malformed SSE */
|
||||||
|
}
|
||||||
};
|
};
|
||||||
es.onerror = () => { es.close(); eventSourceRef.current = null; };
|
es.onerror = () => {
|
||||||
} catch { /* SSE failed, proceed without */ }
|
es.close();
|
||||||
|
eventSourceRef.current = null;
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
/* SSE failed, proceed without */
|
||||||
|
}
|
||||||
|
|
||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
for (const file of files) formData.append("file", file);
|
for (const file of files) formData.append("file", file);
|
||||||
@@ -280,7 +290,10 @@ export function useToolProcessor(toolId: string) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (elapsedRef.current) clearInterval(elapsedRef.current);
|
if (elapsedRef.current) clearInterval(elapsedRef.current);
|
||||||
if (eventSourceRef.current) { eventSourceRef.current.close(); eventSourceRef.current = null; }
|
if (eventSourceRef.current) {
|
||||||
|
eventSourceRef.current.close();
|
||||||
|
eventSourceRef.current = null;
|
||||||
|
}
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
const text = await response.text();
|
const text = await response.text();
|
||||||
@@ -288,7 +301,9 @@ export function useToolProcessor(toolId: string) {
|
|||||||
try {
|
try {
|
||||||
const body = JSON.parse(text);
|
const body = JSON.parse(text);
|
||||||
errorMsg = body.error || body.details || `Batch processing failed: ${response.status}`;
|
errorMsg = body.error || body.details || `Batch processing failed: ${response.status}`;
|
||||||
} catch { errorMsg = `Batch processing failed: ${response.status}`; }
|
} catch {
|
||||||
|
errorMsg = `Batch processing failed: ${response.status}`;
|
||||||
|
}
|
||||||
setError(errorMsg);
|
setError(errorMsg);
|
||||||
setProcessing(false);
|
setProcessing(false);
|
||||||
setProgress(IDLE_PROGRESS);
|
setProgress(IDLE_PROGRESS);
|
||||||
@@ -300,10 +315,12 @@ export function useToolProcessor(toolId: string) {
|
|||||||
|
|
||||||
// Extract files from ZIP using fflate
|
// Extract files from ZIP using fflate
|
||||||
const { unzipSync } = await import("fflate");
|
const { unzipSync } = await import("fflate");
|
||||||
const zipBuffer = new Uint8Array(await zipBlob.arrayBuffer() as ArrayBuffer);
|
const zipBuffer = new Uint8Array((await zipBlob.arrayBuffer()) as ArrayBuffer);
|
||||||
const extracted = unzipSync(zipBuffer);
|
const extracted = unzipSync(zipBuffer);
|
||||||
|
|
||||||
const fileOrder = (response.headers.get("X-File-Order")?.split(",") ?? []).map(decodeURIComponent);
|
const fileOrder = (response.headers.get("X-File-Order")?.split(",") ?? []).map(
|
||||||
|
decodeURIComponent,
|
||||||
|
);
|
||||||
const entries = useFileStore.getState().entries;
|
const entries = useFileStore.getState().entries;
|
||||||
const extractedNames = Object.keys(extracted);
|
const extractedNames = Object.keys(extracted);
|
||||||
|
|
||||||
@@ -316,7 +333,11 @@ export function useToolProcessor(toolId: string) {
|
|||||||
}
|
}
|
||||||
if (zipName && extracted[zipName]) {
|
if (zipName && extracted[zipName]) {
|
||||||
const blob = new Blob([extracted[zipName] as BlobPart]);
|
const blob = new Blob([extracted[zipName] as BlobPart]);
|
||||||
updateEntry(i, { processedUrl: URL.createObjectURL(blob), processedSize: blob.size, status: "completed" });
|
updateEntry(i, {
|
||||||
|
processedUrl: URL.createObjectURL(blob),
|
||||||
|
processedSize: blob.size,
|
||||||
|
status: "completed",
|
||||||
|
});
|
||||||
} else {
|
} else {
|
||||||
updateEntry(i, { status: "failed", error: "File not found in batch results" });
|
updateEntry(i, { status: "failed", error: "File not found in batch results" });
|
||||||
}
|
}
|
||||||
@@ -326,7 +347,10 @@ export function useToolProcessor(toolId: string) {
|
|||||||
setProgress(IDLE_PROGRESS);
|
setProgress(IDLE_PROGRESS);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (elapsedRef.current) clearInterval(elapsedRef.current);
|
if (elapsedRef.current) clearInterval(elapsedRef.current);
|
||||||
if (eventSourceRef.current) { eventSourceRef.current.close(); eventSourceRef.current = null; }
|
if (eventSourceRef.current) {
|
||||||
|
eventSourceRef.current.close();
|
||||||
|
eventSourceRef.current = null;
|
||||||
|
}
|
||||||
setError(err instanceof Error ? err.message : "Batch processing failed");
|
setError(err instanceof Error ? err.message : "Batch processing failed");
|
||||||
setProcessing(false);
|
setProcessing(false);
|
||||||
setProgress(IDLE_PROGRESS);
|
setProgress(IDLE_PROGRESS);
|
||||||
|
|||||||
+8
-10
@@ -59,14 +59,12 @@ export function clearToken() {
|
|||||||
|
|
||||||
// ── File Upload / Download ──────────────────────────────────────
|
// ── File Upload / Download ──────────────────────────────────────
|
||||||
|
|
||||||
export async function apiUpload(
|
export async function apiUpload(files: File[]): Promise<{
|
||||||
files: File[],
|
|
||||||
): Promise<{
|
|
||||||
jobId: string;
|
jobId: string;
|
||||||
files: Array<{ name: string; size: number; format: string }>;
|
files: Array<{ name: string; size: number; format: string }>;
|
||||||
}> {
|
}> {
|
||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
files.forEach((f) => formData.append("files", f));
|
for (const f of files) formData.append("files", f);
|
||||||
const res = await fetch("/api/v1/upload", {
|
const res = await fetch("/api/v1/upload", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { Authorization: `Bearer ${getToken()}` },
|
headers: { Authorization: `Bearer ${getToken()}` },
|
||||||
@@ -118,14 +116,17 @@ export async function apiListFiles(params?: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function apiGetFileDetails(id: string): Promise<UserFileDetail> {
|
export async function apiGetFileDetails(id: string): Promise<UserFileDetail> {
|
||||||
return apiGet(`/v1/files/${id}`);
|
const res = await apiGet<{ file: UserFile; versions: UserFileDetail["versions"] }>(
|
||||||
|
`/v1/files/${id}`,
|
||||||
|
);
|
||||||
|
return { ...res.file, versions: res.versions };
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function apiUploadUserFiles(
|
export async function apiUploadUserFiles(
|
||||||
files: File[],
|
files: File[],
|
||||||
): Promise<{ files: Array<{ id: string; originalName: string; size: number; version: number }> }> {
|
): Promise<{ files: Array<{ id: string; originalName: string; size: number; version: number }> }> {
|
||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
files.forEach((f) => formData.append("files", f));
|
for (const f of files) formData.append("files", f);
|
||||||
const res = await fetch("/api/v1/files/upload", {
|
const res = await fetch("/api/v1/files/upload", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { Authorization: `Bearer ${getToken()}` },
|
headers: { Authorization: `Bearer ${getToken()}` },
|
||||||
@@ -156,10 +157,7 @@ export function getFileDownloadUrl(id: string): string {
|
|||||||
return `/api/v1/files/${id}/download`;
|
return `/api/v1/files/${id}/download`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function apiDownloadBlob(
|
export async function apiDownloadBlob(jobId: string, filename: string): Promise<Blob> {
|
||||||
jobId: string,
|
|
||||||
filename: string,
|
|
||||||
): Promise<Blob> {
|
|
||||||
const res = await fetch(getDownloadUrl(jobId, filename), {
|
const res = await fetch(getDownloadUrl(jobId, filename), {
|
||||||
headers: { Authorization: `Bearer ${getToken()}` },
|
headers: { Authorization: `Bearer ${getToken()}` },
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
import { useState } from "react";
|
|
||||||
import { X } from "lucide-react";
|
import { X } from "lucide-react";
|
||||||
import { AppLayout } from "@/components/layout/app-layout";
|
import { useState } from "react";
|
||||||
import { FilesNav } from "@/components/files/files-nav";
|
|
||||||
import { FileList } from "@/components/files/file-list";
|
|
||||||
import { FileDetails } from "@/components/files/file-details";
|
import { FileDetails } from "@/components/files/file-details";
|
||||||
|
import { FileList } from "@/components/files/file-list";
|
||||||
import { FileUploadArea } from "@/components/files/file-upload-area";
|
import { FileUploadArea } from "@/components/files/file-upload-area";
|
||||||
import { useFilesPageStore } from "@/stores/files-page-store";
|
import { FilesNav } from "@/components/files/files-nav";
|
||||||
|
import { AppLayout } from "@/components/layout/app-layout";
|
||||||
import { useMobile } from "@/hooks/use-mobile";
|
import { useMobile } from "@/hooks/use-mobile";
|
||||||
|
import { useFilesPageStore } from "@/stores/files-page-store";
|
||||||
|
|
||||||
export function FilesPage() {
|
export function FilesPage() {
|
||||||
const { activeTab, setActiveTab, selectedFileId } = useFilesPageStore();
|
const { activeTab, setActiveTab, selectedFileId } = useFilesPageStore();
|
||||||
@@ -20,6 +20,7 @@ export function FilesPage() {
|
|||||||
{/* Mobile tabs */}
|
{/* Mobile tabs */}
|
||||||
<div className="flex border-b border-border">
|
<div className="flex border-b border-border">
|
||||||
<button
|
<button
|
||||||
|
type="button"
|
||||||
onClick={() => setActiveTab("recent")}
|
onClick={() => setActiveTab("recent")}
|
||||||
className={`flex-1 py-2.5 text-sm font-medium text-center transition-colors ${
|
className={`flex-1 py-2.5 text-sm font-medium text-center transition-colors ${
|
||||||
activeTab === "recent"
|
activeTab === "recent"
|
||||||
@@ -30,6 +31,7 @@ export function FilesPage() {
|
|||||||
Recent
|
Recent
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
|
type="button"
|
||||||
onClick={() => setActiveTab("upload")}
|
onClick={() => setActiveTab("upload")}
|
||||||
className={`flex-1 py-2.5 text-sm font-medium text-center transition-colors ${
|
className={`flex-1 py-2.5 text-sm font-medium text-center transition-colors ${
|
||||||
activeTab === "upload"
|
activeTab === "upload"
|
||||||
@@ -42,7 +44,17 @@ export function FilesPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{activeTab === "recent" ? (
|
{activeTab === "recent" ? (
|
||||||
<div className="flex-1 overflow-hidden" onClick={() => { if (selectedFileId) setShowDetails(true); }}>
|
<div
|
||||||
|
role="listbox"
|
||||||
|
tabIndex={0}
|
||||||
|
className="flex-1 overflow-hidden"
|
||||||
|
onClick={() => {
|
||||||
|
if (selectedFileId) setShowDetails(true);
|
||||||
|
}}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if ((e.key === "Enter" || e.key === " ") && selectedFileId) setShowDetails(true);
|
||||||
|
}}
|
||||||
|
>
|
||||||
<FileList />
|
<FileList />
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
@@ -51,14 +63,22 @@ export function FilesPage() {
|
|||||||
|
|
||||||
{/* Mobile detail bottom sheet */}
|
{/* Mobile detail bottom sheet */}
|
||||||
{showDetails && selectedFileId && (
|
{showDetails && selectedFileId && (
|
||||||
<div className="fixed inset-0 z-50 bg-black/50" onClick={() => setShowDetails(false)}>
|
<div
|
||||||
<div
|
role="dialog"
|
||||||
className="absolute bottom-0 left-0 right-0 bg-background rounded-t-xl p-4 max-h-[70vh] overflow-y-auto"
|
aria-modal="true"
|
||||||
onClick={(e) => e.stopPropagation()}
|
aria-label="File Details"
|
||||||
>
|
className="fixed inset-0 z-50 bg-black/50"
|
||||||
|
onClick={(e) => {
|
||||||
|
if (e.target === e.currentTarget) setShowDetails(false);
|
||||||
|
}}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Escape") setShowDetails(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="absolute bottom-0 left-0 right-0 bg-background rounded-t-xl p-4 max-h-[70vh] overflow-y-auto">
|
||||||
<div className="flex justify-between items-center mb-3">
|
<div className="flex justify-between items-center mb-3">
|
||||||
<span className="text-sm font-semibold">File Details</span>
|
<span className="text-sm font-semibold">File Details</span>
|
||||||
<button onClick={() => setShowDetails(false)}>
|
<button type="button" onClick={() => setShowDetails(false)}>
|
||||||
<X className="h-5 w-5 text-muted-foreground" />
|
<X className="h-5 w-5 text-muted-foreground" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -87,8 +87,7 @@ function deriveCompat(entries: FileEntry[], selectedIndex: number) {
|
|||||||
files: entries.map((e) => e.file),
|
files: entries.map((e) => e.file),
|
||||||
currentEntry: entry,
|
currentEntry: entry,
|
||||||
hasFiles: entries.length > 0,
|
hasFiles: entries.length > 0,
|
||||||
allProcessed:
|
allProcessed: entries.length > 0 && entries.every((e) => e.status === "completed"),
|
||||||
entries.length > 0 && entries.every((e) => e.status === "completed"),
|
|
||||||
selectedFileName: entry ? entry.file.name : null,
|
selectedFileName: entry ? entry.file.name : null,
|
||||||
selectedFileSize: entry ? entry.file.size : null,
|
selectedFileSize: entry ? entry.file.size : null,
|
||||||
originalBlobUrl: entry ? entry.blobUrl : null,
|
originalBlobUrl: entry ? entry.blobUrl : null,
|
||||||
@@ -183,8 +182,7 @@ export const useFileStore = create<FileState>((set, get) => ({
|
|||||||
set({ entries, ...deriveCompat(entries, idx) });
|
set({ entries, ...deriveCompat(entries, idx) });
|
||||||
},
|
},
|
||||||
|
|
||||||
setBatchZip: (blob, filename) =>
|
setBatchZip: (blob, filename) => set({ batchZipBlob: blob, batchZipFilename: filename }),
|
||||||
set({ batchZipBlob: blob, batchZipFilename: filename }),
|
|
||||||
|
|
||||||
setProcessing: (v) => set({ processing: v }),
|
setProcessing: (v) => set({ processing: v }),
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,5 @@
|
|||||||
import { create } from "zustand";
|
import { create } from "zustand";
|
||||||
import {
|
import { apiDeleteUserFiles, apiListFiles, apiUploadUserFiles, type UserFile } from "@/lib/api";
|
||||||
apiListFiles,
|
|
||||||
apiUploadUserFiles,
|
|
||||||
apiDeleteUserFiles,
|
|
||||||
type UserFile,
|
|
||||||
} from "@/lib/api";
|
|
||||||
|
|
||||||
interface FilesPageState {
|
interface FilesPageState {
|
||||||
files: UserFile[];
|
files: UserFile[];
|
||||||
@@ -75,12 +70,15 @@ export const useFilesPageStore = create<FilesPageState>((set, get) => ({
|
|||||||
toggleChecked: (id) => {
|
toggleChecked: (id) => {
|
||||||
const { checkedIds } = get();
|
const { checkedIds } = get();
|
||||||
const next = new Set(checkedIds);
|
const next = new Set(checkedIds);
|
||||||
if (next.has(id)) next.delete(id); else next.add(id);
|
if (next.has(id)) next.delete(id);
|
||||||
|
else next.add(id);
|
||||||
set({ checkedIds: next });
|
set({ checkedIds: next });
|
||||||
},
|
},
|
||||||
toggleCheckAll: () => {
|
toggleCheckAll: () => {
|
||||||
const { files, checkedIds } = get();
|
const { files, checkedIds } = get();
|
||||||
set({ checkedIds: checkedIds.size === files.length ? new Set() : new Set(files.map(f => f.id)) });
|
set({
|
||||||
|
checkedIds: checkedIds.size === files.length ? new Set() : new Set(files.map((f) => f.id)),
|
||||||
|
});
|
||||||
},
|
},
|
||||||
setSearchQuery: (q) => set({ searchQuery: q }),
|
setSearchQuery: (q) => set({ searchQuery: q }),
|
||||||
setActiveTab: (tab) => set({ activeTab: tab }),
|
setActiveTab: (tab) => set({ activeTab: tab }),
|
||||||
|
|||||||
Reference in New Issue
Block a user