diff --git a/.gitignore b/.gitignore index bd9837b..69bf9fb 100644 --- a/.gitignore +++ b/.gitignore @@ -7,7 +7,7 @@ web/build/ web/.svelte-kit/ # CLI session file -.xevion-session +.*-session # Added by cargo /target diff --git a/.sqlx/query-2697c981355b4a1d46699aa5fe0f2dad6a07e7b46d7c546fa10e2568a35e710d.json b/.sqlx/query-2697c981355b4a1d46699aa5fe0f2dad6a07e7b46d7c546fa10e2568a35e710d.json new file mode 100644 index 0000000..a7ef4a7 --- /dev/null +++ b/.sqlx/query-2697c981355b4a1d46699aa5fe0f2dad6a07e7b46d7c546fa10e2568a35e710d.json @@ -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" +} diff --git a/.sqlx/query-52430d2d1ebf437bd5fac97ab7f3329e0883b4dae1a2ff93850e26f9bde31914.json b/.sqlx/query-52430d2d1ebf437bd5fac97ab7f3329e0883b4dae1a2ff93850e26f9bde31914.json new file mode 100644 index 0000000..6cf1627 --- /dev/null +++ b/.sqlx/query-52430d2d1ebf437bd5fac97ab7f3329e0883b4dae1a2ff93850e26f9bde31914.json @@ -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" +} diff --git a/.sqlx/query-54b5eb8bf65df8dd3caa1a1fcac9cf71cb9c665ac8d3b86dd63a9692788f7392.json b/.sqlx/query-54b5eb8bf65df8dd3caa1a1fcac9cf71cb9c665ac8d3b86dd63a9692788f7392.json new file mode 100644 index 0000000..62f8d0c --- /dev/null +++ b/.sqlx/query-54b5eb8bf65df8dd3caa1a1fcac9cf71cb9c665ac8d3b86dd63a9692788f7392.json @@ -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" +} diff --git a/.sqlx/query-9d0e8c98364de65920482389d7f1699ae4710394ed27b472d4e33190cdc0bd19.json b/.sqlx/query-9d0e8c98364de65920482389d7f1699ae4710394ed27b472d4e33190cdc0bd19.json new file mode 100644 index 0000000..fd5bd43 --- /dev/null +++ b/.sqlx/query-9d0e8c98364de65920482389d7f1699ae4710394ed27b472d4e33190cdc0bd19.json @@ -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" +} diff --git a/.sqlx/query-a9bd8fffc6963610443422abcc07352c88f6ccf65a51b3088ed21648c2b9c193.json b/.sqlx/query-a9bd8fffc6963610443422abcc07352c88f6ccf65a51b3088ed21648c2b9c193.json new file mode 100644 index 0000000..5abcf39 --- /dev/null +++ b/.sqlx/query-a9bd8fffc6963610443422abcc07352c88f6ccf65a51b3088ed21648c2b9c193.json @@ -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" +} diff --git a/.sqlx/query-aa216d4610fc0708f83704558e9f80449ebb401ccfebaeebed7bd09508a0acd3.json b/.sqlx/query-aa216d4610fc0708f83704558e9f80449ebb401ccfebaeebed7bd09508a0acd3.json new file mode 100644 index 0000000..89d546a --- /dev/null +++ b/.sqlx/query-aa216d4610fc0708f83704558e9f80449ebb401ccfebaeebed7bd09508a0acd3.json @@ -0,0 +1,14 @@ +{ + "db_name": "PostgreSQL", + "query": "DELETE FROM project_media WHERE id = $1", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Uuid" + ] + }, + "nullable": [] + }, + "hash": "aa216d4610fc0708f83704558e9f80449ebb401ccfebaeebed7bd09508a0acd3" +} diff --git a/.sqlx/query-e98512ebc020d1e9097d07c1ef443ed85243cca7a9a3af251b6ab9c2ef85385f.json b/.sqlx/query-e98512ebc020d1e9097d07c1ef443ed85243cca7a9a3af251b6ab9c2ef85385f.json new file mode 100644 index 0000000..90bec08 --- /dev/null +++ b/.sqlx/query-e98512ebc020d1e9097d07c1ef443ed85243cca7a9a3af251b6ab9c2ef85385f.json @@ -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" +} diff --git a/Cargo.lock b/Cargo.lock index b92b368..853b152 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,6 +2,12 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + [[package]] name = "aho-corasick" version = "1.1.4" @@ -11,6 +17,24 @@ dependencies = [ "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]] name = "allocator-api2" version = "0.2.21" @@ -67,6 +91,12 @@ dependencies = [ "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]] name = "api" version = "0.1.0" @@ -76,10 +106,12 @@ dependencies = [ "aws-sdk-s3", "axum", "axum-extra", + "blurhash", "clap", "dashmap", "dotenvy", "futures", + "image", "include_dir", "mime_guess", "moka", @@ -102,6 +134,23 @@ dependencies = [ "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]] name = "argon2" version = "0.5.3" @@ -114,6 +163,21 @@ dependencies = [ "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]] name = "async-lock" version = "3.4.2" @@ -146,6 +210,49 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" 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]] name = "aws-config" version = "1.8.12" @@ -595,6 +702,7 @@ dependencies = [ "matchit", "memchr", "mime", + "multer", "percent-encoding", "pin-project-lite", "serde_core", @@ -678,6 +786,12 @@ version = "1.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7d809780667f4410e7c41b07f52439b94d2bdf8528eeedc287fa38d3b7f95d82" +[[package]] +name = "bit_field" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e4b40c7323adcfc0a41c4b88143ed58346ff65a288fc144329c5c45e05d70c6" + [[package]] name = "bitflags" version = "2.10.0" @@ -687,6 +801,15 @@ dependencies = [ "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]] name = "blake2" version = "0.10.6" @@ -705,18 +828,42 @@ dependencies = [ "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]] name = "bumpalo" version = "3.19.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5dd9dc738b7a8311c7ade152424974d8115f2cdad61e8dab8dac9f2362298510" +[[package]] +name = "bytemuck" +version = "1.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbdf580320f38b612e485521afda1ee26d10cc9884efaaa750d383e13e3c5f4" + [[package]] name = "byteorder" version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" +[[package]] +name = "byteorder-lite" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495" + [[package]] name = "bytes" version = "1.11.0" @@ -812,6 +959,12 @@ dependencies = [ "cc", ] +[[package]] +name = "color_quant" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b" + [[package]] name = "colorchoice" version = "1.0.4" @@ -870,6 +1023,15 @@ version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" +[[package]] +name = "core2" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b49ba7ef1ad6107f8824dbe97de947cbaac53c44e7f9756a1fba0d37c1eec505" +dependencies = [ + "memchr", +] + [[package]] name = "cpufeatures" version = "0.2.17" @@ -925,6 +1087,16 @@ dependencies = [ "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]] name = "crossbeam-epoch" version = "0.9.18" @@ -949,6 +1121,12 @@ version = "0.8.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" +[[package]] +name = "crunchy" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" + [[package]] name = "crypto-bigint" version = "0.4.9" @@ -1111,6 +1289,26 @@ dependencies = [ "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]] name = "equivalent" version = "1.0.2" @@ -1159,12 +1357,56 @@ dependencies = [ "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]] name = "fastrand" version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" 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]] name = "ff" version = "0.12.1" @@ -1181,6 +1423,16 @@ version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" 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]] name = "flume" version = "0.11.1" @@ -1356,6 +1608,16 @@ dependencies = [ "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]] name = "group" version = "0.12.1" @@ -1405,6 +1667,17 @@ dependencies = [ "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]] name = "hashbrown" version = "0.14.5" @@ -1748,6 +2021,46 @@ dependencies = [ "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]] name = "include_dir" version = "0.7.4" @@ -1777,6 +2090,17 @@ dependencies = [ "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]] name = "ipnet" version = "2.11.0" @@ -1799,6 +2123,15 @@ version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" +[[package]] +name = "itertools" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285" +dependencies = [ + "either", +] + [[package]] name = "itoa" version = "1.0.17" @@ -1856,12 +2189,28 @@ dependencies = [ "spin", ] +[[package]] +name = "lebe" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a79a3332a6609480d7d0c9eab957bca6b455b91bb84e66d19f5ff66294b85b8" + [[package]] name = "libc" version = "0.2.179" source = "registry+https://github.com/rust-lang/crates.io-index" 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]] name = "libm" version = "0.2.15" @@ -1910,6 +2259,15 @@ version = "0.4.29" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" +[[package]] +name = "loop9" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fae87c125b03c1d2c0150c90365d7d6bcc53fb73a9acaef207d2d065860f062" +dependencies = [ + "imgref", +] + [[package]] name = "lru" version = "0.12.5" @@ -1940,6 +2298,16 @@ version = "0.8.4" source = "registry+https://github.com/rust-lang/crates.io-index" 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]] name = "md-5" version = "0.10.6" @@ -1972,6 +2340,16 @@ dependencies = [ "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]] name = "mio" version = "1.1.1" @@ -2003,6 +2381,54 @@ dependencies = [ "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]] name = "nu-ansi-term" version = "0.50.3" @@ -2012,6 +2438,16 @@ dependencies = [ "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]] name = "num-bigint-dig" version = "0.8.6" @@ -2034,6 +2470,17 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" 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]] name = "num-integer" version = "0.1.46" @@ -2054,6 +2501,17 @@ dependencies = [ "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]] name = "num-traits" version = "0.2.19" @@ -2139,6 +2597,18 @@ dependencies = [ "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]] name = "pem-rfc7468" version = "0.7.0" @@ -2203,6 +2673,19 @@ version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" 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]] name = "portable-atomic" version = "1.13.0" @@ -2242,6 +2725,49 @@ dependencies = [ "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]] name = "quinn" version = "0.11.9" @@ -2372,6 +2898,76 @@ dependencies = [ "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]] name = "redox_syscall" version = "0.5.18" @@ -2478,6 +3074,12 @@ dependencies = [ "zeroize", ] +[[package]] +name = "rgb" +version = "0.8.52" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c6a884d2998352bb4daf0183589aec883f16a6da1f4dde84d8e2e9a5409a1ce" + [[package]] name = "ring" version = "0.17.14" @@ -2847,6 +3449,21 @@ dependencies = [ "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]] name = "slab" version = "0.4.11" @@ -3224,6 +3841,20 @@ dependencies = [ "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]] name = "time" version = "0.3.44" @@ -3579,6 +4210,17 @@ dependencies = [ "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]] name = "valuable" version = "0.1.1" @@ -3761,6 +4403,12 @@ dependencies = [ "rustls-pki-types", ] +[[package]] +name = "weezl" +version = "0.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a28ac98ddc8b9274cb41bb4d9d4d5c425b6020c50c46f25559911905610b4a88" + [[package]] name = "whoami" version = "1.6.1" @@ -4092,6 +4740,12 @@ version = "0.13.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "66fee0b777b0f5ac1c69bb06d361268faafa61cd4682ae064a171c16c433e9e4" +[[package]] +name = "y4m" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a5a4b21e1a62b67a2970e6831bc091d7b87e119e7f9791aef9702e3bef04448" + [[package]] name = "yoke" version = "0.8.1" @@ -4200,3 +4854,42 @@ name = "zmij" version = "1.0.10" source = "registry+https://github.com/rust-lang/crates.io-index" 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", +] diff --git a/Cargo.toml b/Cargo.toml index aa2157e..eaa5f00 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -11,12 +11,14 @@ path = "src/main.rs" argon2 = "0.5" aws-config = "1.8.12" aws-sdk-s3 = "1.119.0" -axum = "0.8.8" +axum = { version = "0.8.8", features = ["multipart"] } axum-extra = { version = "0.12", features = ["cookie"] } +blurhash = "0.2.3" clap = { version = "4.5.54", features = ["derive", "env"] } dashmap = "6.1.0" dotenvy = "0.15" futures = "0.3.31" +image = "0.25.9" include_dir = "0.7.4" mime_guess = "2.0.5" moka = { version = "0.12.12", features = ["future"] } diff --git a/migrations/20260115031618_add_project_media.sql b/migrations/20260115031618_add_project_media.sql new file mode 100644 index 0000000..e44e78e --- /dev/null +++ b/migrations/20260115031618_add_project_media.sql @@ -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); diff --git a/src/cli/serve.rs b/src/cli/serve.rs index c14dfcf..2154129 100644 --- a/src/cli/serve.rs +++ b/src/cli/serve.rs @@ -1,8 +1,9 @@ +use axum::extract::DefaultBodyLimit; use std::collections::HashSet; use std::net::SocketAddr; use std::sync::Arc; use std::time::Duration; -use tower_http::{cors::CorsLayer, limit::RequestBodyLimitLayer}; +use tower_http::cors::CorsLayer; use crate::cache::{IsrCache, IsrCacheConfig}; use crate::config::ListenAddr; @@ -218,7 +219,8 @@ pub async fn run( router .layer(RequestIdLayer::new(trust_request_id)) .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(); diff --git a/src/db/media.rs b/src/db/media.rs new file mode 100644 index 0000000..b95da00 --- /dev/null +++ b/src/db/media.rs @@ -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, + pub height: Option, + pub size_bytes: i64, + pub blurhash: Option, + pub metadata: Option, + 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, +} + +/// 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, +} + +/// 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, +} + +/// 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, + #[serde(skip_serializing_if = "Option::is_none")] + pub medium: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub full: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub original: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub poster: Option, + // For video original (different structure) + #[serde(skip_serializing_if = "Option::is_none")] + pub video: Option, +} + +/// 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, + #[serde(skip_serializing_if = "Option::is_none")] + pub alt_text: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub duration: Option, +} + +#[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, + #[serde(skip_serializing_if = "Option::is_none")] + pub metadata: Option, +} + +/// 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::(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::(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::(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::(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::(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::(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, 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, 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 { + 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, + height: Option, + size_bytes: i64, + blurhash: Option<&str>, + metadata: Option, +) -> Result { + 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, 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 { + 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 +} diff --git a/src/db/mod.rs b/src/db/mod.rs index c7ef4ea..6fdd500 100644 --- a/src/db/mod.rs +++ b/src/db/mod.rs @@ -1,8 +1,10 @@ +pub mod media; pub mod projects; pub mod settings; pub mod tags; // Re-export all types and functions +pub use media::*; pub use projects::*; pub use settings::*; pub use tags::*; diff --git a/src/db/projects.rs b/src/db/projects.rs index 41afa02..e1570be 100644 --- a/src/db/projects.rs +++ b/src/db/projects.rs @@ -5,7 +5,9 @@ use time::{OffsetDateTime, format_description::well_known::Rfc3339}; use uuid::Uuid; use super::{ - ProjectStatus, slugify, + ProjectStatus, + media::{ApiProjectMedia, DbProjectMedia, get_media_for_project}, + slugify, tags::{ApiTag, DbTag, get_tags_for_project}, }; @@ -49,6 +51,7 @@ pub struct ApiAdminProject { #[serde(flatten)] pub project: ApiProject, pub tags: Vec, + pub media: Vec, pub status: String, pub description: String, #[serde(skip_serializing_if = "Option::is_none")] @@ -87,7 +90,11 @@ impl DbProject { } } - pub fn to_api_admin_project(&self, tags: Vec) -> ApiAdminProject { + pub fn to_api_admin_project( + &self, + tags: Vec, + media: Vec, + ) -> ApiAdminProject { let last_activity = self .last_github_activity .unwrap_or(self.created_at) @@ -97,6 +104,7 @@ impl DbProject { ApiAdminProject { project: self.to_api_project(), 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(), description: self.description.clone(), github_repo: self.github_repo.clone(), @@ -173,13 +181,14 @@ pub async fn get_public_projects(pool: &PgPool) -> Result, sqlx:: pub async fn get_public_projects_with_tags( pool: &PgPool, -) -> Result)>, sqlx::Error> { +) -> Result, Vec)>, sqlx::Error> { let projects = get_public_projects(pool).await?; let mut result = Vec::new(); for project in projects { 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) @@ -210,16 +219,17 @@ pub async fn get_all_projects_admin(pool: &PgPool) -> Result, sql .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( pool: &PgPool, -) -> Result)>, sqlx::Error> { +) -> Result, Vec)>, sqlx::Error> { let projects = get_all_projects_admin(pool).await?; let mut result = Vec::new(); for project in projects { 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) @@ -252,17 +262,18 @@ pub async fn get_project_by_id(pool: &PgPool, id: Uuid) -> Result Result)>, sqlx::Error> { +) -> Result, Vec)>, sqlx::Error> { let project = get_project_by_id(pool, id).await?; match project { Some(p) => { 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), } diff --git a/src/handlers/media.rs b/src/handlers/media.rs new file mode 100644 index 0000000..e2b54df --- /dev/null +++ b/src/handlers/media.rs @@ -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, +} + +/// 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>, + axum::extract::Path(project_id): axum::extract::Path, + 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)>, 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>, + axum::extract::Path(project_id): axum::extract::Path, +) -> 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 = + 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>, + 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>, + axum::extract::Path(project_id): axum::extract::Path, + jar: axum_extra::extract::CookieJar, + Json(payload): Json, +) -> 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, _> = 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 = + 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 diff --git a/src/handlers/mod.rs b/src/handlers/mod.rs index e386625..e7711f9 100644 --- a/src/handlers/mod.rs +++ b/src/handlers/mod.rs @@ -1,6 +1,7 @@ pub mod assets; pub mod auth; pub mod health; +pub mod media; pub mod projects; pub mod settings; pub mod tags; @@ -9,6 +10,7 @@ pub mod tags; pub use assets::*; pub use auth::*; pub use health::*; +pub use media::*; pub use projects::*; pub use settings::*; pub use tags::*; diff --git a/src/handlers/projects.rs b/src/handlers/projects.rs index c171bab..de8a802 100644 --- a/src/handlers/projects.rs +++ b/src/handlers/projects.rs @@ -11,12 +11,12 @@ pub async fn projects_handler( let is_admin = auth::check_session(&state, &jar).is_some(); 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 { Ok(projects_with_tags) => { let response: Vec = projects_with_tags .into_iter() - .map(|(project, tags)| project.to_api_admin_project(tags)) + .map(|(project, tags, media)| project.to_api_admin_project(tags, media)) .collect(); Json(response).into_response() } @@ -33,12 +33,12 @@ pub async fn projects_handler( } } } 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 { Ok(projects_with_tags) => { let response: Vec = projects_with_tags .into_iter() - .map(|(project, tags)| project.to_api_admin_project(tags)) + .map(|(project, tags, media)| project.to_api_admin_project(tags, media)) .collect(); Json(response).into_response() } @@ -80,7 +80,7 @@ pub async fn get_project_handler( let is_admin = auth::check_session(&state, &jar).is_some(); 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.status == db::ProjectStatus::Hidden && !is_admin { return ( @@ -93,7 +93,7 @@ pub async fn get_project_handler( .into_response(); } - Json(project.to_api_admin_project(tags)).into_response() + Json(project.to_api_admin_project(tags, media)).into_response() } Ok(None) => ( 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"); } - // Fetch project with tags to return - let (project, tags) = match db::get_project_by_id_with_tags(&state.pool, project.id).await { + // Fetch project with tags and media to return + let (project, tags, media) = match db::get_project_by_id_with_tags(&state.pool, project.id) + .await + { Ok(Some(data)) => data, Ok(None) => { tracing::error!(project_id = %project.id, "Project not found after creation"); @@ -248,7 +250,7 @@ pub async fn create_project_handler( ( StatusCode::CREATED, - Json(project.to_api_admin_project(tags)), + Json(project.to_api_admin_project(tags, media)), ) .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"); } - // Fetch updated project with tags - let (project, tags) = match db::get_project_by_id_with_tags(&state.pool, project.id).await { + // Fetch updated project with tags and media + let (project, tags, media) = match db::get_project_by_id_with_tags(&state.pool, project.id) + .await + { Ok(Some(data)) => data, Ok(None) => { 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 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) @@ -446,32 +450,33 @@ pub async fn delete_project_handler( }; // Fetch project before deletion to return it - let (project, tags) = match db::get_project_by_id_with_tags(&state.pool, project_id).await { - Ok(Some(data)) => data, - 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 fetch project before deletion"); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(serde_json::json!({ - "error": "Internal server error", - "message": "Failed to delete project" - })), - ) - .into_response(); - } - }; + let (project, tags, media) = + match db::get_project_by_id_with_tags(&state.pool, project_id).await { + Ok(Some(data)) => data, + 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 fetch project before deletion"); + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(serde_json::json!({ + "error": "Internal server error", + "message": "Failed to delete project" + })), + ) + .into_response(); + } + }; - // Delete project (CASCADE handles tags) + // Delete project (CASCADE handles tags and media) match db::delete_project(&state.pool, project_id).await { Ok(()) => { 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 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) => { tracing::error!(error = %err, "Failed to delete project"); diff --git a/src/main.rs b/src/main.rs index e8c7c9d..eb3b8ec 100644 --- a/src/main.rs +++ b/src/main.rs @@ -12,6 +12,7 @@ mod github; mod handlers; mod health; mod http; +mod media_processing; mod middleware; mod og; mod proxy; diff --git a/src/media_processing.rs b/src/media_processing.rs new file mode 100644 index 0000000..388e6cf --- /dev/null +++ b/src/media_processing.rs @@ -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, + pub width: u32, + pub height: u32, +} + +/// Original image info +#[derive(Debug)] +pub struct OriginalImage { + pub data: Vec, + 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 { + // 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 { + 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 { + // 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")); + } +} diff --git a/src/og.rs b/src/og.rs index 3f3c3a2..8a33dff 100644 --- a/src/og.rs +++ b/src/og.rs @@ -57,7 +57,7 @@ pub async fn generate_og_image(spec: &OGImageSpec, state: Arc) -> Resu .map_err(|e| format!("Failed to read response: {e}"))? .to_vec(); - r2.put_object(&r2_key, bytes) + r2.put_object(&r2_key, bytes, "image/png") .await .map_err(|e| format!("Failed to upload to R2: {e}"))?; diff --git a/src/r2.rs b/src/r2.rs index 685d872..5e7d16a 100644 --- a/src/r2.rs +++ b/src/r2.rs @@ -78,13 +78,18 @@ impl R2Client { Ok(bytes) } - pub async fn put_object(&self, key: &str, body: Vec) -> Result<(), String> { + pub async fn put_object( + &self, + key: &str, + body: Vec, + content_type: &str, + ) -> Result<(), String> { self.client .put_object() .bucket(&self.bucket) .key(key) .body(ByteStream::from(body)) - .content_type("image/png") + .content_type(content_type) .send() .await .map_err(|e| format!("Failed to put object to R2: {e}"))?; @@ -92,6 +97,42 @@ impl R2Client { 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 { + 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 { self.client .head_object() diff --git a/src/routes.rs b/src/routes.rs index 769fee8..0db5376 100644 --- a/src/routes.rs +++ b/src/routes.rs @@ -4,7 +4,7 @@ use axum::{ extract::Request, http::{Method, Uri}, response::IntoResponse, - routing::{any, get, post}, + routing::{any, delete, get, post, put}, }; use std::sync::Arc; @@ -41,7 +41,20 @@ pub fn api_routes() -> Router> { ) .route( "/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 .route( diff --git a/web/bun.lock b/web/bun.lock index d7df901..372aa14 100644 --- a/web/bun.lock +++ b/web/bun.lock @@ -14,12 +14,14 @@ "@logtape/logtape": "^1.3.5", "@resvg/resvg-js": "^2.6.2", "@xevion/satori-html": "^0.4.1", + "blurhash": "^2.0.5", "clsx": "^2.1.1", "overlayscrollbars": "^2.13.0", "overlayscrollbars-svelte": "^0.5.5", "posthog-js": "^1.321.1", "posthog-node": "^5.21.0", "satori": "^0.18.3", + "svelte-dnd-action": "^0.9.69", "tailwind-merge": "^3.3.1", }, "devDependencies": { @@ -412,6 +414,8 @@ "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=="], "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-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=="], "tailwind-merge": ["tailwind-merge@3.4.0", "", {}, "sha512-uSaO4gnW+b3Y2aWoWfFpX62vn2sR3skfhbjsEnaBI81WD1wBLlHZe5sWf0AqjksNdYTbGBEd0UasQMT3SNV15g=="], diff --git a/web/package.json b/web/package.json index 0e51a09..9c3128a 100644 --- a/web/package.json +++ b/web/package.json @@ -26,12 +26,14 @@ "@logtape/logtape": "^1.3.5", "@resvg/resvg-js": "^2.6.2", "@xevion/satori-html": "^0.4.1", + "blurhash": "^2.0.5", "clsx": "^2.1.1", "overlayscrollbars": "^2.13.0", "overlayscrollbars-svelte": "^0.5.5", "posthog-js": "^1.321.1", "posthog-node": "^5.21.0", "satori": "^0.18.3", + "svelte-dnd-action": "^0.9.69", "tailwind-merge": "^3.3.1" }, "devDependencies": { diff --git a/web/src/lib/admin-types.ts b/web/src/lib/admin-types.ts index a4f6cbe..cdc1f16 100644 --- a/web/src/lib/admin-types.ts +++ b/web/src/lib/admin-types.ts @@ -19,6 +19,45 @@ export interface TagWithIcon extends AdminTag { 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 { id: string; slug: string; @@ -28,6 +67,7 @@ export interface AdminProject { status: ProjectStatus; links: Array<{ url: string; title?: string }>; tags: AdminTag[]; + media: ProjectMedia[]; githubRepo?: string | null; demoUrl?: string | null; createdAt: string; diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts index 8627a82..ee558ae 100644 --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -9,6 +9,7 @@ import type { CreateTagData, UpdateTagData, SiteSettings, + ProjectMedia, } from "./admin-types"; import { ApiError } from "./errors"; @@ -134,6 +135,68 @@ export async function getRelatedTags(slug: string): Promise { return clientApiFetch(`/api/tags/${slug}/related`); } +// Admin Media API +export async function uploadProjectMedia( + projectId: string, + file: File, + onProgress?: (percent: number) => void, +): Promise { + 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 { + return clientApiFetch( + `/api/projects/${projectId}/media/${mediaId}`, + { method: "DELETE" }, + ); +} + +export async function reorderProjectMedia( + projectId: string, + mediaIds: string[], +): Promise { + return clientApiFetch( + `/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) export async function getAdminEvents(): Promise { // TODO: Implement when events table is added to backend diff --git a/web/src/lib/components/ProjectCard.svelte b/web/src/lib/components/ProjectCard.svelte index 44c4d10..34e66a7 100644 --- a/web/src/lib/components/ProjectCard.svelte +++ b/web/src/lib/components/ProjectCard.svelte @@ -30,44 +30,28 @@ project.demoUrl ? "demo_click" : project.githubRepo ? "github_click" : null, ); - // Random seed generated once per component instance (changes on each page load) - const randomSeed = Math.floor(Math.random() * 1000); + // Get primary media (first by display order) if available + 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) - const isVideo = randomSeed % 10 < 6; + // Get media URLs from primary media + const videoUrl = $derived( + isVideo ? primaryMedia?.variants.video?.url : undefined, + ); - // Sample video URLs - const sampleVideos = [ - "https://storage.googleapis.com/gtv-videos-bucket/sample/ForBiggerBlazes.mp4", - "https://storage.googleapis.com/gtv-videos-bucket/sample/ForBiggerEscapes.mp4", - "https://storage.googleapis.com/gtv-videos-bucket/sample/ForBiggerFun.mp4", - "https://storage.googleapis.com/gtv-videos-bucket/sample/ForBiggerJoyrides.mp4", - "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 imageUrl = $derived( + !isVideo && primaryMedia + ? (primaryMedia.variants.medium?.url ?? + primaryMedia.variants.thumb?.url ?? + primaryMedia.variants.full?.url) + : undefined, + ); - const videoUrl = sampleVideos[randomSeed % sampleVideos.length]; - - // Randomized aspect ratios for images: [width, height] - 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 poster URL (for video media, use the poster variant) + const videoPosterUrl = $derived( + isVideo ? primaryMedia?.variants.poster?.url : undefined, + ); // Video element reference for play/pause control let videoElement: HTMLVideoElement | null = $state(null); @@ -131,34 +115,35 @@ role={isLink ? undefined : "article"} 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", - { - "transition-all hover:border-zinc-300 dark:hover:border-zinc-700 hover:bg-zinc-100/80 dark:hover:bg-zinc-800/50": - isLink, - className: true, - }, + isLink && + "transition-all hover:border-zinc-300 dark:hover:border-zinc-700 hover:bg-zinc-100/80 dark:hover:bg-zinc-800/50", + className, )} > - + {#if hasMedia} + + {/if}
+ 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; + } + + + +
+ +
+ + {#if media.blurhash && !imageLoaded} + + {/if} + + + {#if media.mediaType === "video" && videoUrl} + + + {:else if thumbUrl} + + {:else} + +
+ {#if media.mediaType === "video"} + + {:else} + + {/if} +
+ {/if} + + + {#if media.mediaType === "video"} +
+ + Video +
+ {/if} +
+ + + +
diff --git a/web/src/lib/components/admin/MediaManager.svelte b/web/src/lib/components/admin/MediaManager.svelte new file mode 100644 index 0000000..f89f97e --- /dev/null +++ b/web/src/lib/components/admin/MediaManager.svelte @@ -0,0 +1,395 @@ + + +
+
Media
+ + {#if !projectId} + +
+ +

+ Save the project first to enable media uploads +

+
+ {:else} + + {#if mediaItems.length > 0} +
+ {#each mediaItems as item (item.id)} +
+ handleDeleteClick(item)} /> +
+ {/each} +
+ {/if} + + +
fileInputRef?.click()} + onkeydown={(e) => e.key === "Enter" && fileInputRef?.click()} + > + +

+ {isDraggingFile + ? "Drop files here" + : "Drop files here or click to upload"} +

+

+ JPEG, PNG, GIF, WebP, MP4, WebM +

+
+ + + + + {#if uploadQueue.length > 0} +
+ {#each uploadQueue as task (task.id)} +
+ {#if task.status === "error"} + + {:else} + + {/if} +
+

{task.file.name}

+ {#if task.status === "uploading"} +
+
+
+ {:else if task.status === "error"} +

{task.error}

+ {/if} +
+ {#if task.status === "error"} + + {:else} + {task.progress}% + {/if} +
+ {/each} +
+ {/if} + + + {#if errorMessage} +
+ + {errorMessage} + +
+ {/if} + {/if} + +

+ {#if projectId} + Drag to reorder. First image is shown as the project thumbnail. + {:else} + Media can be uploaded after saving the project. + {/if} +

+
+ + + (deletingMedia = null)} +/> diff --git a/web/src/lib/components/admin/ProjectForm.svelte b/web/src/lib/components/admin/ProjectForm.svelte index efc2b35..cf6f1bf 100644 --- a/web/src/lib/components/admin/ProjectForm.svelte +++ b/web/src/lib/components/admin/ProjectForm.svelte @@ -2,6 +2,7 @@ import Button from "./Button.svelte"; import Input from "./Input.svelte"; import TagPicker from "./TagPicker.svelte"; + import MediaManager from "./MediaManager.svelte"; import type { AdminProject, CreateProjectData, @@ -177,17 +178,8 @@ placeholder="Search and select tags..." /> - -
-
Media
- -

- Media upload functionality will be available soon -

-
+ +
diff --git a/web/src/lib/components/admin/VideoThumbnail.svelte b/web/src/lib/components/admin/VideoThumbnail.svelte new file mode 100644 index 0000000..68474e3 --- /dev/null +++ b/web/src/lib/components/admin/VideoThumbnail.svelte @@ -0,0 +1,60 @@ + + + diff --git a/web/src/lib/mock-data/projects.ts b/web/src/lib/mock-data/projects.ts deleted file mode 100644 index 3023a6e..0000000 --- a/web/src/lib/mock-data/projects.ts +++ /dev/null @@ -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", - }, -];