mirror of
https://github.com/Xevion/xevion.dev.git
synced 2026-01-31 02:26:38 -06:00
feat: add media upload pipeline with multipart support, blurhash generation, and R2 storage
- Add project_media table with image/video variants, ordering, and metadata - Implement multipart upload handlers with 50MB limit - Generate blurhash placeholders and resize images to thumb/medium/full variants - Update ProjectCard to use media carousel instead of mock gradients - Add MediaManager component for drag-drop upload and reordering
This commit is contained in:
Vendored
+1
-1
@@ -7,7 +7,7 @@ web/build/
|
|||||||
web/.svelte-kit/
|
web/.svelte-kit/
|
||||||
|
|
||||||
# CLI session file
|
# CLI session file
|
||||||
.xevion-session
|
.*-session
|
||||||
|
|
||||||
# Added by cargo
|
# Added by cargo
|
||||||
/target
|
/target
|
||||||
|
|||||||
+105
@@ -0,0 +1,105 @@
|
|||||||
|
{
|
||||||
|
"db_name": "PostgreSQL",
|
||||||
|
"query": "\n UPDATE project_media\n SET metadata = $2\n WHERE id = $1\n RETURNING \n id,\n project_id,\n display_order,\n media_type as \"media_type: MediaType\",\n original_filename,\n r2_base_path,\n variants,\n width,\n height,\n size_bytes,\n blurhash,\n metadata,\n created_at\n ",
|
||||||
|
"describe": {
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"ordinal": 0,
|
||||||
|
"name": "id",
|
||||||
|
"type_info": "Uuid"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 1,
|
||||||
|
"name": "project_id",
|
||||||
|
"type_info": "Uuid"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 2,
|
||||||
|
"name": "display_order",
|
||||||
|
"type_info": "Int4"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 3,
|
||||||
|
"name": "media_type: MediaType",
|
||||||
|
"type_info": {
|
||||||
|
"Custom": {
|
||||||
|
"name": "media_type",
|
||||||
|
"kind": {
|
||||||
|
"Enum": [
|
||||||
|
"image",
|
||||||
|
"video"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 4,
|
||||||
|
"name": "original_filename",
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 5,
|
||||||
|
"name": "r2_base_path",
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 6,
|
||||||
|
"name": "variants",
|
||||||
|
"type_info": "Jsonb"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 7,
|
||||||
|
"name": "width",
|
||||||
|
"type_info": "Int4"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 8,
|
||||||
|
"name": "height",
|
||||||
|
"type_info": "Int4"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 9,
|
||||||
|
"name": "size_bytes",
|
||||||
|
"type_info": "Int8"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 10,
|
||||||
|
"name": "blurhash",
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 11,
|
||||||
|
"name": "metadata",
|
||||||
|
"type_info": "Jsonb"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 12,
|
||||||
|
"name": "created_at",
|
||||||
|
"type_info": "Timestamptz"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"parameters": {
|
||||||
|
"Left": [
|
||||||
|
"Uuid",
|
||||||
|
"Jsonb"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"nullable": [
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
true,
|
||||||
|
true,
|
||||||
|
false,
|
||||||
|
true,
|
||||||
|
true,
|
||||||
|
false
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"hash": "2697c981355b4a1d46699aa5fe0f2dad6a07e7b46d7c546fa10e2568a35e710d"
|
||||||
|
}
|
||||||
+16
@@ -0,0 +1,16 @@
|
|||||||
|
{
|
||||||
|
"db_name": "PostgreSQL",
|
||||||
|
"query": "UPDATE project_media SET display_order = $1 WHERE id = $2 AND project_id = $3",
|
||||||
|
"describe": {
|
||||||
|
"columns": [],
|
||||||
|
"parameters": {
|
||||||
|
"Left": [
|
||||||
|
"Int4",
|
||||||
|
"Uuid",
|
||||||
|
"Uuid"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"nullable": []
|
||||||
|
},
|
||||||
|
"hash": "52430d2d1ebf437bd5fac97ab7f3329e0883b4dae1a2ff93850e26f9bde31914"
|
||||||
|
}
|
||||||
+104
@@ -0,0 +1,104 @@
|
|||||||
|
{
|
||||||
|
"db_name": "PostgreSQL",
|
||||||
|
"query": "\n SELECT \n id,\n project_id,\n display_order,\n media_type as \"media_type: MediaType\",\n original_filename,\n r2_base_path,\n variants,\n width,\n height,\n size_bytes,\n blurhash,\n metadata,\n created_at\n FROM project_media\n WHERE project_id = $1\n ORDER BY display_order ASC\n ",
|
||||||
|
"describe": {
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"ordinal": 0,
|
||||||
|
"name": "id",
|
||||||
|
"type_info": "Uuid"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 1,
|
||||||
|
"name": "project_id",
|
||||||
|
"type_info": "Uuid"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 2,
|
||||||
|
"name": "display_order",
|
||||||
|
"type_info": "Int4"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 3,
|
||||||
|
"name": "media_type: MediaType",
|
||||||
|
"type_info": {
|
||||||
|
"Custom": {
|
||||||
|
"name": "media_type",
|
||||||
|
"kind": {
|
||||||
|
"Enum": [
|
||||||
|
"image",
|
||||||
|
"video"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 4,
|
||||||
|
"name": "original_filename",
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 5,
|
||||||
|
"name": "r2_base_path",
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 6,
|
||||||
|
"name": "variants",
|
||||||
|
"type_info": "Jsonb"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 7,
|
||||||
|
"name": "width",
|
||||||
|
"type_info": "Int4"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 8,
|
||||||
|
"name": "height",
|
||||||
|
"type_info": "Int4"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 9,
|
||||||
|
"name": "size_bytes",
|
||||||
|
"type_info": "Int8"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 10,
|
||||||
|
"name": "blurhash",
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 11,
|
||||||
|
"name": "metadata",
|
||||||
|
"type_info": "Jsonb"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 12,
|
||||||
|
"name": "created_at",
|
||||||
|
"type_info": "Timestamptz"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"parameters": {
|
||||||
|
"Left": [
|
||||||
|
"Uuid"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"nullable": [
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
true,
|
||||||
|
true,
|
||||||
|
false,
|
||||||
|
true,
|
||||||
|
true,
|
||||||
|
false
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"hash": "54b5eb8bf65df8dd3caa1a1fcac9cf71cb9c665ac8d3b86dd63a9692788f7392"
|
||||||
|
}
|
||||||
+104
@@ -0,0 +1,104 @@
|
|||||||
|
{
|
||||||
|
"db_name": "PostgreSQL",
|
||||||
|
"query": "\n SELECT \n id,\n project_id,\n display_order,\n media_type as \"media_type: MediaType\",\n original_filename,\n r2_base_path,\n variants,\n width,\n height,\n size_bytes,\n blurhash,\n metadata,\n created_at\n FROM project_media\n WHERE id = $1\n ",
|
||||||
|
"describe": {
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"ordinal": 0,
|
||||||
|
"name": "id",
|
||||||
|
"type_info": "Uuid"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 1,
|
||||||
|
"name": "project_id",
|
||||||
|
"type_info": "Uuid"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 2,
|
||||||
|
"name": "display_order",
|
||||||
|
"type_info": "Int4"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 3,
|
||||||
|
"name": "media_type: MediaType",
|
||||||
|
"type_info": {
|
||||||
|
"Custom": {
|
||||||
|
"name": "media_type",
|
||||||
|
"kind": {
|
||||||
|
"Enum": [
|
||||||
|
"image",
|
||||||
|
"video"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 4,
|
||||||
|
"name": "original_filename",
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 5,
|
||||||
|
"name": "r2_base_path",
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 6,
|
||||||
|
"name": "variants",
|
||||||
|
"type_info": "Jsonb"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 7,
|
||||||
|
"name": "width",
|
||||||
|
"type_info": "Int4"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 8,
|
||||||
|
"name": "height",
|
||||||
|
"type_info": "Int4"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 9,
|
||||||
|
"name": "size_bytes",
|
||||||
|
"type_info": "Int8"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 10,
|
||||||
|
"name": "blurhash",
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 11,
|
||||||
|
"name": "metadata",
|
||||||
|
"type_info": "Jsonb"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 12,
|
||||||
|
"name": "created_at",
|
||||||
|
"type_info": "Timestamptz"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"parameters": {
|
||||||
|
"Left": [
|
||||||
|
"Uuid"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"nullable": [
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
true,
|
||||||
|
true,
|
||||||
|
false,
|
||||||
|
true,
|
||||||
|
true,
|
||||||
|
false
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"hash": "9d0e8c98364de65920482389d7f1699ae4710394ed27b472d4e33190cdc0bd19"
|
||||||
|
}
|
||||||
+124
@@ -0,0 +1,124 @@
|
|||||||
|
{
|
||||||
|
"db_name": "PostgreSQL",
|
||||||
|
"query": "\n INSERT INTO project_media (\n project_id, display_order, media_type, original_filename,\n r2_base_path, variants, width, height, size_bytes, blurhash, metadata\n )\n VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)\n RETURNING \n id,\n project_id,\n display_order,\n media_type as \"media_type: MediaType\",\n original_filename,\n r2_base_path,\n variants,\n width,\n height,\n size_bytes,\n blurhash,\n metadata,\n created_at\n ",
|
||||||
|
"describe": {
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"ordinal": 0,
|
||||||
|
"name": "id",
|
||||||
|
"type_info": "Uuid"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 1,
|
||||||
|
"name": "project_id",
|
||||||
|
"type_info": "Uuid"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 2,
|
||||||
|
"name": "display_order",
|
||||||
|
"type_info": "Int4"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 3,
|
||||||
|
"name": "media_type: MediaType",
|
||||||
|
"type_info": {
|
||||||
|
"Custom": {
|
||||||
|
"name": "media_type",
|
||||||
|
"kind": {
|
||||||
|
"Enum": [
|
||||||
|
"image",
|
||||||
|
"video"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 4,
|
||||||
|
"name": "original_filename",
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 5,
|
||||||
|
"name": "r2_base_path",
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 6,
|
||||||
|
"name": "variants",
|
||||||
|
"type_info": "Jsonb"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 7,
|
||||||
|
"name": "width",
|
||||||
|
"type_info": "Int4"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 8,
|
||||||
|
"name": "height",
|
||||||
|
"type_info": "Int4"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 9,
|
||||||
|
"name": "size_bytes",
|
||||||
|
"type_info": "Int8"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 10,
|
||||||
|
"name": "blurhash",
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 11,
|
||||||
|
"name": "metadata",
|
||||||
|
"type_info": "Jsonb"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 12,
|
||||||
|
"name": "created_at",
|
||||||
|
"type_info": "Timestamptz"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"parameters": {
|
||||||
|
"Left": [
|
||||||
|
"Uuid",
|
||||||
|
"Int4",
|
||||||
|
{
|
||||||
|
"Custom": {
|
||||||
|
"name": "media_type",
|
||||||
|
"kind": {
|
||||||
|
"Enum": [
|
||||||
|
"image",
|
||||||
|
"video"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"Text",
|
||||||
|
"Text",
|
||||||
|
"Jsonb",
|
||||||
|
"Int4",
|
||||||
|
"Int4",
|
||||||
|
"Int8",
|
||||||
|
"Text",
|
||||||
|
"Jsonb"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"nullable": [
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
true,
|
||||||
|
true,
|
||||||
|
false,
|
||||||
|
true,
|
||||||
|
true,
|
||||||
|
false
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"hash": "a9bd8fffc6963610443422abcc07352c88f6ccf65a51b3088ed21648c2b9c193"
|
||||||
|
}
|
||||||
+14
@@ -0,0 +1,14 @@
|
|||||||
|
{
|
||||||
|
"db_name": "PostgreSQL",
|
||||||
|
"query": "DELETE FROM project_media WHERE id = $1",
|
||||||
|
"describe": {
|
||||||
|
"columns": [],
|
||||||
|
"parameters": {
|
||||||
|
"Left": [
|
||||||
|
"Uuid"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"nullable": []
|
||||||
|
},
|
||||||
|
"hash": "aa216d4610fc0708f83704558e9f80449ebb401ccfebaeebed7bd09508a0acd3"
|
||||||
|
}
|
||||||
+22
@@ -0,0 +1,22 @@
|
|||||||
|
{
|
||||||
|
"db_name": "PostgreSQL",
|
||||||
|
"query": "\n SELECT COALESCE(MAX(display_order) + 1, 0) as \"next_order!\"\n FROM project_media\n WHERE project_id = $1\n ",
|
||||||
|
"describe": {
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"ordinal": 0,
|
||||||
|
"name": "next_order!",
|
||||||
|
"type_info": "Int4"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"parameters": {
|
||||||
|
"Left": [
|
||||||
|
"Uuid"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"nullable": [
|
||||||
|
null
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"hash": "e98512ebc020d1e9097d07c1ef443ed85243cca7a9a3af251b6ab9c2ef85385f"
|
||||||
|
}
|
||||||
Generated
+693
@@ -2,6 +2,12 @@
|
|||||||
# It is not intended for manual editing.
|
# It is not intended for manual editing.
|
||||||
version = 4
|
version = 4
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "adler2"
|
||||||
|
version = "2.0.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "aho-corasick"
|
name = "aho-corasick"
|
||||||
version = "1.1.4"
|
version = "1.1.4"
|
||||||
@@ -11,6 +17,24 @@ dependencies = [
|
|||||||
"memchr",
|
"memchr",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "aligned"
|
||||||
|
version = "0.4.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "ee4508988c62edf04abd8d92897fca0c2995d907ce1dfeaf369dac3716a40685"
|
||||||
|
dependencies = [
|
||||||
|
"as-slice",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "aligned-vec"
|
||||||
|
version = "0.6.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "dc890384c8602f339876ded803c97ad529f3842aba97f6392b3dba0dd171769b"
|
||||||
|
dependencies = [
|
||||||
|
"equator",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "allocator-api2"
|
name = "allocator-api2"
|
||||||
version = "0.2.21"
|
version = "0.2.21"
|
||||||
@@ -67,6 +91,12 @@ dependencies = [
|
|||||||
"windows-sys 0.61.2",
|
"windows-sys 0.61.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "anyhow"
|
||||||
|
version = "1.0.100"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "api"
|
name = "api"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
@@ -76,10 +106,12 @@ dependencies = [
|
|||||||
"aws-sdk-s3",
|
"aws-sdk-s3",
|
||||||
"axum",
|
"axum",
|
||||||
"axum-extra",
|
"axum-extra",
|
||||||
|
"blurhash",
|
||||||
"clap",
|
"clap",
|
||||||
"dashmap",
|
"dashmap",
|
||||||
"dotenvy",
|
"dotenvy",
|
||||||
"futures",
|
"futures",
|
||||||
|
"image",
|
||||||
"include_dir",
|
"include_dir",
|
||||||
"mime_guess",
|
"mime_guess",
|
||||||
"moka",
|
"moka",
|
||||||
@@ -102,6 +134,23 @@ dependencies = [
|
|||||||
"uuid",
|
"uuid",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "arbitrary"
|
||||||
|
version = "1.4.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "arg_enum_proc_macro"
|
||||||
|
version = "0.3.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "0ae92a5119aa49cdbcf6b9f893fe4e1d98b04ccbf82ee0584ad948a44a734dea"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "argon2"
|
name = "argon2"
|
||||||
version = "0.5.3"
|
version = "0.5.3"
|
||||||
@@ -114,6 +163,21 @@ dependencies = [
|
|||||||
"password-hash",
|
"password-hash",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "arrayvec"
|
||||||
|
version = "0.7.6"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "as-slice"
|
||||||
|
version = "0.2.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "516b6b4f0e40d50dcda9365d53964ec74560ad4284da2e7fc97122cd83174516"
|
||||||
|
dependencies = [
|
||||||
|
"stable_deref_trait",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "async-lock"
|
name = "async-lock"
|
||||||
version = "3.4.2"
|
version = "3.4.2"
|
||||||
@@ -146,6 +210,49 @@ version = "1.5.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8"
|
checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "av-scenechange"
|
||||||
|
version = "0.14.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "0f321d77c20e19b92c39e7471cf986812cbb46659d2af674adc4331ef3f18394"
|
||||||
|
dependencies = [
|
||||||
|
"aligned",
|
||||||
|
"anyhow",
|
||||||
|
"arg_enum_proc_macro",
|
||||||
|
"arrayvec",
|
||||||
|
"log",
|
||||||
|
"num-rational",
|
||||||
|
"num-traits",
|
||||||
|
"pastey",
|
||||||
|
"rayon",
|
||||||
|
"thiserror 2.0.17",
|
||||||
|
"v_frame",
|
||||||
|
"y4m",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "av1-grain"
|
||||||
|
version = "0.2.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "8cfddb07216410377231960af4fcab838eaa12e013417781b78bd95ee22077f8"
|
||||||
|
dependencies = [
|
||||||
|
"anyhow",
|
||||||
|
"arrayvec",
|
||||||
|
"log",
|
||||||
|
"nom",
|
||||||
|
"num-rational",
|
||||||
|
"v_frame",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "avif-serialize"
|
||||||
|
version = "0.8.6"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "47c8fbc0f831f4519fe8b810b6a7a91410ec83031b8233f730a0480029f6a23f"
|
||||||
|
dependencies = [
|
||||||
|
"arrayvec",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "aws-config"
|
name = "aws-config"
|
||||||
version = "1.8.12"
|
version = "1.8.12"
|
||||||
@@ -595,6 +702,7 @@ dependencies = [
|
|||||||
"matchit",
|
"matchit",
|
||||||
"memchr",
|
"memchr",
|
||||||
"mime",
|
"mime",
|
||||||
|
"multer",
|
||||||
"percent-encoding",
|
"percent-encoding",
|
||||||
"pin-project-lite",
|
"pin-project-lite",
|
||||||
"serde_core",
|
"serde_core",
|
||||||
@@ -678,6 +786,12 @@ version = "1.8.2"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "7d809780667f4410e7c41b07f52439b94d2bdf8528eeedc287fa38d3b7f95d82"
|
checksum = "7d809780667f4410e7c41b07f52439b94d2bdf8528eeedc287fa38d3b7f95d82"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "bit_field"
|
||||||
|
version = "0.10.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "1e4b40c7323adcfc0a41c4b88143ed58346ff65a288fc144329c5c45e05d70c6"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "bitflags"
|
name = "bitflags"
|
||||||
version = "2.10.0"
|
version = "2.10.0"
|
||||||
@@ -687,6 +801,15 @@ dependencies = [
|
|||||||
"serde_core",
|
"serde_core",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "bitstream-io"
|
||||||
|
version = "4.9.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "60d4bd9d1db2c6bdf285e223a7fa369d5ce98ec767dec949c6ca62863ce61757"
|
||||||
|
dependencies = [
|
||||||
|
"core2",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "blake2"
|
name = "blake2"
|
||||||
version = "0.10.6"
|
version = "0.10.6"
|
||||||
@@ -705,18 +828,42 @@ dependencies = [
|
|||||||
"generic-array",
|
"generic-array",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "blurhash"
|
||||||
|
version = "0.2.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "e79769241dcd44edf79a732545e8b5cec84c247ac060f5252cd51885d093a8fc"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "built"
|
||||||
|
version = "0.8.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f4ad8f11f288f48ca24471bbd51ac257aaeaaa07adae295591266b792902ae64"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "bumpalo"
|
name = "bumpalo"
|
||||||
version = "3.19.1"
|
version = "3.19.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "5dd9dc738b7a8311c7ade152424974d8115f2cdad61e8dab8dac9f2362298510"
|
checksum = "5dd9dc738b7a8311c7ade152424974d8115f2cdad61e8dab8dac9f2362298510"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "bytemuck"
|
||||||
|
version = "1.24.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "1fbdf580320f38b612e485521afda1ee26d10cc9884efaaa750d383e13e3c5f4"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "byteorder"
|
name = "byteorder"
|
||||||
version = "1.5.0"
|
version = "1.5.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b"
|
checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "byteorder-lite"
|
||||||
|
version = "0.1.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "bytes"
|
name = "bytes"
|
||||||
version = "1.11.0"
|
version = "1.11.0"
|
||||||
@@ -812,6 +959,12 @@ dependencies = [
|
|||||||
"cc",
|
"cc",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "color_quant"
|
||||||
|
version = "1.1.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "colorchoice"
|
name = "colorchoice"
|
||||||
version = "1.0.4"
|
version = "1.0.4"
|
||||||
@@ -870,6 +1023,15 @@ version = "0.8.7"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b"
|
checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "core2"
|
||||||
|
version = "0.4.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "b49ba7ef1ad6107f8824dbe97de947cbaac53c44e7f9756a1fba0d37c1eec505"
|
||||||
|
dependencies = [
|
||||||
|
"memchr",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "cpufeatures"
|
name = "cpufeatures"
|
||||||
version = "0.2.17"
|
version = "0.2.17"
|
||||||
@@ -925,6 +1087,16 @@ dependencies = [
|
|||||||
"crossbeam-utils",
|
"crossbeam-utils",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "crossbeam-deque"
|
||||||
|
version = "0.8.6"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "9dd111b7b7f7d55b72c0a6ae361660ee5853c9af73f70c3c2ef6858b950e2e51"
|
||||||
|
dependencies = [
|
||||||
|
"crossbeam-epoch",
|
||||||
|
"crossbeam-utils",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "crossbeam-epoch"
|
name = "crossbeam-epoch"
|
||||||
version = "0.9.18"
|
version = "0.9.18"
|
||||||
@@ -949,6 +1121,12 @@ version = "0.8.21"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28"
|
checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "crunchy"
|
||||||
|
version = "0.2.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "crypto-bigint"
|
name = "crypto-bigint"
|
||||||
version = "0.4.9"
|
version = "0.4.9"
|
||||||
@@ -1111,6 +1289,26 @@ dependencies = [
|
|||||||
"cfg-if",
|
"cfg-if",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "equator"
|
||||||
|
version = "0.4.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "4711b213838dfee0117e3be6ac926007d7f433d7bbe33595975d4190cb07e6fc"
|
||||||
|
dependencies = [
|
||||||
|
"equator-macro",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "equator-macro"
|
||||||
|
version = "0.4.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "44f23cf4b44bfce11a86ace86f8a73ffdec849c9fd00a386a53d278bd9e81fb3"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "equivalent"
|
name = "equivalent"
|
||||||
version = "1.0.2"
|
version = "1.0.2"
|
||||||
@@ -1159,12 +1357,56 @@ dependencies = [
|
|||||||
"pin-project-lite",
|
"pin-project-lite",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "exr"
|
||||||
|
version = "1.74.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "4300e043a56aa2cb633c01af81ca8f699a321879a7854d3896a0ba89056363be"
|
||||||
|
dependencies = [
|
||||||
|
"bit_field",
|
||||||
|
"half",
|
||||||
|
"lebe",
|
||||||
|
"miniz_oxide",
|
||||||
|
"rayon-core",
|
||||||
|
"smallvec",
|
||||||
|
"zune-inflate",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "fastrand"
|
name = "fastrand"
|
||||||
version = "2.3.0"
|
version = "2.3.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be"
|
checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "fax"
|
||||||
|
version = "0.2.6"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f05de7d48f37cd6730705cbca900770cab77a89f413d23e100ad7fad7795a0ab"
|
||||||
|
dependencies = [
|
||||||
|
"fax_derive",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "fax_derive"
|
||||||
|
version = "0.2.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "a0aca10fb742cb43f9e7bb8467c91aa9bcb8e3ffbc6a6f7389bb93ffc920577d"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "fdeflate"
|
||||||
|
version = "0.3.7"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c"
|
||||||
|
dependencies = [
|
||||||
|
"simd-adler32",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ff"
|
name = "ff"
|
||||||
version = "0.12.1"
|
version = "0.12.1"
|
||||||
@@ -1181,6 +1423,16 @@ version = "0.1.6"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "645cbb3a84e60b7531617d5ae4e57f7e27308f6445f5abf653209ea76dec8dff"
|
checksum = "645cbb3a84e60b7531617d5ae4e57f7e27308f6445f5abf653209ea76dec8dff"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "flate2"
|
||||||
|
version = "1.1.8"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "b375d6465b98090a5f25b1c7703f3859783755aa9a80433b36e0379a3ec2f369"
|
||||||
|
dependencies = [
|
||||||
|
"crc32fast",
|
||||||
|
"miniz_oxide",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "flume"
|
name = "flume"
|
||||||
version = "0.11.1"
|
version = "0.11.1"
|
||||||
@@ -1356,6 +1608,16 @@ dependencies = [
|
|||||||
"wasm-bindgen",
|
"wasm-bindgen",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "gif"
|
||||||
|
version = "0.14.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f5df2ba84018d80c213569363bdcd0c64e6933c67fe4c1d60ecf822971a3c35e"
|
||||||
|
dependencies = [
|
||||||
|
"color_quant",
|
||||||
|
"weezl",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "group"
|
name = "group"
|
||||||
version = "0.12.1"
|
version = "0.12.1"
|
||||||
@@ -1405,6 +1667,17 @@ dependencies = [
|
|||||||
"tracing",
|
"tracing",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "half"
|
||||||
|
version = "2.7.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b"
|
||||||
|
dependencies = [
|
||||||
|
"cfg-if",
|
||||||
|
"crunchy",
|
||||||
|
"zerocopy",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "hashbrown"
|
name = "hashbrown"
|
||||||
version = "0.14.5"
|
version = "0.14.5"
|
||||||
@@ -1748,6 +2021,46 @@ dependencies = [
|
|||||||
"icu_properties",
|
"icu_properties",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "image"
|
||||||
|
version = "0.25.9"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "e6506c6c10786659413faa717ceebcb8f70731c0a60cbae39795fdf114519c1a"
|
||||||
|
dependencies = [
|
||||||
|
"bytemuck",
|
||||||
|
"byteorder-lite",
|
||||||
|
"color_quant",
|
||||||
|
"exr",
|
||||||
|
"gif",
|
||||||
|
"image-webp",
|
||||||
|
"moxcms",
|
||||||
|
"num-traits",
|
||||||
|
"png",
|
||||||
|
"qoi",
|
||||||
|
"ravif",
|
||||||
|
"rayon",
|
||||||
|
"rgb",
|
||||||
|
"tiff",
|
||||||
|
"zune-core 0.5.0",
|
||||||
|
"zune-jpeg 0.5.8",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "image-webp"
|
||||||
|
version = "0.2.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "525e9ff3e1a4be2fbea1fdf0e98686a6d98b4d8f937e1bf7402245af1909e8c3"
|
||||||
|
dependencies = [
|
||||||
|
"byteorder-lite",
|
||||||
|
"quick-error",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "imgref"
|
||||||
|
version = "1.12.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "e7c5cedc30da3a610cac6b4ba17597bdf7152cf974e8aab3afb3d54455e371c8"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "include_dir"
|
name = "include_dir"
|
||||||
version = "0.7.4"
|
version = "0.7.4"
|
||||||
@@ -1777,6 +2090,17 @@ dependencies = [
|
|||||||
"hashbrown 0.16.1",
|
"hashbrown 0.16.1",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "interpolate_name"
|
||||||
|
version = "0.2.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "c34819042dc3d3971c46c2190835914dfbe0c3c13f61449b2997f4e9722dfa60"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ipnet"
|
name = "ipnet"
|
||||||
version = "2.11.0"
|
version = "2.11.0"
|
||||||
@@ -1799,6 +2123,15 @@ version = "1.70.2"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695"
|
checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "itertools"
|
||||||
|
version = "0.14.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285"
|
||||||
|
dependencies = [
|
||||||
|
"either",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "itoa"
|
name = "itoa"
|
||||||
version = "1.0.17"
|
version = "1.0.17"
|
||||||
@@ -1856,12 +2189,28 @@ dependencies = [
|
|||||||
"spin",
|
"spin",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "lebe"
|
||||||
|
version = "0.5.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "7a79a3332a6609480d7d0c9eab957bca6b455b91bb84e66d19f5ff66294b85b8"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "libc"
|
name = "libc"
|
||||||
version = "0.2.179"
|
version = "0.2.179"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "c5a2d376baa530d1238d133232d15e239abad80d05838b4b59354e5268af431f"
|
checksum = "c5a2d376baa530d1238d133232d15e239abad80d05838b4b59354e5268af431f"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "libfuzzer-sys"
|
||||||
|
version = "0.4.10"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "5037190e1f70cbeef565bd267599242926f724d3b8a9f510fd7e0b540cfa4404"
|
||||||
|
dependencies = [
|
||||||
|
"arbitrary",
|
||||||
|
"cc",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "libm"
|
name = "libm"
|
||||||
version = "0.2.15"
|
version = "0.2.15"
|
||||||
@@ -1910,6 +2259,15 @@ version = "0.4.29"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897"
|
checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "loop9"
|
||||||
|
version = "0.1.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "0fae87c125b03c1d2c0150c90365d7d6bcc53fb73a9acaef207d2d065860f062"
|
||||||
|
dependencies = [
|
||||||
|
"imgref",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "lru"
|
name = "lru"
|
||||||
version = "0.12.5"
|
version = "0.12.5"
|
||||||
@@ -1940,6 +2298,16 @@ version = "0.8.4"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3"
|
checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "maybe-rayon"
|
||||||
|
version = "0.1.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "8ea1f30cedd69f0a2954655f7188c6a834246d2bcf1e315e2ac40c4b24dc9519"
|
||||||
|
dependencies = [
|
||||||
|
"cfg-if",
|
||||||
|
"rayon",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "md-5"
|
name = "md-5"
|
||||||
version = "0.10.6"
|
version = "0.10.6"
|
||||||
@@ -1972,6 +2340,16 @@ dependencies = [
|
|||||||
"unicase",
|
"unicase",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "miniz_oxide"
|
||||||
|
version = "0.8.9"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316"
|
||||||
|
dependencies = [
|
||||||
|
"adler2",
|
||||||
|
"simd-adler32",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "mio"
|
name = "mio"
|
||||||
version = "1.1.1"
|
version = "1.1.1"
|
||||||
@@ -2003,6 +2381,54 @@ dependencies = [
|
|||||||
"uuid",
|
"uuid",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "moxcms"
|
||||||
|
version = "0.7.11"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "ac9557c559cd6fc9867e122e20d2cbefc9ca29d80d027a8e39310920ed2f0a97"
|
||||||
|
dependencies = [
|
||||||
|
"num-traits",
|
||||||
|
"pxfm",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "multer"
|
||||||
|
version = "3.1.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "83e87776546dc87511aa5ee218730c92b666d7264ab6ed41f9d215af9cd5224b"
|
||||||
|
dependencies = [
|
||||||
|
"bytes",
|
||||||
|
"encoding_rs",
|
||||||
|
"futures-util",
|
||||||
|
"http 1.4.0",
|
||||||
|
"httparse",
|
||||||
|
"memchr",
|
||||||
|
"mime",
|
||||||
|
"spin",
|
||||||
|
"version_check",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "new_debug_unreachable"
|
||||||
|
version = "1.0.6"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "nom"
|
||||||
|
version = "8.0.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "df9761775871bdef83bee530e60050f7e54b1105350d6884eb0fb4f46c2f9405"
|
||||||
|
dependencies = [
|
||||||
|
"memchr",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "noop_proc_macro"
|
||||||
|
version = "0.3.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "0676bb32a98c1a483ce53e500a81ad9c3d5b3f7c920c28c24e9cb0980d0b5bc8"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "nu-ansi-term"
|
name = "nu-ansi-term"
|
||||||
version = "0.50.3"
|
version = "0.50.3"
|
||||||
@@ -2012,6 +2438,16 @@ dependencies = [
|
|||||||
"windows-sys 0.61.2",
|
"windows-sys 0.61.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "num-bigint"
|
||||||
|
version = "0.4.6"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9"
|
||||||
|
dependencies = [
|
||||||
|
"num-integer",
|
||||||
|
"num-traits",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "num-bigint-dig"
|
name = "num-bigint-dig"
|
||||||
version = "0.8.6"
|
version = "0.8.6"
|
||||||
@@ -2034,6 +2470,17 @@ version = "0.1.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9"
|
checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "num-derive"
|
||||||
|
version = "0.4.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "ed3955f1a9c7c0c15e092f9c887db08b1fc683305fdf6eb6684f22555355e202"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "num-integer"
|
name = "num-integer"
|
||||||
version = "0.1.46"
|
version = "0.1.46"
|
||||||
@@ -2054,6 +2501,17 @@ dependencies = [
|
|||||||
"num-traits",
|
"num-traits",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "num-rational"
|
||||||
|
version = "0.4.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824"
|
||||||
|
dependencies = [
|
||||||
|
"num-bigint",
|
||||||
|
"num-integer",
|
||||||
|
"num-traits",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "num-traits"
|
name = "num-traits"
|
||||||
version = "0.2.19"
|
version = "0.2.19"
|
||||||
@@ -2139,6 +2597,18 @@ dependencies = [
|
|||||||
"subtle",
|
"subtle",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "paste"
|
||||||
|
version = "1.0.15"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pastey"
|
||||||
|
version = "0.1.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "35fb2e5f958ec131621fdd531e9fc186ed768cbe395337403ae56c17a74c68ec"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pem-rfc7468"
|
name = "pem-rfc7468"
|
||||||
version = "0.7.0"
|
version = "0.7.0"
|
||||||
@@ -2203,6 +2673,19 @@ version = "0.3.32"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c"
|
checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "png"
|
||||||
|
version = "0.18.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "97baced388464909d42d89643fe4361939af9b7ce7a31ee32a168f832a70f2a0"
|
||||||
|
dependencies = [
|
||||||
|
"bitflags",
|
||||||
|
"crc32fast",
|
||||||
|
"fdeflate",
|
||||||
|
"flate2",
|
||||||
|
"miniz_oxide",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "portable-atomic"
|
name = "portable-atomic"
|
||||||
version = "1.13.0"
|
version = "1.13.0"
|
||||||
@@ -2242,6 +2725,49 @@ dependencies = [
|
|||||||
"unicode-ident",
|
"unicode-ident",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "profiling"
|
||||||
|
version = "1.0.17"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "3eb8486b569e12e2c32ad3e204dbaba5e4b5b216e9367044f25f1dba42341773"
|
||||||
|
dependencies = [
|
||||||
|
"profiling-procmacros",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "profiling-procmacros"
|
||||||
|
version = "1.0.17"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "52717f9a02b6965224f95ca2a81e2e0c5c43baacd28ca057577988930b6c3d5b"
|
||||||
|
dependencies = [
|
||||||
|
"quote",
|
||||||
|
"syn",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pxfm"
|
||||||
|
version = "0.1.27"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "7186d3822593aa4393561d186d1393b3923e9d6163d3fbfd6e825e3e6cf3e6a8"
|
||||||
|
dependencies = [
|
||||||
|
"num-traits",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "qoi"
|
||||||
|
version = "0.4.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "7f6d64c71eb498fe9eae14ce4ec935c555749aef511cca85b5568910d6e48001"
|
||||||
|
dependencies = [
|
||||||
|
"bytemuck",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "quick-error"
|
||||||
|
version = "2.0.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "quinn"
|
name = "quinn"
|
||||||
version = "0.11.9"
|
version = "0.11.9"
|
||||||
@@ -2372,6 +2898,76 @@ dependencies = [
|
|||||||
"getrandom 0.3.4",
|
"getrandom 0.3.4",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rav1e"
|
||||||
|
version = "0.8.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "43b6dd56e85d9483277cde964fd1bdb0428de4fec5ebba7540995639a21cb32b"
|
||||||
|
dependencies = [
|
||||||
|
"aligned-vec",
|
||||||
|
"arbitrary",
|
||||||
|
"arg_enum_proc_macro",
|
||||||
|
"arrayvec",
|
||||||
|
"av-scenechange",
|
||||||
|
"av1-grain",
|
||||||
|
"bitstream-io",
|
||||||
|
"built",
|
||||||
|
"cfg-if",
|
||||||
|
"interpolate_name",
|
||||||
|
"itertools",
|
||||||
|
"libc",
|
||||||
|
"libfuzzer-sys",
|
||||||
|
"log",
|
||||||
|
"maybe-rayon",
|
||||||
|
"new_debug_unreachable",
|
||||||
|
"noop_proc_macro",
|
||||||
|
"num-derive",
|
||||||
|
"num-traits",
|
||||||
|
"paste",
|
||||||
|
"profiling",
|
||||||
|
"rand 0.9.2",
|
||||||
|
"rand_chacha 0.9.0",
|
||||||
|
"simd_helpers",
|
||||||
|
"thiserror 2.0.17",
|
||||||
|
"v_frame",
|
||||||
|
"wasm-bindgen",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "ravif"
|
||||||
|
version = "0.12.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "ef69c1990ceef18a116855938e74793a5f7496ee907562bd0857b6ac734ab285"
|
||||||
|
dependencies = [
|
||||||
|
"avif-serialize",
|
||||||
|
"imgref",
|
||||||
|
"loop9",
|
||||||
|
"quick-error",
|
||||||
|
"rav1e",
|
||||||
|
"rayon",
|
||||||
|
"rgb",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rayon"
|
||||||
|
version = "1.11.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "368f01d005bf8fd9b1206fb6fa653e6c4a81ceb1466406b81792d87c5677a58f"
|
||||||
|
dependencies = [
|
||||||
|
"either",
|
||||||
|
"rayon-core",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rayon-core"
|
||||||
|
version = "1.13.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91"
|
||||||
|
dependencies = [
|
||||||
|
"crossbeam-deque",
|
||||||
|
"crossbeam-utils",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "redox_syscall"
|
name = "redox_syscall"
|
||||||
version = "0.5.18"
|
version = "0.5.18"
|
||||||
@@ -2478,6 +3074,12 @@ dependencies = [
|
|||||||
"zeroize",
|
"zeroize",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rgb"
|
||||||
|
version = "0.8.52"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "0c6a884d2998352bb4daf0183589aec883f16a6da1f4dde84d8e2e9a5409a1ce"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ring"
|
name = "ring"
|
||||||
version = "0.17.14"
|
version = "0.17.14"
|
||||||
@@ -2847,6 +3449,21 @@ dependencies = [
|
|||||||
"rand_core 0.6.4",
|
"rand_core 0.6.4",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "simd-adler32"
|
||||||
|
version = "0.3.8"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "e320a6c5ad31d271ad523dcf3ad13e2767ad8b1cb8f047f75a8aeaf8da139da2"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "simd_helpers"
|
||||||
|
version = "0.1.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "95890f873bec569a0362c235787f3aca6e1e887302ba4840839bcc6459c42da6"
|
||||||
|
dependencies = [
|
||||||
|
"quote",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "slab"
|
name = "slab"
|
||||||
version = "0.4.11"
|
version = "0.4.11"
|
||||||
@@ -3224,6 +3841,20 @@ dependencies = [
|
|||||||
"cfg-if",
|
"cfg-if",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tiff"
|
||||||
|
version = "0.10.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "af9605de7fee8d9551863fd692cce7637f548dbd9db9180fcc07ccc6d26c336f"
|
||||||
|
dependencies = [
|
||||||
|
"fax",
|
||||||
|
"flate2",
|
||||||
|
"half",
|
||||||
|
"quick-error",
|
||||||
|
"weezl",
|
||||||
|
"zune-jpeg 0.4.21",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "time"
|
name = "time"
|
||||||
version = "0.3.44"
|
version = "0.3.44"
|
||||||
@@ -3579,6 +4210,17 @@ dependencies = [
|
|||||||
"wasm-bindgen",
|
"wasm-bindgen",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "v_frame"
|
||||||
|
version = "0.3.9"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "666b7727c8875d6ab5db9533418d7c764233ac9c0cff1d469aec8fa127597be2"
|
||||||
|
dependencies = [
|
||||||
|
"aligned-vec",
|
||||||
|
"num-traits",
|
||||||
|
"wasm-bindgen",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "valuable"
|
name = "valuable"
|
||||||
version = "0.1.1"
|
version = "0.1.1"
|
||||||
@@ -3761,6 +4403,12 @@ dependencies = [
|
|||||||
"rustls-pki-types",
|
"rustls-pki-types",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "weezl"
|
||||||
|
version = "0.1.12"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "a28ac98ddc8b9274cb41bb4d9d4d5c425b6020c50c46f25559911905610b4a88"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "whoami"
|
name = "whoami"
|
||||||
version = "1.6.1"
|
version = "1.6.1"
|
||||||
@@ -4092,6 +4740,12 @@ version = "0.13.6"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "66fee0b777b0f5ac1c69bb06d361268faafa61cd4682ae064a171c16c433e9e4"
|
checksum = "66fee0b777b0f5ac1c69bb06d361268faafa61cd4682ae064a171c16c433e9e4"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "y4m"
|
||||||
|
version = "0.8.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "7a5a4b21e1a62b67a2970e6831bc091d7b87e119e7f9791aef9702e3bef04448"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "yoke"
|
name = "yoke"
|
||||||
version = "0.8.1"
|
version = "0.8.1"
|
||||||
@@ -4200,3 +4854,42 @@ name = "zmij"
|
|||||||
version = "1.0.10"
|
version = "1.0.10"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "30e0d8dffbae3d840f64bda38e28391faef673a7b5a6017840f2a106c8145868"
|
checksum = "30e0d8dffbae3d840f64bda38e28391faef673a7b5a6017840f2a106c8145868"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "zune-core"
|
||||||
|
version = "0.4.12"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "3f423a2c17029964870cfaabb1f13dfab7d092a62a29a89264f4d36990ca414a"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "zune-core"
|
||||||
|
version = "0.5.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "111f7d9820f05fd715df3144e254d6fc02ee4088b0644c0ffd0efc9e6d9d2773"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "zune-inflate"
|
||||||
|
version = "0.2.54"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "73ab332fe2f6680068f3582b16a24f90ad7096d5d39b974d1c0aff0125116f02"
|
||||||
|
dependencies = [
|
||||||
|
"simd-adler32",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "zune-jpeg"
|
||||||
|
version = "0.4.21"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "29ce2c8a9384ad323cf564b67da86e21d3cfdff87908bc1223ed5c99bc792713"
|
||||||
|
dependencies = [
|
||||||
|
"zune-core 0.4.12",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "zune-jpeg"
|
||||||
|
version = "0.5.8"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "e35aee689668bf9bd6f6f3a6c60bb29ba1244b3b43adfd50edd554a371da37d5"
|
||||||
|
dependencies = [
|
||||||
|
"zune-core 0.5.0",
|
||||||
|
]
|
||||||
|
|||||||
+3
-1
@@ -11,12 +11,14 @@ path = "src/main.rs"
|
|||||||
argon2 = "0.5"
|
argon2 = "0.5"
|
||||||
aws-config = "1.8.12"
|
aws-config = "1.8.12"
|
||||||
aws-sdk-s3 = "1.119.0"
|
aws-sdk-s3 = "1.119.0"
|
||||||
axum = "0.8.8"
|
axum = { version = "0.8.8", features = ["multipart"] }
|
||||||
axum-extra = { version = "0.12", features = ["cookie"] }
|
axum-extra = { version = "0.12", features = ["cookie"] }
|
||||||
|
blurhash = "0.2.3"
|
||||||
clap = { version = "4.5.54", features = ["derive", "env"] }
|
clap = { version = "4.5.54", features = ["derive", "env"] }
|
||||||
dashmap = "6.1.0"
|
dashmap = "6.1.0"
|
||||||
dotenvy = "0.15"
|
dotenvy = "0.15"
|
||||||
futures = "0.3.31"
|
futures = "0.3.31"
|
||||||
|
image = "0.25.9"
|
||||||
include_dir = "0.7.4"
|
include_dir = "0.7.4"
|
||||||
mime_guess = "2.0.5"
|
mime_guess = "2.0.5"
|
||||||
moka = { version = "0.12.12", features = ["future"] }
|
moka = { version = "0.12.12", features = ["future"] }
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
-- Project media table for carousel support
|
||||||
|
-- Each project can have multiple images/videos with ordering
|
||||||
|
|
||||||
|
CREATE TYPE media_type AS ENUM ('image', 'video');
|
||||||
|
|
||||||
|
CREATE TABLE project_media (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
project_id UUID NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
|
||||||
|
display_order INT NOT NULL DEFAULT 0,
|
||||||
|
media_type media_type NOT NULL,
|
||||||
|
original_filename TEXT NOT NULL,
|
||||||
|
r2_base_path TEXT NOT NULL,
|
||||||
|
variants JSONB NOT NULL,
|
||||||
|
width INT,
|
||||||
|
height INT,
|
||||||
|
size_bytes BIGINT NOT NULL,
|
||||||
|
blurhash TEXT,
|
||||||
|
metadata JSONB,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
|
||||||
|
UNIQUE (project_id, display_order)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX idx_project_media_project_id ON project_media(project_id);
|
||||||
+4
-2
@@ -1,8 +1,9 @@
|
|||||||
|
use axum::extract::DefaultBodyLimit;
|
||||||
use std::collections::HashSet;
|
use std::collections::HashSet;
|
||||||
use std::net::SocketAddr;
|
use std::net::SocketAddr;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
use tower_http::{cors::CorsLayer, limit::RequestBodyLimitLayer};
|
use tower_http::cors::CorsLayer;
|
||||||
|
|
||||||
use crate::cache::{IsrCache, IsrCacheConfig};
|
use crate::cache::{IsrCache, IsrCacheConfig};
|
||||||
use crate::config::ListenAddr;
|
use crate::config::ListenAddr;
|
||||||
@@ -218,7 +219,8 @@ pub async fn run(
|
|||||||
router
|
router
|
||||||
.layer(RequestIdLayer::new(trust_request_id))
|
.layer(RequestIdLayer::new(trust_request_id))
|
||||||
.layer(CorsLayer::permissive())
|
.layer(CorsLayer::permissive())
|
||||||
.layer(RequestBodyLimitLayer::new(1_048_576))
|
// 50 MiB limit for media uploads
|
||||||
|
.layer(DefaultBodyLimit::max(50 * 1024 * 1024))
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut tasks = Vec::new();
|
let mut tasks = Vec::new();
|
||||||
|
|||||||
+450
@@ -0,0 +1,450 @@
|
|||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use sqlx::PgPool;
|
||||||
|
use time::OffsetDateTime;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
/// Media type enum matching PostgreSQL enum
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, sqlx::Type, Serialize, Deserialize)]
|
||||||
|
#[sqlx(type_name = "media_type", rename_all = "lowercase")]
|
||||||
|
#[serde(rename_all = "lowercase")]
|
||||||
|
pub enum MediaType {
|
||||||
|
Image,
|
||||||
|
Video,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Database model for project media
|
||||||
|
#[derive(Debug, Clone, sqlx::FromRow)]
|
||||||
|
pub struct DbProjectMedia {
|
||||||
|
pub id: Uuid,
|
||||||
|
pub project_id: Uuid,
|
||||||
|
pub display_order: i32,
|
||||||
|
pub media_type: MediaType,
|
||||||
|
pub original_filename: String,
|
||||||
|
pub r2_base_path: String,
|
||||||
|
pub variants: serde_json::Value,
|
||||||
|
pub width: Option<i32>,
|
||||||
|
pub height: Option<i32>,
|
||||||
|
pub size_bytes: i64,
|
||||||
|
pub blurhash: Option<String>,
|
||||||
|
pub metadata: Option<serde_json::Value>,
|
||||||
|
pub created_at: OffsetDateTime,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Variant info for images
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct ImageVariant {
|
||||||
|
pub key: String,
|
||||||
|
pub width: i32,
|
||||||
|
pub height: i32,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub mime: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Variant info for video poster
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct VideoOriginal {
|
||||||
|
pub key: String,
|
||||||
|
pub mime: String,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub duration: Option<f64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// API response for media variant with full URL
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct ApiMediaVariant {
|
||||||
|
pub url: String,
|
||||||
|
pub width: i32,
|
||||||
|
pub height: i32,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// API response for video original
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct ApiVideoOriginal {
|
||||||
|
pub url: String,
|
||||||
|
pub mime: String,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub duration: Option<f64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// API response for media variants
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct ApiMediaVariants {
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub thumb: Option<ApiMediaVariant>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub medium: Option<ApiMediaVariant>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub full: Option<ApiMediaVariant>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub original: Option<ApiMediaVariant>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub poster: Option<ApiMediaVariant>,
|
||||||
|
// For video original (different structure)
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub video: Option<ApiVideoOriginal>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Optional metadata stored with media
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct MediaMetadata {
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub focal_point: Option<FocalPoint>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub alt_text: Option<String>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub duration: Option<f64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct FocalPoint {
|
||||||
|
pub x: f64,
|
||||||
|
pub y: f64,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// API response type for project media
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct ApiProjectMedia {
|
||||||
|
pub id: String,
|
||||||
|
pub display_order: i32,
|
||||||
|
pub media_type: MediaType,
|
||||||
|
pub variants: ApiMediaVariants,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub blurhash: Option<String>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub metadata: Option<MediaMetadata>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Base URL for R2 media storage
|
||||||
|
const R2_BASE_URL: &str = "https://media.xevion.dev";
|
||||||
|
|
||||||
|
impl DbProjectMedia {
|
||||||
|
/// Convert database media to API response format
|
||||||
|
pub fn to_api_media(&self) -> ApiProjectMedia {
|
||||||
|
let variants = self.build_api_variants();
|
||||||
|
let metadata = self
|
||||||
|
.metadata
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|m| serde_json::from_value(m.clone()).ok());
|
||||||
|
|
||||||
|
ApiProjectMedia {
|
||||||
|
id: self.id.to_string(),
|
||||||
|
display_order: self.display_order,
|
||||||
|
media_type: self.media_type,
|
||||||
|
variants,
|
||||||
|
blurhash: self.blurhash.clone(),
|
||||||
|
metadata,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_api_variants(&self) -> ApiMediaVariants {
|
||||||
|
let base_url = format!(
|
||||||
|
"{}/{}",
|
||||||
|
R2_BASE_URL,
|
||||||
|
self.r2_base_path.trim_end_matches('/')
|
||||||
|
);
|
||||||
|
|
||||||
|
let mut variants = ApiMediaVariants {
|
||||||
|
thumb: None,
|
||||||
|
medium: None,
|
||||||
|
full: None,
|
||||||
|
original: None,
|
||||||
|
poster: None,
|
||||||
|
video: None,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Parse the JSONB variants
|
||||||
|
if let Some(obj) = self.variants.as_object() {
|
||||||
|
// Handle image variants
|
||||||
|
if let Some(thumb) = obj.get("thumb") {
|
||||||
|
if let Ok(v) = serde_json::from_value::<ImageVariant>(thumb.clone()) {
|
||||||
|
variants.thumb = Some(ApiMediaVariant {
|
||||||
|
url: format!("{}/{}", base_url, v.key),
|
||||||
|
width: v.width,
|
||||||
|
height: v.height,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(medium) = obj.get("medium") {
|
||||||
|
if let Ok(v) = serde_json::from_value::<ImageVariant>(medium.clone()) {
|
||||||
|
variants.medium = Some(ApiMediaVariant {
|
||||||
|
url: format!("{}/{}", base_url, v.key),
|
||||||
|
width: v.width,
|
||||||
|
height: v.height,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(full) = obj.get("full") {
|
||||||
|
if let Ok(v) = serde_json::from_value::<ImageVariant>(full.clone()) {
|
||||||
|
variants.full = Some(ApiMediaVariant {
|
||||||
|
url: format!("{}/{}", base_url, v.key),
|
||||||
|
width: v.width,
|
||||||
|
height: v.height,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle original - could be image or video
|
||||||
|
if let Some(original) = obj.get("original") {
|
||||||
|
if self.media_type == MediaType::Video {
|
||||||
|
// Video original has different structure
|
||||||
|
if let Ok(v) = serde_json::from_value::<VideoOriginal>(original.clone()) {
|
||||||
|
variants.video = Some(ApiVideoOriginal {
|
||||||
|
url: format!("{}/{}", base_url, v.key),
|
||||||
|
mime: v.mime,
|
||||||
|
duration: v.duration,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Image original
|
||||||
|
if let Ok(v) = serde_json::from_value::<ImageVariant>(original.clone()) {
|
||||||
|
variants.original = Some(ApiMediaVariant {
|
||||||
|
url: format!("{}/{}", base_url, v.key),
|
||||||
|
width: v.width,
|
||||||
|
height: v.height,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle video poster
|
||||||
|
if let Some(poster) = obj.get("poster") {
|
||||||
|
if let Ok(v) = serde_json::from_value::<ImageVariant>(poster.clone()) {
|
||||||
|
variants.poster = Some(ApiMediaVariant {
|
||||||
|
url: format!("{}/{}", base_url, v.key),
|
||||||
|
width: v.width,
|
||||||
|
height: v.height,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
variants
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Database query functions
|
||||||
|
|
||||||
|
/// Get all media for a project, ordered by display_order
|
||||||
|
pub async fn get_media_for_project(
|
||||||
|
pool: &PgPool,
|
||||||
|
project_id: Uuid,
|
||||||
|
) -> Result<Vec<DbProjectMedia>, sqlx::Error> {
|
||||||
|
sqlx::query_as!(
|
||||||
|
DbProjectMedia,
|
||||||
|
r#"
|
||||||
|
SELECT
|
||||||
|
id,
|
||||||
|
project_id,
|
||||||
|
display_order,
|
||||||
|
media_type as "media_type: MediaType",
|
||||||
|
original_filename,
|
||||||
|
r2_base_path,
|
||||||
|
variants,
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
size_bytes,
|
||||||
|
blurhash,
|
||||||
|
metadata,
|
||||||
|
created_at
|
||||||
|
FROM project_media
|
||||||
|
WHERE project_id = $1
|
||||||
|
ORDER BY display_order ASC
|
||||||
|
"#,
|
||||||
|
project_id
|
||||||
|
)
|
||||||
|
.fetch_all(pool)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get single media item by ID
|
||||||
|
pub async fn get_media_by_id(
|
||||||
|
pool: &PgPool,
|
||||||
|
id: Uuid,
|
||||||
|
) -> Result<Option<DbProjectMedia>, sqlx::Error> {
|
||||||
|
sqlx::query_as!(
|
||||||
|
DbProjectMedia,
|
||||||
|
r#"
|
||||||
|
SELECT
|
||||||
|
id,
|
||||||
|
project_id,
|
||||||
|
display_order,
|
||||||
|
media_type as "media_type: MediaType",
|
||||||
|
original_filename,
|
||||||
|
r2_base_path,
|
||||||
|
variants,
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
size_bytes,
|
||||||
|
blurhash,
|
||||||
|
metadata,
|
||||||
|
created_at
|
||||||
|
FROM project_media
|
||||||
|
WHERE id = $1
|
||||||
|
"#,
|
||||||
|
id
|
||||||
|
)
|
||||||
|
.fetch_optional(pool)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get the next display order for a project's media
|
||||||
|
pub async fn get_next_display_order(pool: &PgPool, project_id: Uuid) -> Result<i32, sqlx::Error> {
|
||||||
|
let result = sqlx::query!(
|
||||||
|
r#"
|
||||||
|
SELECT COALESCE(MAX(display_order) + 1, 0) as "next_order!"
|
||||||
|
FROM project_media
|
||||||
|
WHERE project_id = $1
|
||||||
|
"#,
|
||||||
|
project_id
|
||||||
|
)
|
||||||
|
.fetch_one(pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(result.next_order)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create a new media record
|
||||||
|
pub async fn create_media(
|
||||||
|
pool: &PgPool,
|
||||||
|
project_id: Uuid,
|
||||||
|
media_type: MediaType,
|
||||||
|
original_filename: &str,
|
||||||
|
r2_base_path: &str,
|
||||||
|
variants: serde_json::Value,
|
||||||
|
width: Option<i32>,
|
||||||
|
height: Option<i32>,
|
||||||
|
size_bytes: i64,
|
||||||
|
blurhash: Option<&str>,
|
||||||
|
metadata: Option<serde_json::Value>,
|
||||||
|
) -> Result<DbProjectMedia, sqlx::Error> {
|
||||||
|
let display_order = get_next_display_order(pool, project_id).await?;
|
||||||
|
|
||||||
|
sqlx::query_as!(
|
||||||
|
DbProjectMedia,
|
||||||
|
r#"
|
||||||
|
INSERT INTO project_media (
|
||||||
|
project_id, display_order, media_type, original_filename,
|
||||||
|
r2_base_path, variants, width, height, size_bytes, blurhash, metadata
|
||||||
|
)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
|
||||||
|
RETURNING
|
||||||
|
id,
|
||||||
|
project_id,
|
||||||
|
display_order,
|
||||||
|
media_type as "media_type: MediaType",
|
||||||
|
original_filename,
|
||||||
|
r2_base_path,
|
||||||
|
variants,
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
size_bytes,
|
||||||
|
blurhash,
|
||||||
|
metadata,
|
||||||
|
created_at
|
||||||
|
"#,
|
||||||
|
project_id,
|
||||||
|
display_order,
|
||||||
|
media_type as MediaType,
|
||||||
|
original_filename,
|
||||||
|
r2_base_path,
|
||||||
|
variants,
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
size_bytes,
|
||||||
|
blurhash,
|
||||||
|
metadata
|
||||||
|
)
|
||||||
|
.fetch_one(pool)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Delete a media record
|
||||||
|
pub async fn delete_media(pool: &PgPool, id: Uuid) -> Result<Option<DbProjectMedia>, sqlx::Error> {
|
||||||
|
// First get the media to return it
|
||||||
|
let media = get_media_by_id(pool, id).await?;
|
||||||
|
|
||||||
|
if media.is_some() {
|
||||||
|
sqlx::query!("DELETE FROM project_media WHERE id = $1", id)
|
||||||
|
.execute(pool)
|
||||||
|
.await?;
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(media)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reorder media for a project
|
||||||
|
/// Takes a list of media IDs in desired order and updates display_order accordingly
|
||||||
|
pub async fn reorder_media(
|
||||||
|
pool: &PgPool,
|
||||||
|
project_id: Uuid,
|
||||||
|
media_ids: &[Uuid],
|
||||||
|
) -> Result<(), sqlx::Error> {
|
||||||
|
// Use a transaction to ensure atomicity
|
||||||
|
let mut tx = pool.begin().await?;
|
||||||
|
|
||||||
|
// First, set all to negative values to avoid unique constraint conflicts
|
||||||
|
for (i, id) in media_ids.iter().enumerate() {
|
||||||
|
sqlx::query!(
|
||||||
|
"UPDATE project_media SET display_order = $1 WHERE id = $2 AND project_id = $3",
|
||||||
|
-(i as i32 + 1),
|
||||||
|
id,
|
||||||
|
project_id
|
||||||
|
)
|
||||||
|
.execute(&mut *tx)
|
||||||
|
.await?;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Then set to final positive values
|
||||||
|
for (i, id) in media_ids.iter().enumerate() {
|
||||||
|
sqlx::query!(
|
||||||
|
"UPDATE project_media SET display_order = $1 WHERE id = $2 AND project_id = $3",
|
||||||
|
i as i32,
|
||||||
|
id,
|
||||||
|
project_id
|
||||||
|
)
|
||||||
|
.execute(&mut *tx)
|
||||||
|
.await?;
|
||||||
|
}
|
||||||
|
|
||||||
|
tx.commit().await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Update media metadata (focal point, alt text, etc.)
|
||||||
|
pub async fn update_media_metadata(
|
||||||
|
pool: &PgPool,
|
||||||
|
id: Uuid,
|
||||||
|
metadata: serde_json::Value,
|
||||||
|
) -> Result<DbProjectMedia, sqlx::Error> {
|
||||||
|
sqlx::query_as!(
|
||||||
|
DbProjectMedia,
|
||||||
|
r#"
|
||||||
|
UPDATE project_media
|
||||||
|
SET metadata = $2
|
||||||
|
WHERE id = $1
|
||||||
|
RETURNING
|
||||||
|
id,
|
||||||
|
project_id,
|
||||||
|
display_order,
|
||||||
|
media_type as "media_type: MediaType",
|
||||||
|
original_filename,
|
||||||
|
r2_base_path,
|
||||||
|
variants,
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
size_bytes,
|
||||||
|
blurhash,
|
||||||
|
metadata,
|
||||||
|
created_at
|
||||||
|
"#,
|
||||||
|
id,
|
||||||
|
metadata
|
||||||
|
)
|
||||||
|
.fetch_one(pool)
|
||||||
|
.await
|
||||||
|
}
|
||||||
@@ -1,8 +1,10 @@
|
|||||||
|
pub mod media;
|
||||||
pub mod projects;
|
pub mod projects;
|
||||||
pub mod settings;
|
pub mod settings;
|
||||||
pub mod tags;
|
pub mod tags;
|
||||||
|
|
||||||
// Re-export all types and functions
|
// Re-export all types and functions
|
||||||
|
pub use media::*;
|
||||||
pub use projects::*;
|
pub use projects::*;
|
||||||
pub use settings::*;
|
pub use settings::*;
|
||||||
pub use tags::*;
|
pub use tags::*;
|
||||||
|
|||||||
+21
-10
@@ -5,7 +5,9 @@ use time::{OffsetDateTime, format_description::well_known::Rfc3339};
|
|||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
use super::{
|
use super::{
|
||||||
ProjectStatus, slugify,
|
ProjectStatus,
|
||||||
|
media::{ApiProjectMedia, DbProjectMedia, get_media_for_project},
|
||||||
|
slugify,
|
||||||
tags::{ApiTag, DbTag, get_tags_for_project},
|
tags::{ApiTag, DbTag, get_tags_for_project},
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -49,6 +51,7 @@ pub struct ApiAdminProject {
|
|||||||
#[serde(flatten)]
|
#[serde(flatten)]
|
||||||
pub project: ApiProject,
|
pub project: ApiProject,
|
||||||
pub tags: Vec<ApiTag>,
|
pub tags: Vec<ApiTag>,
|
||||||
|
pub media: Vec<ApiProjectMedia>,
|
||||||
pub status: String,
|
pub status: String,
|
||||||
pub description: String,
|
pub description: String,
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
@@ -87,7 +90,11 @@ impl DbProject {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn to_api_admin_project(&self, tags: Vec<DbTag>) -> ApiAdminProject {
|
pub fn to_api_admin_project(
|
||||||
|
&self,
|
||||||
|
tags: Vec<DbTag>,
|
||||||
|
media: Vec<DbProjectMedia>,
|
||||||
|
) -> ApiAdminProject {
|
||||||
let last_activity = self
|
let last_activity = self
|
||||||
.last_github_activity
|
.last_github_activity
|
||||||
.unwrap_or(self.created_at)
|
.unwrap_or(self.created_at)
|
||||||
@@ -97,6 +104,7 @@ impl DbProject {
|
|||||||
ApiAdminProject {
|
ApiAdminProject {
|
||||||
project: self.to_api_project(),
|
project: self.to_api_project(),
|
||||||
tags: tags.into_iter().map(|t| t.to_api_tag()).collect(),
|
tags: tags.into_iter().map(|t| t.to_api_tag()).collect(),
|
||||||
|
media: media.into_iter().map(|m| m.to_api_media()).collect(),
|
||||||
status: format!("{:?}", self.status).to_lowercase(),
|
status: format!("{:?}", self.status).to_lowercase(),
|
||||||
description: self.description.clone(),
|
description: self.description.clone(),
|
||||||
github_repo: self.github_repo.clone(),
|
github_repo: self.github_repo.clone(),
|
||||||
@@ -173,13 +181,14 @@ pub async fn get_public_projects(pool: &PgPool) -> Result<Vec<DbProject>, sqlx::
|
|||||||
|
|
||||||
pub async fn get_public_projects_with_tags(
|
pub async fn get_public_projects_with_tags(
|
||||||
pool: &PgPool,
|
pool: &PgPool,
|
||||||
) -> Result<Vec<(DbProject, Vec<DbTag>)>, sqlx::Error> {
|
) -> Result<Vec<(DbProject, Vec<DbTag>, Vec<DbProjectMedia>)>, sqlx::Error> {
|
||||||
let projects = get_public_projects(pool).await?;
|
let projects = get_public_projects(pool).await?;
|
||||||
|
|
||||||
let mut result = Vec::new();
|
let mut result = Vec::new();
|
||||||
for project in projects {
|
for project in projects {
|
||||||
let tags = get_tags_for_project(pool, project.id).await?;
|
let tags = get_tags_for_project(pool, project.id).await?;
|
||||||
result.push((project, tags));
|
let media = get_media_for_project(pool, project.id).await?;
|
||||||
|
result.push((project, tags, media));
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(result)
|
Ok(result)
|
||||||
@@ -210,16 +219,17 @@ pub async fn get_all_projects_admin(pool: &PgPool) -> Result<Vec<DbProject>, sql
|
|||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get all projects with tags (admin view)
|
/// Get all projects with tags and media (admin view)
|
||||||
pub async fn get_all_projects_with_tags_admin(
|
pub async fn get_all_projects_with_tags_admin(
|
||||||
pool: &PgPool,
|
pool: &PgPool,
|
||||||
) -> Result<Vec<(DbProject, Vec<DbTag>)>, sqlx::Error> {
|
) -> Result<Vec<(DbProject, Vec<DbTag>, Vec<DbProjectMedia>)>, sqlx::Error> {
|
||||||
let projects = get_all_projects_admin(pool).await?;
|
let projects = get_all_projects_admin(pool).await?;
|
||||||
|
|
||||||
let mut result = Vec::new();
|
let mut result = Vec::new();
|
||||||
for project in projects {
|
for project in projects {
|
||||||
let tags = get_tags_for_project(pool, project.id).await?;
|
let tags = get_tags_for_project(pool, project.id).await?;
|
||||||
result.push((project, tags));
|
let media = get_media_for_project(pool, project.id).await?;
|
||||||
|
result.push((project, tags, media));
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(result)
|
Ok(result)
|
||||||
@@ -252,17 +262,18 @@ pub async fn get_project_by_id(pool: &PgPool, id: Uuid) -> Result<Option<DbProje
|
|||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get single project by ID with tags
|
/// Get single project by ID with tags and media
|
||||||
pub async fn get_project_by_id_with_tags(
|
pub async fn get_project_by_id_with_tags(
|
||||||
pool: &PgPool,
|
pool: &PgPool,
|
||||||
id: Uuid,
|
id: Uuid,
|
||||||
) -> Result<Option<(DbProject, Vec<DbTag>)>, sqlx::Error> {
|
) -> Result<Option<(DbProject, Vec<DbTag>, Vec<DbProjectMedia>)>, sqlx::Error> {
|
||||||
let project = get_project_by_id(pool, id).await?;
|
let project = get_project_by_id(pool, id).await?;
|
||||||
|
|
||||||
match project {
|
match project {
|
||||||
Some(p) => {
|
Some(p) => {
|
||||||
let tags = get_tags_for_project(pool, p.id).await?;
|
let tags = get_tags_for_project(pool, p.id).await?;
|
||||||
Ok(Some((p, tags)))
|
let media = get_media_for_project(pool, p.id).await?;
|
||||||
|
Ok(Some((p, tags, media)))
|
||||||
}
|
}
|
||||||
None => Ok(None),
|
None => Ok(None),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,709 @@
|
|||||||
|
use axum::{
|
||||||
|
Json,
|
||||||
|
extract::{Multipart, State},
|
||||||
|
http::StatusCode,
|
||||||
|
response::IntoResponse,
|
||||||
|
};
|
||||||
|
use std::sync::Arc;
|
||||||
|
use ulid::Ulid;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
use crate::{auth, db, media_processing, r2::R2Client, state::AppState};
|
||||||
|
|
||||||
|
/// Request type for reordering media
|
||||||
|
#[derive(Debug, serde::Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct ReorderMediaRequest {
|
||||||
|
/// Media IDs in desired order
|
||||||
|
pub media_ids: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Upload media for a project (requires authentication)
|
||||||
|
///
|
||||||
|
/// Accepts multipart/form-data with a single file field.
|
||||||
|
/// Images are processed into variants (thumb, medium, full) and uploaded to R2.
|
||||||
|
pub async fn upload_media_handler(
|
||||||
|
State(state): State<Arc<AppState>>,
|
||||||
|
axum::extract::Path(project_id): axum::extract::Path<String>,
|
||||||
|
jar: axum_extra::extract::CookieJar,
|
||||||
|
mut multipart: Multipart,
|
||||||
|
) -> impl IntoResponse {
|
||||||
|
// Check auth
|
||||||
|
if auth::check_session(&state, &jar).is_none() {
|
||||||
|
return auth::require_auth_response().into_response();
|
||||||
|
}
|
||||||
|
|
||||||
|
let project_id = match Uuid::parse_str(&project_id) {
|
||||||
|
Ok(id) => id,
|
||||||
|
Err(_) => {
|
||||||
|
return (
|
||||||
|
StatusCode::BAD_REQUEST,
|
||||||
|
Json(serde_json::json!({
|
||||||
|
"error": "Invalid project ID",
|
||||||
|
"message": "Project ID must be a valid UUID"
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
.into_response();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Verify project exists
|
||||||
|
match db::get_project_by_id(&state.pool, project_id).await {
|
||||||
|
Ok(None) => {
|
||||||
|
return (
|
||||||
|
StatusCode::NOT_FOUND,
|
||||||
|
Json(serde_json::json!({
|
||||||
|
"error": "Not found",
|
||||||
|
"message": "Project not found"
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
.into_response();
|
||||||
|
}
|
||||||
|
Err(err) => {
|
||||||
|
tracing::error!(error = %err, "Failed to check project existence");
|
||||||
|
return (
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
Json(serde_json::json!({
|
||||||
|
"error": "Internal server error",
|
||||||
|
"message": "Failed to verify project"
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
.into_response();
|
||||||
|
}
|
||||||
|
Ok(Some(_)) => {}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get R2 client
|
||||||
|
let r2 = match R2Client::get().await {
|
||||||
|
Some(r2) => r2,
|
||||||
|
None => {
|
||||||
|
tracing::error!("R2 client not available");
|
||||||
|
return (
|
||||||
|
StatusCode::SERVICE_UNAVAILABLE,
|
||||||
|
Json(serde_json::json!({
|
||||||
|
"error": "Service unavailable",
|
||||||
|
"message": "Media storage is not configured"
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
.into_response();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Extract file from multipart
|
||||||
|
let (filename, content_type, data) = match extract_file(&mut multipart).await {
|
||||||
|
Ok(Some(file)) => file,
|
||||||
|
Ok(None) => {
|
||||||
|
return (
|
||||||
|
StatusCode::BAD_REQUEST,
|
||||||
|
Json(serde_json::json!({
|
||||||
|
"error": "Bad request",
|
||||||
|
"message": "No file provided"
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
.into_response();
|
||||||
|
}
|
||||||
|
Err(err) => {
|
||||||
|
tracing::error!(error = %err, "Failed to extract file from multipart");
|
||||||
|
return (
|
||||||
|
StatusCode::BAD_REQUEST,
|
||||||
|
Json(serde_json::json!({
|
||||||
|
"error": "Bad request",
|
||||||
|
"message": format!("Failed to read upload: {err}")
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
.into_response();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Determine media type and process
|
||||||
|
let is_video = media_processing::is_supported_video(&content_type);
|
||||||
|
let is_image = media_processing::is_supported_image(&content_type);
|
||||||
|
|
||||||
|
if !is_video && !is_image {
|
||||||
|
return (
|
||||||
|
StatusCode::BAD_REQUEST,
|
||||||
|
Json(serde_json::json!({
|
||||||
|
"error": "Unsupported format",
|
||||||
|
"message": format!("Content type '{}' is not supported. Supported: JPEG, PNG, GIF, WebP, AVIF, MP4, WebM", content_type)
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
.into_response();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate unique asset ID
|
||||||
|
let asset_id = Ulid::new();
|
||||||
|
let r2_base_path = format!("projects/{project_id}/{asset_id}");
|
||||||
|
|
||||||
|
if is_image {
|
||||||
|
// Process image
|
||||||
|
let processed = match media_processing::process_image(&data, &filename) {
|
||||||
|
Ok(p) => p,
|
||||||
|
Err(err) => {
|
||||||
|
tracing::error!(error = %err, filename = %filename, "Failed to process image");
|
||||||
|
return (
|
||||||
|
StatusCode::BAD_REQUEST,
|
||||||
|
Json(serde_json::json!({
|
||||||
|
"error": "Processing failed",
|
||||||
|
"message": format!("Failed to process image: {err}")
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
.into_response();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Upload all variants to R2
|
||||||
|
if let Err(err) = upload_image_variants(&r2, &r2_base_path, &processed).await {
|
||||||
|
tracing::error!(error = %err, "Failed to upload image variants to R2");
|
||||||
|
return (
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
Json(serde_json::json!({
|
||||||
|
"error": "Upload failed",
|
||||||
|
"message": "Failed to upload media to storage"
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
.into_response();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build variants JSON
|
||||||
|
let original_ext = filename.rsplit('.').next().unwrap_or("jpg");
|
||||||
|
let variants = serde_json::json!({
|
||||||
|
"thumb": {
|
||||||
|
"key": "thumb.webp",
|
||||||
|
"width": processed.thumb.width,
|
||||||
|
"height": processed.thumb.height
|
||||||
|
},
|
||||||
|
"medium": {
|
||||||
|
"key": "medium.webp",
|
||||||
|
"width": processed.medium.width,
|
||||||
|
"height": processed.medium.height
|
||||||
|
},
|
||||||
|
"full": {
|
||||||
|
"key": "full.webp",
|
||||||
|
"width": processed.full.width,
|
||||||
|
"height": processed.full.height
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"key": format!("original.{original_ext}"),
|
||||||
|
"width": processed.original.width,
|
||||||
|
"height": processed.original.height,
|
||||||
|
"mime": processed.original.mime
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create database record
|
||||||
|
match db::create_media(
|
||||||
|
&state.pool,
|
||||||
|
project_id,
|
||||||
|
db::MediaType::Image,
|
||||||
|
&filename,
|
||||||
|
&r2_base_path,
|
||||||
|
variants,
|
||||||
|
Some(processed.original.width as i32),
|
||||||
|
Some(processed.original.height as i32),
|
||||||
|
data.len() as i64,
|
||||||
|
Some(&processed.blurhash),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(media) => {
|
||||||
|
tracing::info!(
|
||||||
|
media_id = %media.id,
|
||||||
|
project_id = %project_id,
|
||||||
|
filename = %filename,
|
||||||
|
"Image uploaded successfully"
|
||||||
|
);
|
||||||
|
|
||||||
|
// Invalidate cache
|
||||||
|
state.isr_cache.invalidate("/").await;
|
||||||
|
|
||||||
|
(StatusCode::CREATED, Json(media.to_api_media())).into_response()
|
||||||
|
}
|
||||||
|
Err(err) => {
|
||||||
|
tracing::error!(error = %err, "Failed to create media record");
|
||||||
|
// TODO: Clean up R2 files on DB failure
|
||||||
|
(
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
Json(serde_json::json!({
|
||||||
|
"error": "Database error",
|
||||||
|
"message": "Failed to save media record"
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
.into_response()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Video upload - basic support (no transcoding, ffmpeg poster extraction optional)
|
||||||
|
let original_ext = match content_type.as_str() {
|
||||||
|
"video/mp4" => "mp4",
|
||||||
|
"video/webm" => "webm",
|
||||||
|
"video/quicktime" => "mov",
|
||||||
|
_ => "mp4",
|
||||||
|
};
|
||||||
|
|
||||||
|
// Upload original video
|
||||||
|
let video_key = format!("{r2_base_path}/original.{original_ext}");
|
||||||
|
if let Err(err) = r2.put_object(&video_key, data.clone(), &content_type).await {
|
||||||
|
tracing::error!(error = %err, "Failed to upload video to R2");
|
||||||
|
return (
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
Json(serde_json::json!({
|
||||||
|
"error": "Upload failed",
|
||||||
|
"message": "Failed to upload video to storage"
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
.into_response();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build variants JSON (video only has original for now)
|
||||||
|
let variants = serde_json::json!({
|
||||||
|
"original": {
|
||||||
|
"key": format!("original.{original_ext}"),
|
||||||
|
"mime": content_type
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create database record
|
||||||
|
match db::create_media(
|
||||||
|
&state.pool,
|
||||||
|
project_id,
|
||||||
|
db::MediaType::Video,
|
||||||
|
&filename,
|
||||||
|
&r2_base_path,
|
||||||
|
variants,
|
||||||
|
None, // Video dimensions would require ffprobe
|
||||||
|
None,
|
||||||
|
data.len() as i64,
|
||||||
|
None, // No blurhash without poster
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(media) => {
|
||||||
|
tracing::info!(
|
||||||
|
media_id = %media.id,
|
||||||
|
project_id = %project_id,
|
||||||
|
filename = %filename,
|
||||||
|
"Video uploaded successfully"
|
||||||
|
);
|
||||||
|
|
||||||
|
// Invalidate cache
|
||||||
|
state.isr_cache.invalidate("/").await;
|
||||||
|
|
||||||
|
(StatusCode::CREATED, Json(media.to_api_media())).into_response()
|
||||||
|
}
|
||||||
|
Err(err) => {
|
||||||
|
tracing::error!(error = %err, "Failed to create media record");
|
||||||
|
(
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
Json(serde_json::json!({
|
||||||
|
"error": "Database error",
|
||||||
|
"message": "Failed to save media record"
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
.into_response()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Extract file from multipart form data
|
||||||
|
async fn extract_file(
|
||||||
|
multipart: &mut Multipart,
|
||||||
|
) -> Result<Option<(String, String, Vec<u8>)>, String> {
|
||||||
|
while let Some(field) = multipart
|
||||||
|
.next_field()
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("Failed to get field: {e}"))?
|
||||||
|
{
|
||||||
|
let name = field.name().unwrap_or("").to_string();
|
||||||
|
|
||||||
|
// Accept 'file' or 'media' field names
|
||||||
|
if name == "file" || name == "media" {
|
||||||
|
let filename = field.file_name().unwrap_or("upload").to_string();
|
||||||
|
|
||||||
|
let content_type = field
|
||||||
|
.content_type()
|
||||||
|
.unwrap_or("application/octet-stream")
|
||||||
|
.to_string();
|
||||||
|
|
||||||
|
let data = field
|
||||||
|
.bytes()
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("Failed to read file data: {e}"))?
|
||||||
|
.to_vec();
|
||||||
|
|
||||||
|
return Ok(Some((filename, content_type, data)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(None)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Upload all image variants to R2
|
||||||
|
async fn upload_image_variants(
|
||||||
|
r2: &R2Client,
|
||||||
|
base_path: &str,
|
||||||
|
processed: &media_processing::ProcessedImage,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
// Upload thumb
|
||||||
|
r2.put_object(
|
||||||
|
&format!("{base_path}/thumb.webp"),
|
||||||
|
processed.thumb.data.clone(),
|
||||||
|
"image/webp",
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
// Upload medium
|
||||||
|
r2.put_object(
|
||||||
|
&format!("{base_path}/medium.webp"),
|
||||||
|
processed.medium.data.clone(),
|
||||||
|
"image/webp",
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
// Upload full
|
||||||
|
r2.put_object(
|
||||||
|
&format!("{base_path}/full.webp"),
|
||||||
|
processed.full.data.clone(),
|
||||||
|
"image/webp",
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
// Upload original (preserve format)
|
||||||
|
let original_ext = match processed.original.mime.as_str() {
|
||||||
|
"image/jpeg" => "jpg",
|
||||||
|
"image/png" => "png",
|
||||||
|
"image/gif" => "gif",
|
||||||
|
"image/webp" => "webp",
|
||||||
|
"image/avif" => "avif",
|
||||||
|
_ => "jpg",
|
||||||
|
};
|
||||||
|
|
||||||
|
r2.put_object(
|
||||||
|
&format!("{base_path}/original.{original_ext}"),
|
||||||
|
processed.original.data.clone(),
|
||||||
|
&processed.original.mime,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get all media for a project
|
||||||
|
pub async fn get_project_media_handler(
|
||||||
|
State(state): State<Arc<AppState>>,
|
||||||
|
axum::extract::Path(project_id): axum::extract::Path<String>,
|
||||||
|
) -> impl IntoResponse {
|
||||||
|
let project_id = match Uuid::parse_str(&project_id) {
|
||||||
|
Ok(id) => id,
|
||||||
|
Err(_) => {
|
||||||
|
return (
|
||||||
|
StatusCode::BAD_REQUEST,
|
||||||
|
Json(serde_json::json!({
|
||||||
|
"error": "Invalid project ID",
|
||||||
|
"message": "Project ID must be a valid UUID"
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
.into_response();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Verify project exists
|
||||||
|
match db::get_project_by_id(&state.pool, project_id).await {
|
||||||
|
Ok(None) => {
|
||||||
|
return (
|
||||||
|
StatusCode::NOT_FOUND,
|
||||||
|
Json(serde_json::json!({
|
||||||
|
"error": "Not found",
|
||||||
|
"message": "Project not found"
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
.into_response();
|
||||||
|
}
|
||||||
|
Err(err) => {
|
||||||
|
tracing::error!(error = %err, "Failed to check project existence");
|
||||||
|
return (
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
Json(serde_json::json!({
|
||||||
|
"error": "Internal server error",
|
||||||
|
"message": "Failed to fetch project"
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
.into_response();
|
||||||
|
}
|
||||||
|
Ok(Some(_)) => {}
|
||||||
|
}
|
||||||
|
|
||||||
|
match db::get_media_for_project(&state.pool, project_id).await {
|
||||||
|
Ok(media) => {
|
||||||
|
let response: Vec<db::ApiProjectMedia> =
|
||||||
|
media.into_iter().map(|m| m.to_api_media()).collect();
|
||||||
|
Json(response).into_response()
|
||||||
|
}
|
||||||
|
Err(err) => {
|
||||||
|
tracing::error!(error = %err, project_id = %project_id, "Failed to fetch project media");
|
||||||
|
(
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
Json(serde_json::json!({
|
||||||
|
"error": "Internal server error",
|
||||||
|
"message": "Failed to fetch media"
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
.into_response()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Delete a media item (requires authentication)
|
||||||
|
pub async fn delete_media_handler(
|
||||||
|
State(state): State<Arc<AppState>>,
|
||||||
|
axum::extract::Path((project_id, media_id)): axum::extract::Path<(String, String)>,
|
||||||
|
jar: axum_extra::extract::CookieJar,
|
||||||
|
) -> impl IntoResponse {
|
||||||
|
// Check auth
|
||||||
|
if auth::check_session(&state, &jar).is_none() {
|
||||||
|
return auth::require_auth_response().into_response();
|
||||||
|
}
|
||||||
|
|
||||||
|
let project_id = match Uuid::parse_str(&project_id) {
|
||||||
|
Ok(id) => id,
|
||||||
|
Err(_) => {
|
||||||
|
return (
|
||||||
|
StatusCode::BAD_REQUEST,
|
||||||
|
Json(serde_json::json!({
|
||||||
|
"error": "Invalid project ID",
|
||||||
|
"message": "Project ID must be a valid UUID"
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
.into_response();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let media_id = match Uuid::parse_str(&media_id) {
|
||||||
|
Ok(id) => id,
|
||||||
|
Err(_) => {
|
||||||
|
return (
|
||||||
|
StatusCode::BAD_REQUEST,
|
||||||
|
Json(serde_json::json!({
|
||||||
|
"error": "Invalid media ID",
|
||||||
|
"message": "Media ID must be a valid UUID"
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
.into_response();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Get media first to verify it belongs to the project
|
||||||
|
match db::get_media_by_id(&state.pool, media_id).await {
|
||||||
|
Ok(Some(media)) => {
|
||||||
|
if media.project_id != project_id {
|
||||||
|
return (
|
||||||
|
StatusCode::NOT_FOUND,
|
||||||
|
Json(serde_json::json!({
|
||||||
|
"error": "Not found",
|
||||||
|
"message": "Media not found for this project"
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
.into_response();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete files from R2 storage
|
||||||
|
let r2_base_path = media.r2_base_path.clone();
|
||||||
|
if let Some(r2) = R2Client::get().await {
|
||||||
|
// Delete all files under the media's R2 prefix
|
||||||
|
let prefix = format!("{}/", r2_base_path.trim_end_matches('/'));
|
||||||
|
match r2.delete_prefix(&prefix).await {
|
||||||
|
Ok(count) => {
|
||||||
|
tracing::info!(
|
||||||
|
media_id = %media_id,
|
||||||
|
r2_prefix = %prefix,
|
||||||
|
deleted_count = count,
|
||||||
|
"Deleted R2 objects"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Err(err) => {
|
||||||
|
// Log but don't fail - DB record deletion is more important
|
||||||
|
tracing::warn!(
|
||||||
|
error = %err,
|
||||||
|
media_id = %media_id,
|
||||||
|
r2_prefix = %prefix,
|
||||||
|
"Failed to delete R2 objects (will be orphaned)"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
match db::delete_media(&state.pool, media_id).await {
|
||||||
|
Ok(Some(deleted)) => {
|
||||||
|
tracing::info!(
|
||||||
|
media_id = %media_id,
|
||||||
|
project_id = %project_id,
|
||||||
|
r2_base_path = %deleted.r2_base_path,
|
||||||
|
"Media deleted from database"
|
||||||
|
);
|
||||||
|
|
||||||
|
// Invalidate cache since project data changed
|
||||||
|
state.isr_cache.invalidate("/").await;
|
||||||
|
|
||||||
|
Json(deleted.to_api_media()).into_response()
|
||||||
|
}
|
||||||
|
Ok(None) => (
|
||||||
|
StatusCode::NOT_FOUND,
|
||||||
|
Json(serde_json::json!({
|
||||||
|
"error": "Not found",
|
||||||
|
"message": "Media not found"
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
.into_response(),
|
||||||
|
Err(err) => {
|
||||||
|
tracing::error!(error = %err, "Failed to delete media");
|
||||||
|
(
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
Json(serde_json::json!({
|
||||||
|
"error": "Internal server error",
|
||||||
|
"message": "Failed to delete media"
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
.into_response()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(None) => (
|
||||||
|
StatusCode::NOT_FOUND,
|
||||||
|
Json(serde_json::json!({
|
||||||
|
"error": "Not found",
|
||||||
|
"message": "Media not found"
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
.into_response(),
|
||||||
|
Err(err) => {
|
||||||
|
tracing::error!(error = %err, "Failed to fetch media");
|
||||||
|
(
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
Json(serde_json::json!({
|
||||||
|
"error": "Internal server error",
|
||||||
|
"message": "Failed to fetch media"
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
.into_response()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reorder media items for a project (requires authentication)
|
||||||
|
pub async fn reorder_media_handler(
|
||||||
|
State(state): State<Arc<AppState>>,
|
||||||
|
axum::extract::Path(project_id): axum::extract::Path<String>,
|
||||||
|
jar: axum_extra::extract::CookieJar,
|
||||||
|
Json(payload): Json<ReorderMediaRequest>,
|
||||||
|
) -> impl IntoResponse {
|
||||||
|
// Check auth
|
||||||
|
if auth::check_session(&state, &jar).is_none() {
|
||||||
|
return auth::require_auth_response().into_response();
|
||||||
|
}
|
||||||
|
|
||||||
|
let project_id = match Uuid::parse_str(&project_id) {
|
||||||
|
Ok(id) => id,
|
||||||
|
Err(_) => {
|
||||||
|
return (
|
||||||
|
StatusCode::BAD_REQUEST,
|
||||||
|
Json(serde_json::json!({
|
||||||
|
"error": "Invalid project ID",
|
||||||
|
"message": "Project ID must be a valid UUID"
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
.into_response();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Parse media IDs
|
||||||
|
let media_ids: Result<Vec<Uuid>, _> = payload
|
||||||
|
.media_ids
|
||||||
|
.iter()
|
||||||
|
.map(|id| Uuid::parse_str(id))
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let media_ids = match media_ids {
|
||||||
|
Ok(ids) => ids,
|
||||||
|
Err(_) => {
|
||||||
|
return (
|
||||||
|
StatusCode::BAD_REQUEST,
|
||||||
|
Json(serde_json::json!({
|
||||||
|
"error": "Invalid media ID",
|
||||||
|
"message": "All media IDs must be valid UUIDs"
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
.into_response();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Verify project exists
|
||||||
|
match db::get_project_by_id(&state.pool, project_id).await {
|
||||||
|
Ok(None) => {
|
||||||
|
return (
|
||||||
|
StatusCode::NOT_FOUND,
|
||||||
|
Json(serde_json::json!({
|
||||||
|
"error": "Not found",
|
||||||
|
"message": "Project not found"
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
.into_response();
|
||||||
|
}
|
||||||
|
Err(err) => {
|
||||||
|
tracing::error!(error = %err, "Failed to check project existence");
|
||||||
|
return (
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
Json(serde_json::json!({
|
||||||
|
"error": "Internal server error",
|
||||||
|
"message": "Failed to verify project"
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
.into_response();
|
||||||
|
}
|
||||||
|
Ok(Some(_)) => {}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reorder media
|
||||||
|
match db::reorder_media(&state.pool, project_id, &media_ids).await {
|
||||||
|
Ok(()) => {
|
||||||
|
// Fetch updated media list
|
||||||
|
match db::get_media_for_project(&state.pool, project_id).await {
|
||||||
|
Ok(media) => {
|
||||||
|
// Invalidate cache since project data changed
|
||||||
|
state.isr_cache.invalidate("/").await;
|
||||||
|
|
||||||
|
let response: Vec<db::ApiProjectMedia> =
|
||||||
|
media.into_iter().map(|m| m.to_api_media()).collect();
|
||||||
|
Json(response).into_response()
|
||||||
|
}
|
||||||
|
Err(err) => {
|
||||||
|
tracing::error!(error = %err, "Failed to fetch updated media");
|
||||||
|
(
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
Json(serde_json::json!({
|
||||||
|
"error": "Internal server error",
|
||||||
|
"message": "Reorder succeeded but failed to fetch updated list"
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
.into_response()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(err) => {
|
||||||
|
tracing::error!(error = %err, "Failed to reorder media");
|
||||||
|
(
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
Json(serde_json::json!({
|
||||||
|
"error": "Internal server error",
|
||||||
|
"message": "Failed to reorder media"
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
.into_response()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: Upload handler requires multipart form handling and image processing
|
||||||
|
// This will be implemented when we add the upload functionality
|
||||||
|
// For now, media records can only be created programmatically or via seeding
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
pub mod assets;
|
pub mod assets;
|
||||||
pub mod auth;
|
pub mod auth;
|
||||||
pub mod health;
|
pub mod health;
|
||||||
|
pub mod media;
|
||||||
pub mod projects;
|
pub mod projects;
|
||||||
pub mod settings;
|
pub mod settings;
|
||||||
pub mod tags;
|
pub mod tags;
|
||||||
@@ -9,6 +10,7 @@ pub mod tags;
|
|||||||
pub use assets::*;
|
pub use assets::*;
|
||||||
pub use auth::*;
|
pub use auth::*;
|
||||||
pub use health::*;
|
pub use health::*;
|
||||||
|
pub use media::*;
|
||||||
pub use projects::*;
|
pub use projects::*;
|
||||||
pub use settings::*;
|
pub use settings::*;
|
||||||
pub use tags::*;
|
pub use tags::*;
|
||||||
|
|||||||
+43
-38
@@ -11,12 +11,12 @@ pub async fn projects_handler(
|
|||||||
let is_admin = auth::check_session(&state, &jar).is_some();
|
let is_admin = auth::check_session(&state, &jar).is_some();
|
||||||
|
|
||||||
if is_admin {
|
if is_admin {
|
||||||
// Admin view: return all projects with tags
|
// Admin view: return all projects with tags and media
|
||||||
match db::get_all_projects_with_tags_admin(&state.pool).await {
|
match db::get_all_projects_with_tags_admin(&state.pool).await {
|
||||||
Ok(projects_with_tags) => {
|
Ok(projects_with_tags) => {
|
||||||
let response: Vec<db::ApiAdminProject> = projects_with_tags
|
let response: Vec<db::ApiAdminProject> = projects_with_tags
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.map(|(project, tags)| project.to_api_admin_project(tags))
|
.map(|(project, tags, media)| project.to_api_admin_project(tags, media))
|
||||||
.collect();
|
.collect();
|
||||||
Json(response).into_response()
|
Json(response).into_response()
|
||||||
}
|
}
|
||||||
@@ -33,12 +33,12 @@ pub async fn projects_handler(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Public view: return non-hidden projects with tags
|
// Public view: return non-hidden projects with tags and media
|
||||||
match db::get_public_projects_with_tags(&state.pool).await {
|
match db::get_public_projects_with_tags(&state.pool).await {
|
||||||
Ok(projects_with_tags) => {
|
Ok(projects_with_tags) => {
|
||||||
let response: Vec<db::ApiAdminProject> = projects_with_tags
|
let response: Vec<db::ApiAdminProject> = projects_with_tags
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.map(|(project, tags)| project.to_api_admin_project(tags))
|
.map(|(project, tags, media)| project.to_api_admin_project(tags, media))
|
||||||
.collect();
|
.collect();
|
||||||
Json(response).into_response()
|
Json(response).into_response()
|
||||||
}
|
}
|
||||||
@@ -80,7 +80,7 @@ pub async fn get_project_handler(
|
|||||||
let is_admin = auth::check_session(&state, &jar).is_some();
|
let is_admin = auth::check_session(&state, &jar).is_some();
|
||||||
|
|
||||||
match db::get_project_by_id_with_tags(&state.pool, project_id).await {
|
match db::get_project_by_id_with_tags(&state.pool, project_id).await {
|
||||||
Ok(Some((project, tags))) => {
|
Ok(Some((project, tags, media))) => {
|
||||||
// If project is hidden and user is not admin, return 404
|
// If project is hidden and user is not admin, return 404
|
||||||
if project.status == db::ProjectStatus::Hidden && !is_admin {
|
if project.status == db::ProjectStatus::Hidden && !is_admin {
|
||||||
return (
|
return (
|
||||||
@@ -93,7 +93,7 @@ pub async fn get_project_handler(
|
|||||||
.into_response();
|
.into_response();
|
||||||
}
|
}
|
||||||
|
|
||||||
Json(project.to_api_admin_project(tags)).into_response()
|
Json(project.to_api_admin_project(tags, media)).into_response()
|
||||||
}
|
}
|
||||||
Ok(None) => (
|
Ok(None) => (
|
||||||
StatusCode::NOT_FOUND,
|
StatusCode::NOT_FOUND,
|
||||||
@@ -214,8 +214,10 @@ pub async fn create_project_handler(
|
|||||||
tracing::error!(error = %err, project_id = %project.id, "Failed to set project tags");
|
tracing::error!(error = %err, project_id = %project.id, "Failed to set project tags");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fetch project with tags to return
|
// Fetch project with tags and media to return
|
||||||
let (project, tags) = match db::get_project_by_id_with_tags(&state.pool, project.id).await {
|
let (project, tags, media) = match db::get_project_by_id_with_tags(&state.pool, project.id)
|
||||||
|
.await
|
||||||
|
{
|
||||||
Ok(Some(data)) => data,
|
Ok(Some(data)) => data,
|
||||||
Ok(None) => {
|
Ok(None) => {
|
||||||
tracing::error!(project_id = %project.id, "Project not found after creation");
|
tracing::error!(project_id = %project.id, "Project not found after creation");
|
||||||
@@ -248,7 +250,7 @@ pub async fn create_project_handler(
|
|||||||
|
|
||||||
(
|
(
|
||||||
StatusCode::CREATED,
|
StatusCode::CREATED,
|
||||||
Json(project.to_api_admin_project(tags)),
|
Json(project.to_api_admin_project(tags, media)),
|
||||||
)
|
)
|
||||||
.into_response()
|
.into_response()
|
||||||
}
|
}
|
||||||
@@ -384,8 +386,10 @@ pub async fn update_project_handler(
|
|||||||
tracing::error!(error = %err, project_id = %project.id, "Failed to update project tags");
|
tracing::error!(error = %err, project_id = %project.id, "Failed to update project tags");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fetch updated project with tags
|
// Fetch updated project with tags and media
|
||||||
let (project, tags) = match db::get_project_by_id_with_tags(&state.pool, project.id).await {
|
let (project, tags, media) = match db::get_project_by_id_with_tags(&state.pool, project.id)
|
||||||
|
.await
|
||||||
|
{
|
||||||
Ok(Some(data)) => data,
|
Ok(Some(data)) => data,
|
||||||
Ok(None) => {
|
Ok(None) => {
|
||||||
tracing::error!(project_id = %project.id, "Project not found after update");
|
tracing::error!(project_id = %project.id, "Project not found after update");
|
||||||
@@ -416,7 +420,7 @@ pub async fn update_project_handler(
|
|||||||
// Invalidate cached pages that display projects
|
// Invalidate cached pages that display projects
|
||||||
state.isr_cache.invalidate("/").await;
|
state.isr_cache.invalidate("/").await;
|
||||||
|
|
||||||
Json(project.to_api_admin_project(tags)).into_response()
|
Json(project.to_api_admin_project(tags, media)).into_response()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Delete a project (requires authentication)
|
/// Delete a project (requires authentication)
|
||||||
@@ -446,32 +450,33 @@ pub async fn delete_project_handler(
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Fetch project before deletion to return it
|
// Fetch project before deletion to return it
|
||||||
let (project, tags) = match db::get_project_by_id_with_tags(&state.pool, project_id).await {
|
let (project, tags, media) =
|
||||||
Ok(Some(data)) => data,
|
match db::get_project_by_id_with_tags(&state.pool, project_id).await {
|
||||||
Ok(None) => {
|
Ok(Some(data)) => data,
|
||||||
return (
|
Ok(None) => {
|
||||||
StatusCode::NOT_FOUND,
|
return (
|
||||||
Json(serde_json::json!({
|
StatusCode::NOT_FOUND,
|
||||||
"error": "Not found",
|
Json(serde_json::json!({
|
||||||
"message": "Project not found"
|
"error": "Not found",
|
||||||
})),
|
"message": "Project not found"
|
||||||
)
|
})),
|
||||||
.into_response();
|
)
|
||||||
}
|
.into_response();
|
||||||
Err(err) => {
|
}
|
||||||
tracing::error!(error = %err, "Failed to fetch project before deletion");
|
Err(err) => {
|
||||||
return (
|
tracing::error!(error = %err, "Failed to fetch project before deletion");
|
||||||
StatusCode::INTERNAL_SERVER_ERROR,
|
return (
|
||||||
Json(serde_json::json!({
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
"error": "Internal server error",
|
Json(serde_json::json!({
|
||||||
"message": "Failed to delete project"
|
"error": "Internal server error",
|
||||||
})),
|
"message": "Failed to delete project"
|
||||||
)
|
})),
|
||||||
.into_response();
|
)
|
||||||
}
|
.into_response();
|
||||||
};
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// Delete project (CASCADE handles tags)
|
// Delete project (CASCADE handles tags and media)
|
||||||
match db::delete_project(&state.pool, project_id).await {
|
match db::delete_project(&state.pool, project_id).await {
|
||||||
Ok(()) => {
|
Ok(()) => {
|
||||||
tracing::info!(project_id = %project_id, project_name = %project.name, "Project deleted");
|
tracing::info!(project_id = %project_id, project_name = %project.name, "Project deleted");
|
||||||
@@ -479,7 +484,7 @@ pub async fn delete_project_handler(
|
|||||||
// Invalidate cached pages that display projects
|
// Invalidate cached pages that display projects
|
||||||
state.isr_cache.invalidate("/").await;
|
state.isr_cache.invalidate("/").await;
|
||||||
|
|
||||||
Json(project.to_api_admin_project(tags)).into_response()
|
Json(project.to_api_admin_project(tags, media)).into_response()
|
||||||
}
|
}
|
||||||
Err(err) => {
|
Err(err) => {
|
||||||
tracing::error!(error = %err, "Failed to delete project");
|
tracing::error!(error = %err, "Failed to delete project");
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ mod github;
|
|||||||
mod handlers;
|
mod handlers;
|
||||||
mod health;
|
mod health;
|
||||||
mod http;
|
mod http;
|
||||||
|
mod media_processing;
|
||||||
mod middleware;
|
mod middleware;
|
||||||
mod og;
|
mod og;
|
||||||
mod proxy;
|
mod proxy;
|
||||||
|
|||||||
@@ -0,0 +1,229 @@
|
|||||||
|
//! Image processing utilities for media uploads.
|
||||||
|
//!
|
||||||
|
//! Handles resizing, format conversion, and blurhash generation.
|
||||||
|
|
||||||
|
use image::{DynamicImage, GenericImageView, ImageFormat, imageops::FilterType};
|
||||||
|
use std::io::Cursor;
|
||||||
|
|
||||||
|
/// Target widths for image variants
|
||||||
|
pub const THUMB_WIDTH: u32 = 300;
|
||||||
|
pub const MEDIUM_WIDTH: u32 = 800;
|
||||||
|
pub const FULL_WIDTH: u32 = 1600;
|
||||||
|
|
||||||
|
/// Quality setting for WebP encoding (0-100)
|
||||||
|
const WEBP_QUALITY: u8 = 85;
|
||||||
|
|
||||||
|
/// Result of processing an uploaded image
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct ProcessedImage {
|
||||||
|
pub thumb: ImageVariant,
|
||||||
|
pub medium: ImageVariant,
|
||||||
|
pub full: ImageVariant,
|
||||||
|
pub original: OriginalImage,
|
||||||
|
pub blurhash: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A processed image variant
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct ImageVariant {
|
||||||
|
pub data: Vec<u8>,
|
||||||
|
pub width: u32,
|
||||||
|
pub height: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Original image info
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct OriginalImage {
|
||||||
|
pub data: Vec<u8>,
|
||||||
|
pub width: u32,
|
||||||
|
pub height: u32,
|
||||||
|
pub mime: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Errors that can occur during image processing
|
||||||
|
#[derive(Debug, thiserror::Error)]
|
||||||
|
pub enum ProcessingError {
|
||||||
|
#[error("Failed to decode image: {0}")]
|
||||||
|
DecodeError(String),
|
||||||
|
|
||||||
|
#[error("Failed to encode image: {0}")]
|
||||||
|
EncodeError(String),
|
||||||
|
|
||||||
|
#[error("Unsupported image format: {0}")]
|
||||||
|
UnsupportedFormat(String),
|
||||||
|
|
||||||
|
#[error("Image too small: minimum {min}px, got {actual}px")]
|
||||||
|
TooSmall { min: u32, actual: u32 },
|
||||||
|
|
||||||
|
#[error("BlurHash generation failed: {0}")]
|
||||||
|
BlurHashError(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Detect image format from bytes and filename
|
||||||
|
pub fn detect_format(
|
||||||
|
data: &[u8],
|
||||||
|
filename: &str,
|
||||||
|
) -> Result<(ImageFormat, &'static str), ProcessingError> {
|
||||||
|
// Try to detect from magic bytes first
|
||||||
|
if let Ok(format) = image::guess_format(data) {
|
||||||
|
let mime = match format {
|
||||||
|
ImageFormat::Jpeg => "image/jpeg",
|
||||||
|
ImageFormat::Png => "image/png",
|
||||||
|
ImageFormat::Gif => "image/gif",
|
||||||
|
ImageFormat::WebP => "image/webp",
|
||||||
|
ImageFormat::Avif => "image/avif",
|
||||||
|
_ => return Err(ProcessingError::UnsupportedFormat(format!("{format:?}"))),
|
||||||
|
};
|
||||||
|
return Ok((format, mime));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fall back to extension
|
||||||
|
let ext = filename.rsplit('.').next().unwrap_or("").to_lowercase();
|
||||||
|
|
||||||
|
match ext.as_str() {
|
||||||
|
"jpg" | "jpeg" => Ok((ImageFormat::Jpeg, "image/jpeg")),
|
||||||
|
"png" => Ok((ImageFormat::Png, "image/png")),
|
||||||
|
"gif" => Ok((ImageFormat::Gif, "image/gif")),
|
||||||
|
"webp" => Ok((ImageFormat::WebP, "image/webp")),
|
||||||
|
"avif" => Ok((ImageFormat::Avif, "image/avif")),
|
||||||
|
_ => Err(ProcessingError::UnsupportedFormat(ext)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Process an uploaded image into all required variants
|
||||||
|
pub fn process_image(data: &[u8], filename: &str) -> Result<ProcessedImage, ProcessingError> {
|
||||||
|
// Detect format and decode
|
||||||
|
let (format, mime) = detect_format(data, filename)?;
|
||||||
|
let img = image::load_from_memory_with_format(data, format)
|
||||||
|
.map_err(|e| ProcessingError::DecodeError(e.to_string()))?;
|
||||||
|
|
||||||
|
let (orig_width, orig_height) = img.dimensions();
|
||||||
|
|
||||||
|
// Minimum size check - at least thumbnail size
|
||||||
|
let min_dim = orig_width.min(orig_height);
|
||||||
|
if min_dim < THUMB_WIDTH {
|
||||||
|
return Err(ProcessingError::TooSmall {
|
||||||
|
min: THUMB_WIDTH,
|
||||||
|
actual: min_dim,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate variants (only resize if larger than target)
|
||||||
|
let thumb = resize_to_webp(&img, THUMB_WIDTH)?;
|
||||||
|
let medium = resize_to_webp(&img, MEDIUM_WIDTH)?;
|
||||||
|
let full = resize_to_webp(&img, FULL_WIDTH)?;
|
||||||
|
|
||||||
|
// Generate blurhash from thumbnail for efficiency
|
||||||
|
let blurhash = generate_blurhash(&img, 4, 3)?;
|
||||||
|
|
||||||
|
// Keep original as-is (preserve format)
|
||||||
|
let original = OriginalImage {
|
||||||
|
data: data.to_vec(),
|
||||||
|
width: orig_width,
|
||||||
|
height: orig_height,
|
||||||
|
mime: mime.to_string(),
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok(ProcessedImage {
|
||||||
|
thumb,
|
||||||
|
medium,
|
||||||
|
full,
|
||||||
|
original,
|
||||||
|
blurhash,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resize image to target width (maintaining aspect ratio) and encode as WebP
|
||||||
|
fn resize_to_webp(img: &DynamicImage, target_width: u32) -> Result<ImageVariant, ProcessingError> {
|
||||||
|
let (orig_width, orig_height) = img.dimensions();
|
||||||
|
|
||||||
|
// Only resize if larger than target
|
||||||
|
let (resized, width, height) = if orig_width > target_width {
|
||||||
|
let ratio = target_width as f64 / orig_width as f64;
|
||||||
|
let target_height = (orig_height as f64 * ratio).round() as u32;
|
||||||
|
let resized = img.resize(target_width, target_height, FilterType::Lanczos3);
|
||||||
|
(resized, target_width, target_height)
|
||||||
|
} else {
|
||||||
|
(img.clone(), orig_width, orig_height)
|
||||||
|
};
|
||||||
|
|
||||||
|
// Encode as WebP
|
||||||
|
let mut buf = Cursor::new(Vec::new());
|
||||||
|
resized
|
||||||
|
.write_to(&mut buf, ImageFormat::WebP)
|
||||||
|
.map_err(|e| ProcessingError::EncodeError(e.to_string()))?;
|
||||||
|
|
||||||
|
Ok(ImageVariant {
|
||||||
|
data: buf.into_inner(),
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Generate a BlurHash string from an image
|
||||||
|
fn generate_blurhash(
|
||||||
|
img: &DynamicImage,
|
||||||
|
x_components: u32,
|
||||||
|
y_components: u32,
|
||||||
|
) -> Result<String, ProcessingError> {
|
||||||
|
// Resize to small size for efficient blurhash computation
|
||||||
|
let small = img.resize(32, 32, FilterType::Triangle);
|
||||||
|
let rgba = small.to_rgba8();
|
||||||
|
let (w, h) = rgba.dimensions();
|
||||||
|
|
||||||
|
let hash = blurhash::encode(x_components, y_components, w, h, rgba.as_raw())
|
||||||
|
.map_err(|e| ProcessingError::BlurHashError(format!("{e:?}")))?;
|
||||||
|
|
||||||
|
Ok(hash)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check if a MIME type is a supported image format
|
||||||
|
pub fn is_supported_image(mime: &str) -> bool {
|
||||||
|
matches!(
|
||||||
|
mime,
|
||||||
|
"image/jpeg" | "image/png" | "image/gif" | "image/webp" | "image/avif"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check if a MIME type is a supported video format
|
||||||
|
pub fn is_supported_video(mime: &str) -> bool {
|
||||||
|
matches!(mime, "video/mp4" | "video/webm" | "video/quicktime")
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_detect_format_jpeg() {
|
||||||
|
// JPEG magic bytes
|
||||||
|
let data = [0xFF, 0xD8, 0xFF, 0xE0];
|
||||||
|
let (format, mime) = detect_format(&data, "test.jpg").unwrap();
|
||||||
|
assert_eq!(format, ImageFormat::Jpeg);
|
||||||
|
assert_eq!(mime, "image/jpeg");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_detect_format_png() {
|
||||||
|
// PNG magic bytes
|
||||||
|
let data = [0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A];
|
||||||
|
let (format, mime) = detect_format(&data, "test.png").unwrap();
|
||||||
|
assert_eq!(format, ImageFormat::Png);
|
||||||
|
assert_eq!(mime, "image/png");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_is_supported_image() {
|
||||||
|
assert!(is_supported_image("image/jpeg"));
|
||||||
|
assert!(is_supported_image("image/png"));
|
||||||
|
assert!(!is_supported_image("text/plain"));
|
||||||
|
assert!(!is_supported_image("video/mp4"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_is_supported_video() {
|
||||||
|
assert!(is_supported_video("video/mp4"));
|
||||||
|
assert!(is_supported_video("video/webm"));
|
||||||
|
assert!(!is_supported_video("image/jpeg"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -57,7 +57,7 @@ pub async fn generate_og_image(spec: &OGImageSpec, state: Arc<AppState>) -> Resu
|
|||||||
.map_err(|e| format!("Failed to read response: {e}"))?
|
.map_err(|e| format!("Failed to read response: {e}"))?
|
||||||
.to_vec();
|
.to_vec();
|
||||||
|
|
||||||
r2.put_object(&r2_key, bytes)
|
r2.put_object(&r2_key, bytes, "image/png")
|
||||||
.await
|
.await
|
||||||
.map_err(|e| format!("Failed to upload to R2: {e}"))?;
|
.map_err(|e| format!("Failed to upload to R2: {e}"))?;
|
||||||
|
|
||||||
|
|||||||
@@ -78,13 +78,18 @@ impl R2Client {
|
|||||||
Ok(bytes)
|
Ok(bytes)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn put_object(&self, key: &str, body: Vec<u8>) -> Result<(), String> {
|
pub async fn put_object(
|
||||||
|
&self,
|
||||||
|
key: &str,
|
||||||
|
body: Vec<u8>,
|
||||||
|
content_type: &str,
|
||||||
|
) -> Result<(), String> {
|
||||||
self.client
|
self.client
|
||||||
.put_object()
|
.put_object()
|
||||||
.bucket(&self.bucket)
|
.bucket(&self.bucket)
|
||||||
.key(key)
|
.key(key)
|
||||||
.body(ByteStream::from(body))
|
.body(ByteStream::from(body))
|
||||||
.content_type("image/png")
|
.content_type(content_type)
|
||||||
.send()
|
.send()
|
||||||
.await
|
.await
|
||||||
.map_err(|e| format!("Failed to put object to R2: {e}"))?;
|
.map_err(|e| format!("Failed to put object to R2: {e}"))?;
|
||||||
@@ -92,6 +97,42 @@ impl R2Client {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn delete_object(&self, key: &str) -> Result<(), String> {
|
||||||
|
self.client
|
||||||
|
.delete_object()
|
||||||
|
.bucket(&self.bucket)
|
||||||
|
.key(key)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("Failed to delete object from R2: {e}"))?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Delete all objects under a prefix (e.g., "projects/{id}/{ulid}/")
|
||||||
|
pub async fn delete_prefix(&self, prefix: &str) -> Result<usize, String> {
|
||||||
|
let list_result = self
|
||||||
|
.client
|
||||||
|
.list_objects_v2()
|
||||||
|
.bucket(&self.bucket)
|
||||||
|
.prefix(prefix)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("Failed to list objects in R2: {e}"))?;
|
||||||
|
|
||||||
|
let mut deleted = 0;
|
||||||
|
if let Some(contents) = list_result.contents {
|
||||||
|
for object in contents {
|
||||||
|
if let Some(key) = object.key {
|
||||||
|
self.delete_object(&key).await?;
|
||||||
|
deleted += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(deleted)
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn object_exists(&self, key: &str) -> bool {
|
pub async fn object_exists(&self, key: &str) -> bool {
|
||||||
self.client
|
self.client
|
||||||
.head_object()
|
.head_object()
|
||||||
|
|||||||
+15
-2
@@ -4,7 +4,7 @@ use axum::{
|
|||||||
extract::Request,
|
extract::Request,
|
||||||
http::{Method, Uri},
|
http::{Method, Uri},
|
||||||
response::IntoResponse,
|
response::IntoResponse,
|
||||||
routing::{any, get, post},
|
routing::{any, delete, get, post, put},
|
||||||
};
|
};
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
@@ -41,7 +41,20 @@ pub fn api_routes() -> Router<Arc<AppState>> {
|
|||||||
)
|
)
|
||||||
.route(
|
.route(
|
||||||
"/projects/{id}/tags/{tag_id}",
|
"/projects/{id}/tags/{tag_id}",
|
||||||
axum::routing::delete(handlers::remove_project_tag_handler),
|
delete(handlers::remove_project_tag_handler),
|
||||||
|
)
|
||||||
|
// Project media - GET is public, POST/PUT/DELETE require authentication
|
||||||
|
.route(
|
||||||
|
"/projects/{id}/media",
|
||||||
|
get(handlers::get_project_media_handler).post(handlers::upload_media_handler),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/projects/{id}/media/reorder",
|
||||||
|
put(handlers::reorder_media_handler),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/projects/{id}/media/{media_id}",
|
||||||
|
delete(handlers::delete_media_handler),
|
||||||
)
|
)
|
||||||
// Tags - authentication checked in handlers
|
// Tags - authentication checked in handlers
|
||||||
.route(
|
.route(
|
||||||
|
|||||||
@@ -14,12 +14,14 @@
|
|||||||
"@logtape/logtape": "^1.3.5",
|
"@logtape/logtape": "^1.3.5",
|
||||||
"@resvg/resvg-js": "^2.6.2",
|
"@resvg/resvg-js": "^2.6.2",
|
||||||
"@xevion/satori-html": "^0.4.1",
|
"@xevion/satori-html": "^0.4.1",
|
||||||
|
"blurhash": "^2.0.5",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"overlayscrollbars": "^2.13.0",
|
"overlayscrollbars": "^2.13.0",
|
||||||
"overlayscrollbars-svelte": "^0.5.5",
|
"overlayscrollbars-svelte": "^0.5.5",
|
||||||
"posthog-js": "^1.321.1",
|
"posthog-js": "^1.321.1",
|
||||||
"posthog-node": "^5.21.0",
|
"posthog-node": "^5.21.0",
|
||||||
"satori": "^0.18.3",
|
"satori": "^0.18.3",
|
||||||
|
"svelte-dnd-action": "^0.9.69",
|
||||||
"tailwind-merge": "^3.3.1",
|
"tailwind-merge": "^3.3.1",
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
@@ -412,6 +414,8 @@
|
|||||||
|
|
||||||
"base64-js": ["base64-js@0.0.8", "", {}, "sha512-3XSA2cR/h/73EzlXXdU6YNycmYI7+kicTxks4eJg2g39biHR84slg2+des+p7iHYhbRg/udIS4TD53WabcOUkw=="],
|
"base64-js": ["base64-js@0.0.8", "", {}, "sha512-3XSA2cR/h/73EzlXXdU6YNycmYI7+kicTxks4eJg2g39biHR84slg2+des+p7iHYhbRg/udIS4TD53WabcOUkw=="],
|
||||||
|
|
||||||
|
"blurhash": ["blurhash@2.0.5", "", {}, "sha512-cRygWd7kGBQO3VEhPiTgq4Wc43ctsM+o46urrmPOiuAe+07fzlSB9OJVdpgDL0jPqXUVQ9ht7aq7kxOeJHRK+w=="],
|
||||||
|
|
||||||
"brace-expansion": ["brace-expansion@1.1.12", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg=="],
|
"brace-expansion": ["brace-expansion@1.1.12", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg=="],
|
||||||
|
|
||||||
"buffer-from": ["buffer-from@1.1.2", "", {}, "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ=="],
|
"buffer-from": ["buffer-from@1.1.2", "", {}, "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ=="],
|
||||||
@@ -762,6 +766,8 @@
|
|||||||
|
|
||||||
"svelte-check": ["svelte-check@4.3.5", "", { "dependencies": { "@jridgewell/trace-mapping": "^0.3.25", "chokidar": "^4.0.1", "fdir": "^6.2.0", "picocolors": "^1.0.0", "sade": "^1.7.4" }, "peerDependencies": { "svelte": "^4.0.0 || ^5.0.0-next.0", "typescript": ">=5.0.0" }, "bin": { "svelte-check": "bin/svelte-check" } }, "sha512-e4VWZETyXaKGhpkxOXP+B/d0Fp/zKViZoJmneZWe/05Y2aqSKj3YN2nLfYPJBQ87WEiY4BQCQ9hWGu9mPT1a1Q=="],
|
"svelte-check": ["svelte-check@4.3.5", "", { "dependencies": { "@jridgewell/trace-mapping": "^0.3.25", "chokidar": "^4.0.1", "fdir": "^6.2.0", "picocolors": "^1.0.0", "sade": "^1.7.4" }, "peerDependencies": { "svelte": "^4.0.0 || ^5.0.0-next.0", "typescript": ">=5.0.0" }, "bin": { "svelte-check": "bin/svelte-check" } }, "sha512-e4VWZETyXaKGhpkxOXP+B/d0Fp/zKViZoJmneZWe/05Y2aqSKj3YN2nLfYPJBQ87WEiY4BQCQ9hWGu9mPT1a1Q=="],
|
||||||
|
|
||||||
|
"svelte-dnd-action": ["svelte-dnd-action@0.9.69", "", { "peerDependencies": { "svelte": ">=3.23.0 || ^5.0.0-next.0" } }, "sha512-NAmSOH7htJoYraTQvr+q5whlIuVoq88vEuHr4NcFgscDRUxfWPPxgie2OoxepBCQCikrXZV4pqV86aun60wVyw=="],
|
||||||
|
|
||||||
"svelte-eslint-parser": ["svelte-eslint-parser@1.4.1", "", { "dependencies": { "eslint-scope": "^8.2.0", "eslint-visitor-keys": "^4.0.0", "espree": "^10.0.0", "postcss": "^8.4.49", "postcss-scss": "^4.0.9", "postcss-selector-parser": "^7.0.0" }, "peerDependencies": { "svelte": "^3.37.0 || ^4.0.0 || ^5.0.0" }, "optionalPeers": ["svelte"] }, "sha512-1eqkfQ93goAhjAXxZiu1SaKI9+0/sxp4JIWQwUpsz7ybehRE5L8dNuz7Iry7K22R47p5/+s9EM+38nHV2OlgXA=="],
|
"svelte-eslint-parser": ["svelte-eslint-parser@1.4.1", "", { "dependencies": { "eslint-scope": "^8.2.0", "eslint-visitor-keys": "^4.0.0", "espree": "^10.0.0", "postcss": "^8.4.49", "postcss-scss": "^4.0.9", "postcss-selector-parser": "^7.0.0" }, "peerDependencies": { "svelte": "^3.37.0 || ^4.0.0 || ^5.0.0" }, "optionalPeers": ["svelte"] }, "sha512-1eqkfQ93goAhjAXxZiu1SaKI9+0/sxp4JIWQwUpsz7ybehRE5L8dNuz7Iry7K22R47p5/+s9EM+38nHV2OlgXA=="],
|
||||||
|
|
||||||
"tailwind-merge": ["tailwind-merge@3.4.0", "", {}, "sha512-uSaO4gnW+b3Y2aWoWfFpX62vn2sR3skfhbjsEnaBI81WD1wBLlHZe5sWf0AqjksNdYTbGBEd0UasQMT3SNV15g=="],
|
"tailwind-merge": ["tailwind-merge@3.4.0", "", {}, "sha512-uSaO4gnW+b3Y2aWoWfFpX62vn2sR3skfhbjsEnaBI81WD1wBLlHZe5sWf0AqjksNdYTbGBEd0UasQMT3SNV15g=="],
|
||||||
|
|||||||
@@ -26,12 +26,14 @@
|
|||||||
"@logtape/logtape": "^1.3.5",
|
"@logtape/logtape": "^1.3.5",
|
||||||
"@resvg/resvg-js": "^2.6.2",
|
"@resvg/resvg-js": "^2.6.2",
|
||||||
"@xevion/satori-html": "^0.4.1",
|
"@xevion/satori-html": "^0.4.1",
|
||||||
|
"blurhash": "^2.0.5",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"overlayscrollbars": "^2.13.0",
|
"overlayscrollbars": "^2.13.0",
|
||||||
"overlayscrollbars-svelte": "^0.5.5",
|
"overlayscrollbars-svelte": "^0.5.5",
|
||||||
"posthog-js": "^1.321.1",
|
"posthog-js": "^1.321.1",
|
||||||
"posthog-node": "^5.21.0",
|
"posthog-node": "^5.21.0",
|
||||||
"satori": "^0.18.3",
|
"satori": "^0.18.3",
|
||||||
|
"svelte-dnd-action": "^0.9.69",
|
||||||
"tailwind-merge": "^3.3.1"
|
"tailwind-merge": "^3.3.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
@@ -19,6 +19,45 @@ export interface TagWithIcon extends AdminTag {
|
|||||||
iconSvg?: string;
|
iconSvg?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Media types for project carousel
|
||||||
|
export type MediaType = "image" | "video";
|
||||||
|
|
||||||
|
export interface MediaVariant {
|
||||||
|
url: string;
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface VideoOriginal {
|
||||||
|
url: string;
|
||||||
|
mime: string;
|
||||||
|
duration?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MediaVariants {
|
||||||
|
thumb?: MediaVariant;
|
||||||
|
medium?: MediaVariant;
|
||||||
|
full?: MediaVariant;
|
||||||
|
original?: MediaVariant;
|
||||||
|
poster?: MediaVariant;
|
||||||
|
video?: VideoOriginal;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MediaMetadata {
|
||||||
|
focalPoint?: { x: number; y: number };
|
||||||
|
altText?: string;
|
||||||
|
duration?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProjectMedia {
|
||||||
|
id: string;
|
||||||
|
displayOrder: number;
|
||||||
|
mediaType: MediaType;
|
||||||
|
variants: MediaVariants;
|
||||||
|
blurhash?: string;
|
||||||
|
metadata?: MediaMetadata;
|
||||||
|
}
|
||||||
|
|
||||||
export interface AdminProject {
|
export interface AdminProject {
|
||||||
id: string;
|
id: string;
|
||||||
slug: string;
|
slug: string;
|
||||||
@@ -28,6 +67,7 @@ export interface AdminProject {
|
|||||||
status: ProjectStatus;
|
status: ProjectStatus;
|
||||||
links: Array<{ url: string; title?: string }>;
|
links: Array<{ url: string; title?: string }>;
|
||||||
tags: AdminTag[];
|
tags: AdminTag[];
|
||||||
|
media: ProjectMedia[];
|
||||||
githubRepo?: string | null;
|
githubRepo?: string | null;
|
||||||
demoUrl?: string | null;
|
demoUrl?: string | null;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import type {
|
|||||||
CreateTagData,
|
CreateTagData,
|
||||||
UpdateTagData,
|
UpdateTagData,
|
||||||
SiteSettings,
|
SiteSettings,
|
||||||
|
ProjectMedia,
|
||||||
} from "./admin-types";
|
} from "./admin-types";
|
||||||
import { ApiError } from "./errors";
|
import { ApiError } from "./errors";
|
||||||
|
|
||||||
@@ -134,6 +135,68 @@ export async function getRelatedTags(slug: string): Promise<RelatedTag[]> {
|
|||||||
return clientApiFetch<RelatedTag[]>(`/api/tags/${slug}/related`);
|
return clientApiFetch<RelatedTag[]>(`/api/tags/${slug}/related`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Admin Media API
|
||||||
|
export async function uploadProjectMedia(
|
||||||
|
projectId: string,
|
||||||
|
file: File,
|
||||||
|
onProgress?: (percent: number) => void,
|
||||||
|
): Promise<ProjectMedia> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const xhr = new XMLHttpRequest();
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append("file", file);
|
||||||
|
|
||||||
|
xhr.upload.onprogress = (e) => {
|
||||||
|
if (e.lengthComputable && onProgress) {
|
||||||
|
onProgress(Math.round((e.loaded / e.total) * 100));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
xhr.onload = () => {
|
||||||
|
if (xhr.status >= 200 && xhr.status < 300) {
|
||||||
|
try {
|
||||||
|
const data = JSON.parse(xhr.responseText);
|
||||||
|
resolve(data as ProjectMedia);
|
||||||
|
} catch {
|
||||||
|
reject(new Error("Invalid response from server"));
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
reject(new Error(`Upload failed: ${xhr.statusText}`));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
xhr.onerror = () => reject(new Error("Network error during upload"));
|
||||||
|
|
||||||
|
xhr.open("POST", `/api/projects/${projectId}/media`);
|
||||||
|
xhr.withCredentials = true;
|
||||||
|
xhr.send(formData);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteProjectMedia(
|
||||||
|
projectId: string,
|
||||||
|
mediaId: string,
|
||||||
|
): Promise<ProjectMedia> {
|
||||||
|
return clientApiFetch<ProjectMedia>(
|
||||||
|
`/api/projects/${projectId}/media/${mediaId}`,
|
||||||
|
{ method: "DELETE" },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function reorderProjectMedia(
|
||||||
|
projectId: string,
|
||||||
|
mediaIds: string[],
|
||||||
|
): Promise<ProjectMedia[]> {
|
||||||
|
return clientApiFetch<ProjectMedia[]>(
|
||||||
|
`/api/projects/${projectId}/media/reorder`,
|
||||||
|
{
|
||||||
|
method: "PUT",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ mediaIds }),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// Admin Events API (currently mocked - no backend implementation yet)
|
// Admin Events API (currently mocked - no backend implementation yet)
|
||||||
export async function getAdminEvents(): Promise<AdminEvent[]> {
|
export async function getAdminEvents(): Promise<AdminEvent[]> {
|
||||||
// TODO: Implement when events table is added to backend
|
// TODO: Implement when events table is added to backend
|
||||||
|
|||||||
@@ -30,44 +30,28 @@
|
|||||||
project.demoUrl ? "demo_click" : project.githubRepo ? "github_click" : null,
|
project.demoUrl ? "demo_click" : project.githubRepo ? "github_click" : null,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Random seed generated once per component instance (changes on each page load)
|
// Get primary media (first by display order) if available
|
||||||
const randomSeed = Math.floor(Math.random() * 1000);
|
const primaryMedia = $derived(project.media?.[0]);
|
||||||
|
const hasMedia = $derived(!!primaryMedia);
|
||||||
|
const isVideo = $derived(primaryMedia?.mediaType === "video");
|
||||||
|
|
||||||
// Randomly decide if this card shows video or image (~60% video)
|
// Get media URLs from primary media
|
||||||
const isVideo = randomSeed % 10 < 6;
|
const videoUrl = $derived(
|
||||||
|
isVideo ? primaryMedia?.variants.video?.url : undefined,
|
||||||
|
);
|
||||||
|
|
||||||
// Sample video URLs
|
const imageUrl = $derived(
|
||||||
const sampleVideos = [
|
!isVideo && primaryMedia
|
||||||
"https://storage.googleapis.com/gtv-videos-bucket/sample/ForBiggerBlazes.mp4",
|
? (primaryMedia.variants.medium?.url ??
|
||||||
"https://storage.googleapis.com/gtv-videos-bucket/sample/ForBiggerEscapes.mp4",
|
primaryMedia.variants.thumb?.url ??
|
||||||
"https://storage.googleapis.com/gtv-videos-bucket/sample/ForBiggerFun.mp4",
|
primaryMedia.variants.full?.url)
|
||||||
"https://storage.googleapis.com/gtv-videos-bucket/sample/ForBiggerJoyrides.mp4",
|
: undefined,
|
||||||
"https://storage.googleapis.com/gtv-videos-bucket/sample/ForBiggerMeltdowns.mp4",
|
);
|
||||||
"https://storage.googleapis.com/gtv-videos-bucket/sample/SubaruOutbackOnStreetAndDirt.mp4",
|
|
||||||
"https://storage.googleapis.com/gtv-videos-bucket/sample/WeAreGoingOnBullrun.mp4",
|
|
||||||
"https://res.cloudinary.com/demo/video/upload/w_640,h_360,c_fill/samples/elephants.mp4",
|
|
||||||
"https://res.cloudinary.com/demo/video/upload/w_640,h_360,c_fill/samples/sea-turtle.mp4",
|
|
||||||
"https://res.cloudinary.com/demo/video/upload/w_640,h_360,c_fill/dog.mp4",
|
|
||||||
"https://res.cloudinary.com/demo/video/upload/w_640,h_360,c_fill/ski_jump.mp4",
|
|
||||||
"https://res.cloudinary.com/demo/video/upload/w_640,h_360,c_fill/snow_horses.mp4",
|
|
||||||
];
|
|
||||||
|
|
||||||
const videoUrl = sampleVideos[randomSeed % sampleVideos.length];
|
// Video poster URL (for video media, use the poster variant)
|
||||||
|
const videoPosterUrl = $derived(
|
||||||
// Randomized aspect ratios for images: [width, height]
|
isVideo ? primaryMedia?.variants.poster?.url : undefined,
|
||||||
const aspectRatios: [number, number][] = [
|
);
|
||||||
[400, 300], // 4:3 landscape
|
|
||||||
[300, 400], // 3:4 portrait
|
|
||||||
[400, 400], // 1:1 square
|
|
||||||
[480, 270], // 16:9 landscape
|
|
||||||
[270, 480], // 9:16 portrait
|
|
||||||
[400, 240], // 5:3 wide landscape
|
|
||||||
[240, 400], // 3:5 tall portrait
|
|
||||||
];
|
|
||||||
const aspectIndex = randomSeed % aspectRatios.length;
|
|
||||||
const [imgWidth, imgHeight] = aspectRatios[aspectIndex];
|
|
||||||
|
|
||||||
const imageUrl = `https://picsum.photos/seed/${randomSeed}/${imgWidth}/${imgHeight}`;
|
|
||||||
|
|
||||||
// Video element reference for play/pause control
|
// Video element reference for play/pause control
|
||||||
let videoElement: HTMLVideoElement | null = $state(null);
|
let videoElement: HTMLVideoElement | null = $state(null);
|
||||||
@@ -131,34 +115,35 @@
|
|||||||
role={isLink ? undefined : "article"}
|
role={isLink ? undefined : "article"}
|
||||||
class={cn(
|
class={cn(
|
||||||
"group relative flex h-44 flex-col gap-2.5 rounded-lg border border-zinc-200 dark:border-zinc-800 bg-zinc-50 dark:bg-zinc-900/50 p-3 overflow-hidden",
|
"group relative flex h-44 flex-col gap-2.5 rounded-lg border border-zinc-200 dark:border-zinc-800 bg-zinc-50 dark:bg-zinc-900/50 p-3 overflow-hidden",
|
||||||
{
|
isLink &&
|
||||||
"transition-all hover:border-zinc-300 dark:hover:border-zinc-700 hover:bg-zinc-100/80 dark:hover:bg-zinc-800/50":
|
"transition-all hover:border-zinc-300 dark:hover:border-zinc-700 hover:bg-zinc-100/80 dark:hover:bg-zinc-800/50",
|
||||||
isLink,
|
className,
|
||||||
className: true,
|
|
||||||
},
|
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<!-- Background media layer -->
|
<!-- Background media layer -->
|
||||||
<div
|
{#if hasMedia}
|
||||||
class="pointer-events-none absolute inset-0 opacity-25 group-hover:opacity-40"
|
<div
|
||||||
style="transition: opacity 300ms ease-in-out;"
|
class="pointer-events-none absolute inset-0 opacity-25 group-hover:opacity-40"
|
||||||
aria-hidden="true"
|
style="transition: opacity 300ms ease-in-out;"
|
||||||
>
|
aria-hidden="true"
|
||||||
{#if isVideo}
|
>
|
||||||
<video
|
{#if isVideo && videoUrl}
|
||||||
bind:this={videoElement}
|
<video
|
||||||
src={videoUrl}
|
bind:this={videoElement}
|
||||||
class={cn(mediaBaseClasses, "grayscale group-hover:grayscale-0")}
|
src={videoUrl}
|
||||||
style="transition: filter 300ms ease-in-out;"
|
poster={videoPosterUrl}
|
||||||
muted
|
class={cn(mediaBaseClasses, "grayscale group-hover:grayscale-0")}
|
||||||
loop
|
style="transition: filter 300ms ease-in-out;"
|
||||||
playsinline
|
muted
|
||||||
preload="metadata"
|
loop
|
||||||
></video>
|
playsinline
|
||||||
{:else}
|
preload="metadata"
|
||||||
<img src={imageUrl} alt="" class={mediaBaseClasses} loading="lazy" />
|
></video>
|
||||||
{/if}
|
{:else if imageUrl}
|
||||||
</div>
|
<img src={imageUrl} alt="" class={mediaBaseClasses} loading="lazy" />
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
<!-- Content layer -->
|
<!-- Content layer -->
|
||||||
<div
|
<div
|
||||||
|
|||||||
@@ -0,0 +1,118 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { cn } from "$lib/utils";
|
||||||
|
import { decode } from "blurhash";
|
||||||
|
import type { ProjectMedia } from "$lib/admin-types";
|
||||||
|
import VideoThumbnail from "./VideoThumbnail.svelte";
|
||||||
|
import IconX from "~icons/lucide/x";
|
||||||
|
import IconPlay from "~icons/lucide/play";
|
||||||
|
import IconFilm from "~icons/lucide/film";
|
||||||
|
import IconImage from "~icons/lucide/image";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
media: ProjectMedia;
|
||||||
|
ondelete: () => void;
|
||||||
|
class?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { media, ondelete, class: className }: Props = $props();
|
||||||
|
|
||||||
|
// Get the best thumbnail URL (for images)
|
||||||
|
const thumbUrl = $derived(
|
||||||
|
media.variants.thumb?.url ??
|
||||||
|
media.variants.medium?.url ??
|
||||||
|
media.variants.full?.url ??
|
||||||
|
media.variants.poster?.url,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Get video URL (for videos)
|
||||||
|
const videoUrl = $derived(media.variants.video?.url);
|
||||||
|
|
||||||
|
// Decode blurhash to canvas on mount
|
||||||
|
let canvasRef: HTMLCanvasElement | null = $state(null);
|
||||||
|
let imageLoaded = $state(false);
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (canvasRef && media.blurhash && !imageLoaded) {
|
||||||
|
try {
|
||||||
|
const pixels = decode(media.blurhash, 32, 32);
|
||||||
|
const ctx = canvasRef.getContext("2d");
|
||||||
|
if (ctx) {
|
||||||
|
const imageData = ctx.createImageData(32, 32);
|
||||||
|
imageData.data.set(pixels);
|
||||||
|
ctx.putImageData(imageData, 0, 0);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Silently fail if blurhash is invalid
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function handleImageLoad() {
|
||||||
|
imageLoaded = true;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<!-- Outer wrapper allows delete button to escape bounds -->
|
||||||
|
<div class={cn("group relative", className)}>
|
||||||
|
<!-- Media container with fixed height -->
|
||||||
|
<div
|
||||||
|
class="relative h-28 rounded-lg border border-admin-border bg-admin-bg-secondary overflow-hidden"
|
||||||
|
>
|
||||||
|
<!-- Blurhash placeholder -->
|
||||||
|
{#if media.blurhash && !imageLoaded}
|
||||||
|
<canvas
|
||||||
|
bind:this={canvasRef}
|
||||||
|
width="32"
|
||||||
|
height="32"
|
||||||
|
class="absolute inset-0 w-full h-full object-cover"
|
||||||
|
></canvas>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Actual thumbnail or video -->
|
||||||
|
{#if media.mediaType === "video" && videoUrl}
|
||||||
|
<!-- Video thumbnail - capture first frame to canvas -->
|
||||||
|
<VideoThumbnail src={videoUrl} onload={handleImageLoad} />
|
||||||
|
{:else if thumbUrl}
|
||||||
|
<img
|
||||||
|
src={thumbUrl}
|
||||||
|
alt=""
|
||||||
|
class={cn(
|
||||||
|
"absolute inset-0 w-full h-full object-cover transition-opacity duration-200",
|
||||||
|
imageLoaded ? "opacity-100" : "opacity-0",
|
||||||
|
)}
|
||||||
|
onload={handleImageLoad}
|
||||||
|
/>
|
||||||
|
{:else}
|
||||||
|
<!-- Fallback for missing thumbnail -->
|
||||||
|
<div
|
||||||
|
class="absolute inset-0 flex items-center justify-center text-admin-text-muted"
|
||||||
|
>
|
||||||
|
{#if media.mediaType === "video"}
|
||||||
|
<IconFilm class="size-6" />
|
||||||
|
{:else}
|
||||||
|
<IconImage class="size-6" />
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Video badge -->
|
||||||
|
{#if media.mediaType === "video"}
|
||||||
|
<div
|
||||||
|
class="absolute top-2 left-2 bg-black/70 text-white text-xs px-1.5 py-0.5 rounded flex items-center gap-1"
|
||||||
|
>
|
||||||
|
<IconPlay class="size-2.5" />
|
||||||
|
<span>Video</span>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Delete button - positioned outside the overflow-hidden container -->
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={ondelete}
|
||||||
|
class="absolute -top-2 -right-2 w-6 h-6 bg-red-600 hover:bg-red-500 text-white rounded-full flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity shadow-md z-10"
|
||||||
|
aria-label="Delete media"
|
||||||
|
>
|
||||||
|
<IconX class="size-3.5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,395 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { cn } from "$lib/utils";
|
||||||
|
import { dndzone } from "svelte-dnd-action";
|
||||||
|
import { flip } from "svelte/animate";
|
||||||
|
import type { ProjectMedia } from "$lib/admin-types";
|
||||||
|
import {
|
||||||
|
uploadProjectMedia,
|
||||||
|
deleteProjectMedia,
|
||||||
|
reorderProjectMedia,
|
||||||
|
} from "$lib/api";
|
||||||
|
import MediaItem from "./MediaItem.svelte";
|
||||||
|
import Modal from "./Modal.svelte";
|
||||||
|
import { getLogger } from "@logtape/logtape";
|
||||||
|
import IconCloudUpload from "~icons/lucide/cloud-upload";
|
||||||
|
import IconAlertCircle from "~icons/lucide/alert-circle";
|
||||||
|
import IconLoader from "~icons/lucide/loader-2";
|
||||||
|
import IconX from "~icons/lucide/x";
|
||||||
|
|
||||||
|
const logger = getLogger(["admin", "components", "MediaManager"]);
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
projectId: string | null;
|
||||||
|
media?: ProjectMedia[];
|
||||||
|
onchange?: (media: ProjectMedia[]) => void;
|
||||||
|
class?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { projectId, media = [], onchange, class: className }: Props = $props();
|
||||||
|
|
||||||
|
// Local media state (for reordering) - needs to be mutable for drag-drop
|
||||||
|
// eslint-disable-next-line svelte/prefer-writable-derived -- intentional: svelte-dnd-action requires mutable array
|
||||||
|
let mediaItems = $state<ProjectMedia[]>([]);
|
||||||
|
|
||||||
|
// Sync from props when they change
|
||||||
|
$effect(() => {
|
||||||
|
mediaItems = [...media];
|
||||||
|
});
|
||||||
|
|
||||||
|
// Upload state
|
||||||
|
interface UploadTask {
|
||||||
|
id: string;
|
||||||
|
file: File;
|
||||||
|
progress: number;
|
||||||
|
status: "uploading" | "done" | "error";
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
let uploadQueue = $state<UploadTask[]>([]);
|
||||||
|
|
||||||
|
// UI state
|
||||||
|
let isDraggingFile = $state(false);
|
||||||
|
let errorMessage = $state<string | null>(null);
|
||||||
|
let fileInputRef: HTMLInputElement | null = $state(null);
|
||||||
|
|
||||||
|
// Delete confirmation
|
||||||
|
let deleteModalOpen = $state(false);
|
||||||
|
let deletingMedia = $state<ProjectMedia | null>(null);
|
||||||
|
|
||||||
|
const flipDurationMs = 150;
|
||||||
|
const SUPPORTED_IMAGE_TYPES = [
|
||||||
|
"image/jpeg",
|
||||||
|
"image/png",
|
||||||
|
"image/gif",
|
||||||
|
"image/webp",
|
||||||
|
"image/avif",
|
||||||
|
];
|
||||||
|
const SUPPORTED_VIDEO_TYPES = ["video/mp4", "video/webm", "video/quicktime"];
|
||||||
|
const SUPPORTED_TYPES = [...SUPPORTED_IMAGE_TYPES, ...SUPPORTED_VIDEO_TYPES];
|
||||||
|
|
||||||
|
// Drag and drop reorder handlers
|
||||||
|
function handleDndConsider(e: CustomEvent<{ items: ProjectMedia[] }>) {
|
||||||
|
mediaItems = e.detail.items;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDndFinalize(e: CustomEvent<{ items: ProjectMedia[] }>) {
|
||||||
|
mediaItems = e.detail.items;
|
||||||
|
onchange?.(mediaItems);
|
||||||
|
|
||||||
|
// Call reorder API
|
||||||
|
if (projectId) {
|
||||||
|
try {
|
||||||
|
await reorderProjectMedia(
|
||||||
|
projectId,
|
||||||
|
mediaItems.map((m) => m.id),
|
||||||
|
);
|
||||||
|
logger.info("Media reordered", { projectId, count: mediaItems.length });
|
||||||
|
} catch (err) {
|
||||||
|
logger.error("Failed to reorder media", { error: err });
|
||||||
|
showError("Failed to save new order");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// File upload handlers
|
||||||
|
function handleDragEnter(e: DragEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
isDraggingFile = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleDragLeave(e: DragEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
// Only set false if leaving the drop zone entirely
|
||||||
|
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
|
||||||
|
const x = e.clientX;
|
||||||
|
const y = e.clientY;
|
||||||
|
if (x < rect.left || x > rect.right || y < rect.top || y > rect.bottom) {
|
||||||
|
isDraggingFile = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleDragOver(e: DragEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
isDraggingFile = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleDrop(e: DragEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
isDraggingFile = false;
|
||||||
|
|
||||||
|
const files = e.dataTransfer?.files;
|
||||||
|
if (files && files.length > 0) {
|
||||||
|
handleFiles(Array.from(files));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleFileInputChange(e: Event) {
|
||||||
|
const input = e.target as HTMLInputElement;
|
||||||
|
if (input.files && input.files.length > 0) {
|
||||||
|
handleFiles(Array.from(input.files));
|
||||||
|
input.value = ""; // Reset for re-upload of same file
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleFiles(files: File[]) {
|
||||||
|
clearError();
|
||||||
|
|
||||||
|
const validFiles: File[] = [];
|
||||||
|
const invalidTypes: string[] = [];
|
||||||
|
|
||||||
|
for (const file of files) {
|
||||||
|
if (SUPPORTED_TYPES.includes(file.type)) {
|
||||||
|
validFiles.push(file);
|
||||||
|
} else {
|
||||||
|
invalidTypes.push(file.name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (invalidTypes.length > 0) {
|
||||||
|
showError(
|
||||||
|
`Unsupported file type${invalidTypes.length > 1 ? "s" : ""}: ${invalidTypes.join(", ")}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const file of validFiles) {
|
||||||
|
uploadFile(file);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function uploadFile(file: File) {
|
||||||
|
if (!projectId) return;
|
||||||
|
|
||||||
|
const taskId = crypto.randomUUID();
|
||||||
|
const task: UploadTask = {
|
||||||
|
id: taskId,
|
||||||
|
file,
|
||||||
|
progress: 0,
|
||||||
|
status: "uploading",
|
||||||
|
};
|
||||||
|
|
||||||
|
uploadQueue = [...uploadQueue, task];
|
||||||
|
|
||||||
|
try {
|
||||||
|
const media = await uploadProjectMedia(projectId, file, (progress) => {
|
||||||
|
uploadQueue = uploadQueue.map((t) =>
|
||||||
|
t.id === taskId ? { ...t, progress } : t,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add to media items
|
||||||
|
mediaItems = [...mediaItems, media];
|
||||||
|
onchange?.(mediaItems);
|
||||||
|
|
||||||
|
// Remove from queue
|
||||||
|
uploadQueue = uploadQueue.filter((t) => t.id !== taskId);
|
||||||
|
|
||||||
|
logger.info("Media uploaded", { projectId, mediaId: media.id });
|
||||||
|
} catch (err) {
|
||||||
|
logger.error("Upload failed", { error: err, filename: file.name });
|
||||||
|
uploadQueue = uploadQueue.map((t) =>
|
||||||
|
t.id === taskId ? { ...t, status: "error", error: String(err) } : t,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeUploadTask(taskId: string) {
|
||||||
|
uploadQueue = uploadQueue.filter((t) => t.id !== taskId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete handlers
|
||||||
|
function handleDeleteClick(media: ProjectMedia) {
|
||||||
|
deletingMedia = media;
|
||||||
|
deleteModalOpen = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function confirmDelete() {
|
||||||
|
if (!projectId || !deletingMedia) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await deleteProjectMedia(projectId, deletingMedia.id);
|
||||||
|
mediaItems = mediaItems.filter((m) => m.id !== deletingMedia!.id);
|
||||||
|
onchange?.(mediaItems);
|
||||||
|
logger.info("Media deleted", { projectId, mediaId: deletingMedia.id });
|
||||||
|
} catch (err) {
|
||||||
|
logger.error("Failed to delete media", { error: err });
|
||||||
|
showError("Failed to delete media");
|
||||||
|
}
|
||||||
|
|
||||||
|
deletingMedia = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Error handling
|
||||||
|
function showError(msg: string) {
|
||||||
|
errorMessage = msg;
|
||||||
|
setTimeout(() => {
|
||||||
|
if (errorMessage === msg) {
|
||||||
|
errorMessage = null;
|
||||||
|
}
|
||||||
|
}, 5000);
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearError() {
|
||||||
|
errorMessage = null;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class={cn("space-y-1.5", className)}>
|
||||||
|
<div class="block text-sm font-medium text-admin-text">Media</div>
|
||||||
|
|
||||||
|
{#if !projectId}
|
||||||
|
<!-- Disabled state for new projects -->
|
||||||
|
<div
|
||||||
|
class="rounded-lg border-2 border-dashed border-admin-border bg-admin-bg-secondary p-8 text-center"
|
||||||
|
>
|
||||||
|
<IconCloudUpload class="size-8 text-admin-text-muted mb-2 mx-auto" />
|
||||||
|
<p class="text-sm text-admin-text-muted">
|
||||||
|
Save the project first to enable media uploads
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<!-- Media grid (if has media) -->
|
||||||
|
{#if mediaItems.length > 0}
|
||||||
|
<div
|
||||||
|
use:dndzone={{
|
||||||
|
items: mediaItems,
|
||||||
|
flipDurationMs,
|
||||||
|
dropTargetStyle: {},
|
||||||
|
}}
|
||||||
|
onconsider={handleDndConsider}
|
||||||
|
onfinalize={handleDndFinalize}
|
||||||
|
class="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-3 mb-3"
|
||||||
|
>
|
||||||
|
{#each mediaItems as item (item.id)}
|
||||||
|
<div animate:flip={{ duration: flipDurationMs }}>
|
||||||
|
<MediaItem media={item} ondelete={() => handleDeleteClick(item)} />
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Upload drop zone -->
|
||||||
|
<div
|
||||||
|
role="button"
|
||||||
|
tabindex="0"
|
||||||
|
class={cn(
|
||||||
|
"rounded-lg border-2 border-dashed p-6 text-center cursor-pointer transition-colors",
|
||||||
|
isDraggingFile
|
||||||
|
? "border-admin-accent bg-admin-accent/10"
|
||||||
|
: "border-admin-border bg-admin-bg-secondary hover:border-admin-text-muted hover:bg-admin-surface",
|
||||||
|
)}
|
||||||
|
ondragenter={handleDragEnter}
|
||||||
|
ondragleave={handleDragLeave}
|
||||||
|
ondragover={handleDragOver}
|
||||||
|
ondrop={handleDrop}
|
||||||
|
onclick={() => fileInputRef?.click()}
|
||||||
|
onkeydown={(e) => e.key === "Enter" && fileInputRef?.click()}
|
||||||
|
>
|
||||||
|
<IconCloudUpload
|
||||||
|
class={cn(
|
||||||
|
"size-6 mb-2 mx-auto",
|
||||||
|
isDraggingFile ? "text-admin-accent" : "text-admin-text-muted",
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<p class="text-sm text-admin-text">
|
||||||
|
{isDraggingFile
|
||||||
|
? "Drop files here"
|
||||||
|
: "Drop files here or click to upload"}
|
||||||
|
</p>
|
||||||
|
<p class="text-xs text-admin-text-muted mt-1">
|
||||||
|
JPEG, PNG, GIF, WebP, MP4, WebM
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<input
|
||||||
|
bind:this={fileInputRef}
|
||||||
|
type="file"
|
||||||
|
accept={SUPPORTED_TYPES.join(",")}
|
||||||
|
multiple
|
||||||
|
class="hidden"
|
||||||
|
onchange={handleFileInputChange}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- Upload queue -->
|
||||||
|
{#if uploadQueue.length > 0}
|
||||||
|
<div class="space-y-2 mt-3">
|
||||||
|
{#each uploadQueue as task (task.id)}
|
||||||
|
<div
|
||||||
|
class="flex items-center gap-3 p-2 rounded-lg bg-admin-bg-secondary border border-admin-border"
|
||||||
|
>
|
||||||
|
{#if task.status === "error"}
|
||||||
|
<IconAlertCircle class="size-4 text-red-500 shrink-0" />
|
||||||
|
{:else}
|
||||||
|
<IconLoader
|
||||||
|
class="size-4 text-admin-text-muted animate-spin shrink-0"
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
<div class="flex-1 min-w-0">
|
||||||
|
<p class="text-sm text-admin-text truncate">{task.file.name}</p>
|
||||||
|
{#if task.status === "uploading"}
|
||||||
|
<div
|
||||||
|
class="h-1.5 bg-admin-border rounded-full mt-1 overflow-hidden"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="h-full bg-admin-accent transition-all duration-200"
|
||||||
|
style="width: {task.progress}%"
|
||||||
|
></div>
|
||||||
|
</div>
|
||||||
|
{:else if task.status === "error"}
|
||||||
|
<p class="text-xs text-red-500 truncate">{task.error}</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{#if task.status === "error"}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={() => removeUploadTask(task.id)}
|
||||||
|
class="text-admin-text-muted hover:text-admin-text p-1"
|
||||||
|
aria-label="Dismiss error"
|
||||||
|
>
|
||||||
|
<IconX class="size-4" />
|
||||||
|
</button>
|
||||||
|
{:else}
|
||||||
|
<span class="text-xs text-admin-text-muted">{task.progress}%</span
|
||||||
|
>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Error message -->
|
||||||
|
{#if errorMessage}
|
||||||
|
<div
|
||||||
|
class="flex items-center gap-2 p-3 mt-2 rounded-lg bg-red-500/10 border border-red-500/30 text-red-500 text-sm"
|
||||||
|
>
|
||||||
|
<IconAlertCircle class="size-4 shrink-0" />
|
||||||
|
<span>{errorMessage}</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={clearError}
|
||||||
|
class="ml-auto hover:text-red-400"
|
||||||
|
aria-label="Dismiss error"
|
||||||
|
>
|
||||||
|
<IconX class="size-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<p class="text-xs text-admin-text-muted">
|
||||||
|
{#if projectId}
|
||||||
|
Drag to reorder. First image is shown as the project thumbnail.
|
||||||
|
{:else}
|
||||||
|
Media can be uploaded after saving the project.
|
||||||
|
{/if}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Delete confirmation modal -->
|
||||||
|
<Modal
|
||||||
|
bind:open={deleteModalOpen}
|
||||||
|
title="Delete Media"
|
||||||
|
description="Are you sure you want to delete this media? This action cannot be undone."
|
||||||
|
confirmText="Delete"
|
||||||
|
confirmVariant="danger"
|
||||||
|
onconfirm={confirmDelete}
|
||||||
|
oncancel={() => (deletingMedia = null)}
|
||||||
|
/>
|
||||||
@@ -2,6 +2,7 @@
|
|||||||
import Button from "./Button.svelte";
|
import Button from "./Button.svelte";
|
||||||
import Input from "./Input.svelte";
|
import Input from "./Input.svelte";
|
||||||
import TagPicker from "./TagPicker.svelte";
|
import TagPicker from "./TagPicker.svelte";
|
||||||
|
import MediaManager from "./MediaManager.svelte";
|
||||||
import type {
|
import type {
|
||||||
AdminProject,
|
AdminProject,
|
||||||
CreateProjectData,
|
CreateProjectData,
|
||||||
@@ -177,17 +178,8 @@
|
|||||||
placeholder="Search and select tags..."
|
placeholder="Search and select tags..."
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<!-- Media Upload Placeholder -->
|
<!-- Media -->
|
||||||
<div class="space-y-1.5">
|
<MediaManager projectId={project?.id ?? null} media={project?.media ?? []} />
|
||||||
<div class="block text-sm font-medium text-admin-text">Media</div>
|
|
||||||
<Button type="button" variant="secondary" disabled class="w-full">
|
|
||||||
<i class="fa-solid fa-upload mr-2"></i>
|
|
||||||
Upload Images/Videos (Coming Soon)
|
|
||||||
</Button>
|
|
||||||
<p class="text-xs text-admin-text-muted">
|
|
||||||
Media upload functionality will be available soon
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Actions -->
|
<!-- Actions -->
|
||||||
<div class="flex justify-end gap-3 pt-4 border-t border-admin-border">
|
<div class="flex justify-end gap-3 pt-4 border-t border-admin-border">
|
||||||
|
|||||||
@@ -0,0 +1,60 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
interface Props {
|
||||||
|
src: string;
|
||||||
|
onload?: () => void;
|
||||||
|
class?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { src, onload, class: className }: Props = $props();
|
||||||
|
|
||||||
|
let canvasRef: HTMLCanvasElement | null = $state(null);
|
||||||
|
let loaded = $state(false);
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (!canvasRef || !src || loaded) return;
|
||||||
|
|
||||||
|
const video = document.createElement("video");
|
||||||
|
video.crossOrigin = "anonymous";
|
||||||
|
video.muted = true;
|
||||||
|
video.playsInline = true;
|
||||||
|
video.preload = "metadata";
|
||||||
|
|
||||||
|
video.onloadeddata = () => {
|
||||||
|
// Seek to 0.1s to avoid black frames
|
||||||
|
video.currentTime = 0.1;
|
||||||
|
};
|
||||||
|
|
||||||
|
video.onseeked = () => {
|
||||||
|
if (!canvasRef) return;
|
||||||
|
|
||||||
|
const ctx = canvasRef.getContext("2d");
|
||||||
|
if (ctx) {
|
||||||
|
// Set canvas size to match video
|
||||||
|
canvasRef.width = video.videoWidth;
|
||||||
|
canvasRef.height = video.videoHeight;
|
||||||
|
|
||||||
|
// Draw the frame
|
||||||
|
ctx.drawImage(video, 0, 0, video.videoWidth, video.videoHeight);
|
||||||
|
loaded = true;
|
||||||
|
onload?.();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clean up
|
||||||
|
video.src = "";
|
||||||
|
video.load();
|
||||||
|
};
|
||||||
|
|
||||||
|
video.onerror = () => {
|
||||||
|
// Still call onload to remove loading state
|
||||||
|
loaded = true;
|
||||||
|
onload?.();
|
||||||
|
};
|
||||||
|
|
||||||
|
video.src = src;
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<canvas
|
||||||
|
bind:this={canvasRef}
|
||||||
|
class={className ?? "absolute inset-0 w-full h-full object-cover"}
|
||||||
|
></canvas>
|
||||||
@@ -1,98 +0,0 @@
|
|||||||
export interface MockProjectTag {
|
|
||||||
name: string;
|
|
||||||
icon: string; // Icon identifier like "simple-icons:rust"
|
|
||||||
color?: string; // Hex color without hash
|
|
||||||
iconSvg?: string; // Pre-rendered SVG (populated server-side)
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface MockProject {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
description: string;
|
|
||||||
url: string;
|
|
||||||
tags: MockProjectTag[];
|
|
||||||
lastActivity: string;
|
|
||||||
clockIconSvg?: string; // Pre-rendered clock icon for "Updated" text
|
|
||||||
}
|
|
||||||
|
|
||||||
export const MOCK_PROJECTS: MockProject[] = [
|
|
||||||
{
|
|
||||||
id: "1",
|
|
||||||
name: "xevion.dev",
|
|
||||||
description:
|
|
||||||
"Personal portfolio showcasing projects and technical expertise. Built with Rust backend, SvelteKit frontend, and PostgreSQL.",
|
|
||||||
url: "https://github.com/Xevion/xevion.dev",
|
|
||||||
tags: [
|
|
||||||
{ name: "Rust", icon: "simple-icons:rust", color: "f97316" },
|
|
||||||
{ name: "SvelteKit", icon: "simple-icons:svelte", color: "f43f5e" },
|
|
||||||
{ name: "PostgreSQL", icon: "cib:postgresql", color: "3b82f6" },
|
|
||||||
],
|
|
||||||
lastActivity: "2026-01-06T22:12:37Z",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "2",
|
|
||||||
name: "historee",
|
|
||||||
description:
|
|
||||||
"Powerful browser history analyzer for visualizing and understanding web browsing patterns across multiple browsers.",
|
|
||||||
url: "https://github.com/Xevion/historee",
|
|
||||||
tags: [
|
|
||||||
{ name: "Rust", icon: "simple-icons:rust", color: "f97316" },
|
|
||||||
{ name: "CLI", icon: "lucide:terminal", color: "a1a1aa" },
|
|
||||||
{ name: "Analytics", icon: "lucide:bar-chart-3", color: "10b981" },
|
|
||||||
],
|
|
||||||
lastActivity: "2026-01-06T06:01:27Z",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "3",
|
|
||||||
name: "satori-html",
|
|
||||||
description:
|
|
||||||
"HTML adapter for Vercel's Satori library, enabling generation of beautiful social card images from HTML markup.",
|
|
||||||
url: "https://github.com/Xevion/satori-html",
|
|
||||||
tags: [
|
|
||||||
{ name: "TypeScript", icon: "simple-icons:typescript", color: "3b82f6" },
|
|
||||||
{ name: "NPM", icon: "simple-icons:npm", color: "ec4899" },
|
|
||||||
{ name: "Graphics", icon: "lucide:image", color: "a855f7" },
|
|
||||||
],
|
|
||||||
lastActivity: "2026-01-05T20:23:07Z",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "4",
|
|
||||||
name: "byte-me",
|
|
||||||
description:
|
|
||||||
"Cross-platform media bitrate visualizer with real-time analysis. Built with Tauri for native performance and modern UI.",
|
|
||||||
url: "https://github.com/Xevion/byte-me",
|
|
||||||
tags: [
|
|
||||||
{ name: "Rust", icon: "simple-icons:rust", color: "f97316" },
|
|
||||||
{ name: "Tauri", icon: "simple-icons:tauri", color: "14b8a6" },
|
|
||||||
{ name: "Desktop", icon: "lucide:monitor", color: "6366f1" },
|
|
||||||
{ name: "Media", icon: "lucide:video", color: "f43f5e" },
|
|
||||||
],
|
|
||||||
lastActivity: "2026-01-05T05:09:09Z",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "5",
|
|
||||||
name: "rdap",
|
|
||||||
description:
|
|
||||||
"Modern RDAP query client for domain registration data lookup. Clean interface built with static Next.js for instant loads.",
|
|
||||||
url: "https://github.com/Xevion/rdap",
|
|
||||||
tags: [
|
|
||||||
{ name: "TypeScript", icon: "simple-icons:typescript", color: "3b82f6" },
|
|
||||||
{ name: "Next.js", icon: "simple-icons:nextdotjs", color: "a1a1aa" },
|
|
||||||
{ name: "Networking", icon: "lucide:network", color: "0ea5e9" },
|
|
||||||
],
|
|
||||||
lastActivity: "2026-01-05T10:36:55Z",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "6",
|
|
||||||
name: "rebinded",
|
|
||||||
description:
|
|
||||||
"Cross-platform key remapping daemon with per-application context awareness and intelligent stateful debouncing.",
|
|
||||||
url: "https://github.com/Xevion/rebinded",
|
|
||||||
tags: [
|
|
||||||
{ name: "Rust", icon: "simple-icons:rust", color: "f97316" },
|
|
||||||
{ name: "System", icon: "lucide:settings-2", color: "a1a1aa" },
|
|
||||||
{ name: "Cross-platform", icon: "lucide:globe", color: "22c55e" },
|
|
||||||
],
|
|
||||||
lastActivity: "2026-01-01T00:34:09Z",
|
|
||||||
},
|
|
||||||
];
|
|
||||||
Reference in New Issue
Block a user