feat: add media upload pipeline with multipart support, blurhash generation, and R2 storage

- Add project_media table with image/video variants, ordering, and metadata
- Implement multipart upload handlers with 50MB limit
- Generate blurhash placeholders and resize images to thumb/medium/full variants
- Update ProjectCard to use media carousel instead of mock gradients
- Add MediaManager component for drag-drop upload and reordering
This commit is contained in:
2026-01-14 22:34:15 -06:00
parent 39a4e702fd
commit e83133cfcc
33 changed files with 3462 additions and 226 deletions
Vendored
+1 -1
View File
@@ -7,7 +7,7 @@ web/build/
web/.svelte-kit/
# CLI session file
.xevion-session
.*-session
# Added by cargo
/target
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -0,0 +1,14 @@
{
"db_name": "PostgreSQL",
"query": "DELETE FROM project_media WHERE id = $1",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Uuid"
]
},
"nullable": []
},
"hash": "aa216d4610fc0708f83704558e9f80449ebb401ccfebaeebed7bd09508a0acd3"
}
@@ -0,0 +1,22 @@
{
"db_name": "PostgreSQL",
"query": "\n SELECT COALESCE(MAX(display_order) + 1, 0) as \"next_order!\"\n FROM project_media\n WHERE project_id = $1\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "next_order!",
"type_info": "Int4"
}
],
"parameters": {
"Left": [
"Uuid"
]
},
"nullable": [
null
]
},
"hash": "e98512ebc020d1e9097d07c1ef443ed85243cca7a9a3af251b6ab9c2ef85385f"
}
Generated
+693
View File
@@ -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",
]
+3 -1
View File
@@ -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"] }
@@ -0,0 +1,24 @@
-- Project media table for carousel support
-- Each project can have multiple images/videos with ordering
CREATE TYPE media_type AS ENUM ('image', 'video');
CREATE TABLE project_media (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
project_id UUID NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
display_order INT NOT NULL DEFAULT 0,
media_type media_type NOT NULL,
original_filename TEXT NOT NULL,
r2_base_path TEXT NOT NULL,
variants JSONB NOT NULL,
width INT,
height INT,
size_bytes BIGINT NOT NULL,
blurhash TEXT,
metadata JSONB,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE (project_id, display_order)
);
CREATE INDEX idx_project_media_project_id ON project_media(project_id);
+4 -2
View File
@@ -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();
+450
View File
@@ -0,0 +1,450 @@
use serde::{Deserialize, Serialize};
use sqlx::PgPool;
use time::OffsetDateTime;
use uuid::Uuid;
/// Media type enum matching PostgreSQL enum
#[derive(Debug, Clone, Copy, PartialEq, Eq, sqlx::Type, Serialize, Deserialize)]
#[sqlx(type_name = "media_type", rename_all = "lowercase")]
#[serde(rename_all = "lowercase")]
pub enum MediaType {
Image,
Video,
}
/// Database model for project media
#[derive(Debug, Clone, sqlx::FromRow)]
pub struct DbProjectMedia {
pub id: Uuid,
pub project_id: Uuid,
pub display_order: i32,
pub media_type: MediaType,
pub original_filename: String,
pub r2_base_path: String,
pub variants: serde_json::Value,
pub width: Option<i32>,
pub height: Option<i32>,
pub size_bytes: i64,
pub blurhash: Option<String>,
pub metadata: Option<serde_json::Value>,
pub created_at: OffsetDateTime,
}
/// Variant info for images
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ImageVariant {
pub key: String,
pub width: i32,
pub height: i32,
#[serde(skip_serializing_if = "Option::is_none")]
pub mime: Option<String>,
}
/// Variant info for video poster
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct VideoOriginal {
pub key: String,
pub mime: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub duration: Option<f64>,
}
/// API response for media variant with full URL
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ApiMediaVariant {
pub url: String,
pub width: i32,
pub height: i32,
}
/// API response for video original
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ApiVideoOriginal {
pub url: String,
pub mime: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub duration: Option<f64>,
}
/// API response for media variants
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ApiMediaVariants {
#[serde(skip_serializing_if = "Option::is_none")]
pub thumb: Option<ApiMediaVariant>,
#[serde(skip_serializing_if = "Option::is_none")]
pub medium: Option<ApiMediaVariant>,
#[serde(skip_serializing_if = "Option::is_none")]
pub full: Option<ApiMediaVariant>,
#[serde(skip_serializing_if = "Option::is_none")]
pub original: Option<ApiMediaVariant>,
#[serde(skip_serializing_if = "Option::is_none")]
pub poster: Option<ApiMediaVariant>,
// For video original (different structure)
#[serde(skip_serializing_if = "Option::is_none")]
pub video: Option<ApiVideoOriginal>,
}
/// Optional metadata stored with media
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct MediaMetadata {
#[serde(skip_serializing_if = "Option::is_none")]
pub focal_point: Option<FocalPoint>,
#[serde(skip_serializing_if = "Option::is_none")]
pub alt_text: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub duration: Option<f64>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FocalPoint {
pub x: f64,
pub y: f64,
}
/// API response type for project media
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ApiProjectMedia {
pub id: String,
pub display_order: i32,
pub media_type: MediaType,
pub variants: ApiMediaVariants,
#[serde(skip_serializing_if = "Option::is_none")]
pub blurhash: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub metadata: Option<MediaMetadata>,
}
/// Base URL for R2 media storage
const R2_BASE_URL: &str = "https://media.xevion.dev";
impl DbProjectMedia {
/// Convert database media to API response format
pub fn to_api_media(&self) -> ApiProjectMedia {
let variants = self.build_api_variants();
let metadata = self
.metadata
.as_ref()
.and_then(|m| serde_json::from_value(m.clone()).ok());
ApiProjectMedia {
id: self.id.to_string(),
display_order: self.display_order,
media_type: self.media_type,
variants,
blurhash: self.blurhash.clone(),
metadata,
}
}
fn build_api_variants(&self) -> ApiMediaVariants {
let base_url = format!(
"{}/{}",
R2_BASE_URL,
self.r2_base_path.trim_end_matches('/')
);
let mut variants = ApiMediaVariants {
thumb: None,
medium: None,
full: None,
original: None,
poster: None,
video: None,
};
// Parse the JSONB variants
if let Some(obj) = self.variants.as_object() {
// Handle image variants
if let Some(thumb) = obj.get("thumb") {
if let Ok(v) = serde_json::from_value::<ImageVariant>(thumb.clone()) {
variants.thumb = Some(ApiMediaVariant {
url: format!("{}/{}", base_url, v.key),
width: v.width,
height: v.height,
});
}
}
if let Some(medium) = obj.get("medium") {
if let Ok(v) = serde_json::from_value::<ImageVariant>(medium.clone()) {
variants.medium = Some(ApiMediaVariant {
url: format!("{}/{}", base_url, v.key),
width: v.width,
height: v.height,
});
}
}
if let Some(full) = obj.get("full") {
if let Ok(v) = serde_json::from_value::<ImageVariant>(full.clone()) {
variants.full = Some(ApiMediaVariant {
url: format!("{}/{}", base_url, v.key),
width: v.width,
height: v.height,
});
}
}
// Handle original - could be image or video
if let Some(original) = obj.get("original") {
if self.media_type == MediaType::Video {
// Video original has different structure
if let Ok(v) = serde_json::from_value::<VideoOriginal>(original.clone()) {
variants.video = Some(ApiVideoOriginal {
url: format!("{}/{}", base_url, v.key),
mime: v.mime,
duration: v.duration,
});
}
} else {
// Image original
if let Ok(v) = serde_json::from_value::<ImageVariant>(original.clone()) {
variants.original = Some(ApiMediaVariant {
url: format!("{}/{}", base_url, v.key),
width: v.width,
height: v.height,
});
}
}
}
// Handle video poster
if let Some(poster) = obj.get("poster") {
if let Ok(v) = serde_json::from_value::<ImageVariant>(poster.clone()) {
variants.poster = Some(ApiMediaVariant {
url: format!("{}/{}", base_url, v.key),
width: v.width,
height: v.height,
});
}
}
}
variants
}
}
// Database query functions
/// Get all media for a project, ordered by display_order
pub async fn get_media_for_project(
pool: &PgPool,
project_id: Uuid,
) -> Result<Vec<DbProjectMedia>, sqlx::Error> {
sqlx::query_as!(
DbProjectMedia,
r#"
SELECT
id,
project_id,
display_order,
media_type as "media_type: MediaType",
original_filename,
r2_base_path,
variants,
width,
height,
size_bytes,
blurhash,
metadata,
created_at
FROM project_media
WHERE project_id = $1
ORDER BY display_order ASC
"#,
project_id
)
.fetch_all(pool)
.await
}
/// Get single media item by ID
pub async fn get_media_by_id(
pool: &PgPool,
id: Uuid,
) -> Result<Option<DbProjectMedia>, sqlx::Error> {
sqlx::query_as!(
DbProjectMedia,
r#"
SELECT
id,
project_id,
display_order,
media_type as "media_type: MediaType",
original_filename,
r2_base_path,
variants,
width,
height,
size_bytes,
blurhash,
metadata,
created_at
FROM project_media
WHERE id = $1
"#,
id
)
.fetch_optional(pool)
.await
}
/// Get the next display order for a project's media
pub async fn get_next_display_order(pool: &PgPool, project_id: Uuid) -> Result<i32, sqlx::Error> {
let result = sqlx::query!(
r#"
SELECT COALESCE(MAX(display_order) + 1, 0) as "next_order!"
FROM project_media
WHERE project_id = $1
"#,
project_id
)
.fetch_one(pool)
.await?;
Ok(result.next_order)
}
/// Create a new media record
pub async fn create_media(
pool: &PgPool,
project_id: Uuid,
media_type: MediaType,
original_filename: &str,
r2_base_path: &str,
variants: serde_json::Value,
width: Option<i32>,
height: Option<i32>,
size_bytes: i64,
blurhash: Option<&str>,
metadata: Option<serde_json::Value>,
) -> Result<DbProjectMedia, sqlx::Error> {
let display_order = get_next_display_order(pool, project_id).await?;
sqlx::query_as!(
DbProjectMedia,
r#"
INSERT INTO project_media (
project_id, display_order, media_type, original_filename,
r2_base_path, variants, width, height, size_bytes, blurhash, metadata
)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
RETURNING
id,
project_id,
display_order,
media_type as "media_type: MediaType",
original_filename,
r2_base_path,
variants,
width,
height,
size_bytes,
blurhash,
metadata,
created_at
"#,
project_id,
display_order,
media_type as MediaType,
original_filename,
r2_base_path,
variants,
width,
height,
size_bytes,
blurhash,
metadata
)
.fetch_one(pool)
.await
}
/// Delete a media record
pub async fn delete_media(pool: &PgPool, id: Uuid) -> Result<Option<DbProjectMedia>, sqlx::Error> {
// First get the media to return it
let media = get_media_by_id(pool, id).await?;
if media.is_some() {
sqlx::query!("DELETE FROM project_media WHERE id = $1", id)
.execute(pool)
.await?;
}
Ok(media)
}
/// Reorder media for a project
/// Takes a list of media IDs in desired order and updates display_order accordingly
pub async fn reorder_media(
pool: &PgPool,
project_id: Uuid,
media_ids: &[Uuid],
) -> Result<(), sqlx::Error> {
// Use a transaction to ensure atomicity
let mut tx = pool.begin().await?;
// First, set all to negative values to avoid unique constraint conflicts
for (i, id) in media_ids.iter().enumerate() {
sqlx::query!(
"UPDATE project_media SET display_order = $1 WHERE id = $2 AND project_id = $3",
-(i as i32 + 1),
id,
project_id
)
.execute(&mut *tx)
.await?;
}
// Then set to final positive values
for (i, id) in media_ids.iter().enumerate() {
sqlx::query!(
"UPDATE project_media SET display_order = $1 WHERE id = $2 AND project_id = $3",
i as i32,
id,
project_id
)
.execute(&mut *tx)
.await?;
}
tx.commit().await?;
Ok(())
}
/// Update media metadata (focal point, alt text, etc.)
pub async fn update_media_metadata(
pool: &PgPool,
id: Uuid,
metadata: serde_json::Value,
) -> Result<DbProjectMedia, sqlx::Error> {
sqlx::query_as!(
DbProjectMedia,
r#"
UPDATE project_media
SET metadata = $2
WHERE id = $1
RETURNING
id,
project_id,
display_order,
media_type as "media_type: MediaType",
original_filename,
r2_base_path,
variants,
width,
height,
size_bytes,
blurhash,
metadata,
created_at
"#,
id,
metadata
)
.fetch_one(pool)
.await
}
+2
View File
@@ -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::*;
+21 -10
View File
@@ -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<ApiTag>,
pub media: Vec<ApiProjectMedia>,
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<DbTag>) -> ApiAdminProject {
pub fn to_api_admin_project(
&self,
tags: Vec<DbTag>,
media: Vec<DbProjectMedia>,
) -> 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<Vec<DbProject>, sqlx::
pub async fn get_public_projects_with_tags(
pool: &PgPool,
) -> Result<Vec<(DbProject, Vec<DbTag>)>, sqlx::Error> {
) -> Result<Vec<(DbProject, Vec<DbTag>, Vec<DbProjectMedia>)>, sqlx::Error> {
let projects = get_public_projects(pool).await?;
let 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<Vec<DbProject>, 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<Vec<(DbProject, Vec<DbTag>)>, sqlx::Error> {
) -> Result<Vec<(DbProject, Vec<DbTag>, Vec<DbProjectMedia>)>, 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<Option<DbProje
.await
}
/// Get single project by ID with tags
/// Get single project by ID with tags and media
pub async fn get_project_by_id_with_tags(
pool: &PgPool,
id: Uuid,
) -> Result<Option<(DbProject, Vec<DbTag>)>, sqlx::Error> {
) -> Result<Option<(DbProject, Vec<DbTag>, Vec<DbProjectMedia>)>, sqlx::Error> {
let project = get_project_by_id(pool, id).await?;
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),
}
+709
View File
@@ -0,0 +1,709 @@
use axum::{
Json,
extract::{Multipart, State},
http::StatusCode,
response::IntoResponse,
};
use std::sync::Arc;
use ulid::Ulid;
use uuid::Uuid;
use crate::{auth, db, media_processing, r2::R2Client, state::AppState};
/// Request type for reordering media
#[derive(Debug, serde::Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ReorderMediaRequest {
/// Media IDs in desired order
pub media_ids: Vec<String>,
}
/// Upload media for a project (requires authentication)
///
/// Accepts multipart/form-data with a single file field.
/// Images are processed into variants (thumb, medium, full) and uploaded to R2.
pub async fn upload_media_handler(
State(state): State<Arc<AppState>>,
axum::extract::Path(project_id): axum::extract::Path<String>,
jar: axum_extra::extract::CookieJar,
mut multipart: Multipart,
) -> impl IntoResponse {
// Check auth
if auth::check_session(&state, &jar).is_none() {
return auth::require_auth_response().into_response();
}
let project_id = match Uuid::parse_str(&project_id) {
Ok(id) => id,
Err(_) => {
return (
StatusCode::BAD_REQUEST,
Json(serde_json::json!({
"error": "Invalid project ID",
"message": "Project ID must be a valid UUID"
})),
)
.into_response();
}
};
// Verify project exists
match db::get_project_by_id(&state.pool, project_id).await {
Ok(None) => {
return (
StatusCode::NOT_FOUND,
Json(serde_json::json!({
"error": "Not found",
"message": "Project not found"
})),
)
.into_response();
}
Err(err) => {
tracing::error!(error = %err, "Failed to check project existence");
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({
"error": "Internal server error",
"message": "Failed to verify project"
})),
)
.into_response();
}
Ok(Some(_)) => {}
}
// Get R2 client
let r2 = match R2Client::get().await {
Some(r2) => r2,
None => {
tracing::error!("R2 client not available");
return (
StatusCode::SERVICE_UNAVAILABLE,
Json(serde_json::json!({
"error": "Service unavailable",
"message": "Media storage is not configured"
})),
)
.into_response();
}
};
// Extract file from multipart
let (filename, content_type, data) = match extract_file(&mut multipart).await {
Ok(Some(file)) => file,
Ok(None) => {
return (
StatusCode::BAD_REQUEST,
Json(serde_json::json!({
"error": "Bad request",
"message": "No file provided"
})),
)
.into_response();
}
Err(err) => {
tracing::error!(error = %err, "Failed to extract file from multipart");
return (
StatusCode::BAD_REQUEST,
Json(serde_json::json!({
"error": "Bad request",
"message": format!("Failed to read upload: {err}")
})),
)
.into_response();
}
};
// Determine media type and process
let is_video = media_processing::is_supported_video(&content_type);
let is_image = media_processing::is_supported_image(&content_type);
if !is_video && !is_image {
return (
StatusCode::BAD_REQUEST,
Json(serde_json::json!({
"error": "Unsupported format",
"message": format!("Content type '{}' is not supported. Supported: JPEG, PNG, GIF, WebP, AVIF, MP4, WebM", content_type)
})),
)
.into_response();
}
// Generate unique asset ID
let asset_id = Ulid::new();
let r2_base_path = format!("projects/{project_id}/{asset_id}");
if is_image {
// Process image
let processed = match media_processing::process_image(&data, &filename) {
Ok(p) => p,
Err(err) => {
tracing::error!(error = %err, filename = %filename, "Failed to process image");
return (
StatusCode::BAD_REQUEST,
Json(serde_json::json!({
"error": "Processing failed",
"message": format!("Failed to process image: {err}")
})),
)
.into_response();
}
};
// Upload all variants to R2
if let Err(err) = upload_image_variants(&r2, &r2_base_path, &processed).await {
tracing::error!(error = %err, "Failed to upload image variants to R2");
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({
"error": "Upload failed",
"message": "Failed to upload media to storage"
})),
)
.into_response();
}
// Build variants JSON
let original_ext = filename.rsplit('.').next().unwrap_or("jpg");
let variants = serde_json::json!({
"thumb": {
"key": "thumb.webp",
"width": processed.thumb.width,
"height": processed.thumb.height
},
"medium": {
"key": "medium.webp",
"width": processed.medium.width,
"height": processed.medium.height
},
"full": {
"key": "full.webp",
"width": processed.full.width,
"height": processed.full.height
},
"original": {
"key": format!("original.{original_ext}"),
"width": processed.original.width,
"height": processed.original.height,
"mime": processed.original.mime
}
});
// Create database record
match db::create_media(
&state.pool,
project_id,
db::MediaType::Image,
&filename,
&r2_base_path,
variants,
Some(processed.original.width as i32),
Some(processed.original.height as i32),
data.len() as i64,
Some(&processed.blurhash),
None,
)
.await
{
Ok(media) => {
tracing::info!(
media_id = %media.id,
project_id = %project_id,
filename = %filename,
"Image uploaded successfully"
);
// Invalidate cache
state.isr_cache.invalidate("/").await;
(StatusCode::CREATED, Json(media.to_api_media())).into_response()
}
Err(err) => {
tracing::error!(error = %err, "Failed to create media record");
// TODO: Clean up R2 files on DB failure
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({
"error": "Database error",
"message": "Failed to save media record"
})),
)
.into_response()
}
}
} else {
// Video upload - basic support (no transcoding, ffmpeg poster extraction optional)
let original_ext = match content_type.as_str() {
"video/mp4" => "mp4",
"video/webm" => "webm",
"video/quicktime" => "mov",
_ => "mp4",
};
// Upload original video
let video_key = format!("{r2_base_path}/original.{original_ext}");
if let Err(err) = r2.put_object(&video_key, data.clone(), &content_type).await {
tracing::error!(error = %err, "Failed to upload video to R2");
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({
"error": "Upload failed",
"message": "Failed to upload video to storage"
})),
)
.into_response();
}
// Build variants JSON (video only has original for now)
let variants = serde_json::json!({
"original": {
"key": format!("original.{original_ext}"),
"mime": content_type
}
});
// Create database record
match db::create_media(
&state.pool,
project_id,
db::MediaType::Video,
&filename,
&r2_base_path,
variants,
None, // Video dimensions would require ffprobe
None,
data.len() as i64,
None, // No blurhash without poster
None,
)
.await
{
Ok(media) => {
tracing::info!(
media_id = %media.id,
project_id = %project_id,
filename = %filename,
"Video uploaded successfully"
);
// Invalidate cache
state.isr_cache.invalidate("/").await;
(StatusCode::CREATED, Json(media.to_api_media())).into_response()
}
Err(err) => {
tracing::error!(error = %err, "Failed to create media record");
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({
"error": "Database error",
"message": "Failed to save media record"
})),
)
.into_response()
}
}
}
}
/// Extract file from multipart form data
async fn extract_file(
multipart: &mut Multipart,
) -> Result<Option<(String, String, Vec<u8>)>, String> {
while let Some(field) = multipart
.next_field()
.await
.map_err(|e| format!("Failed to get field: {e}"))?
{
let name = field.name().unwrap_or("").to_string();
// Accept 'file' or 'media' field names
if name == "file" || name == "media" {
let filename = field.file_name().unwrap_or("upload").to_string();
let content_type = field
.content_type()
.unwrap_or("application/octet-stream")
.to_string();
let data = field
.bytes()
.await
.map_err(|e| format!("Failed to read file data: {e}"))?
.to_vec();
return Ok(Some((filename, content_type, data)));
}
}
Ok(None)
}
/// Upload all image variants to R2
async fn upload_image_variants(
r2: &R2Client,
base_path: &str,
processed: &media_processing::ProcessedImage,
) -> Result<(), String> {
// Upload thumb
r2.put_object(
&format!("{base_path}/thumb.webp"),
processed.thumb.data.clone(),
"image/webp",
)
.await?;
// Upload medium
r2.put_object(
&format!("{base_path}/medium.webp"),
processed.medium.data.clone(),
"image/webp",
)
.await?;
// Upload full
r2.put_object(
&format!("{base_path}/full.webp"),
processed.full.data.clone(),
"image/webp",
)
.await?;
// Upload original (preserve format)
let original_ext = match processed.original.mime.as_str() {
"image/jpeg" => "jpg",
"image/png" => "png",
"image/gif" => "gif",
"image/webp" => "webp",
"image/avif" => "avif",
_ => "jpg",
};
r2.put_object(
&format!("{base_path}/original.{original_ext}"),
processed.original.data.clone(),
&processed.original.mime,
)
.await?;
Ok(())
}
/// Get all media for a project
pub async fn get_project_media_handler(
State(state): State<Arc<AppState>>,
axum::extract::Path(project_id): axum::extract::Path<String>,
) -> impl IntoResponse {
let project_id = match Uuid::parse_str(&project_id) {
Ok(id) => id,
Err(_) => {
return (
StatusCode::BAD_REQUEST,
Json(serde_json::json!({
"error": "Invalid project ID",
"message": "Project ID must be a valid UUID"
})),
)
.into_response();
}
};
// Verify project exists
match db::get_project_by_id(&state.pool, project_id).await {
Ok(None) => {
return (
StatusCode::NOT_FOUND,
Json(serde_json::json!({
"error": "Not found",
"message": "Project not found"
})),
)
.into_response();
}
Err(err) => {
tracing::error!(error = %err, "Failed to check project existence");
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({
"error": "Internal server error",
"message": "Failed to fetch project"
})),
)
.into_response();
}
Ok(Some(_)) => {}
}
match db::get_media_for_project(&state.pool, project_id).await {
Ok(media) => {
let response: Vec<db::ApiProjectMedia> =
media.into_iter().map(|m| m.to_api_media()).collect();
Json(response).into_response()
}
Err(err) => {
tracing::error!(error = %err, project_id = %project_id, "Failed to fetch project media");
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({
"error": "Internal server error",
"message": "Failed to fetch media"
})),
)
.into_response()
}
}
}
/// Delete a media item (requires authentication)
pub async fn delete_media_handler(
State(state): State<Arc<AppState>>,
axum::extract::Path((project_id, media_id)): axum::extract::Path<(String, String)>,
jar: axum_extra::extract::CookieJar,
) -> impl IntoResponse {
// Check auth
if auth::check_session(&state, &jar).is_none() {
return auth::require_auth_response().into_response();
}
let project_id = match Uuid::parse_str(&project_id) {
Ok(id) => id,
Err(_) => {
return (
StatusCode::BAD_REQUEST,
Json(serde_json::json!({
"error": "Invalid project ID",
"message": "Project ID must be a valid UUID"
})),
)
.into_response();
}
};
let media_id = match Uuid::parse_str(&media_id) {
Ok(id) => id,
Err(_) => {
return (
StatusCode::BAD_REQUEST,
Json(serde_json::json!({
"error": "Invalid media ID",
"message": "Media ID must be a valid UUID"
})),
)
.into_response();
}
};
// Get media first to verify it belongs to the project
match db::get_media_by_id(&state.pool, media_id).await {
Ok(Some(media)) => {
if media.project_id != project_id {
return (
StatusCode::NOT_FOUND,
Json(serde_json::json!({
"error": "Not found",
"message": "Media not found for this project"
})),
)
.into_response();
}
// Delete files from R2 storage
let r2_base_path = media.r2_base_path.clone();
if let Some(r2) = R2Client::get().await {
// Delete all files under the media's R2 prefix
let prefix = format!("{}/", r2_base_path.trim_end_matches('/'));
match r2.delete_prefix(&prefix).await {
Ok(count) => {
tracing::info!(
media_id = %media_id,
r2_prefix = %prefix,
deleted_count = count,
"Deleted R2 objects"
);
}
Err(err) => {
// Log but don't fail - DB record deletion is more important
tracing::warn!(
error = %err,
media_id = %media_id,
r2_prefix = %prefix,
"Failed to delete R2 objects (will be orphaned)"
);
}
}
}
match db::delete_media(&state.pool, media_id).await {
Ok(Some(deleted)) => {
tracing::info!(
media_id = %media_id,
project_id = %project_id,
r2_base_path = %deleted.r2_base_path,
"Media deleted from database"
);
// Invalidate cache since project data changed
state.isr_cache.invalidate("/").await;
Json(deleted.to_api_media()).into_response()
}
Ok(None) => (
StatusCode::NOT_FOUND,
Json(serde_json::json!({
"error": "Not found",
"message": "Media not found"
})),
)
.into_response(),
Err(err) => {
tracing::error!(error = %err, "Failed to delete media");
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({
"error": "Internal server error",
"message": "Failed to delete media"
})),
)
.into_response()
}
}
}
Ok(None) => (
StatusCode::NOT_FOUND,
Json(serde_json::json!({
"error": "Not found",
"message": "Media not found"
})),
)
.into_response(),
Err(err) => {
tracing::error!(error = %err, "Failed to fetch media");
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({
"error": "Internal server error",
"message": "Failed to fetch media"
})),
)
.into_response()
}
}
}
/// Reorder media items for a project (requires authentication)
pub async fn reorder_media_handler(
State(state): State<Arc<AppState>>,
axum::extract::Path(project_id): axum::extract::Path<String>,
jar: axum_extra::extract::CookieJar,
Json(payload): Json<ReorderMediaRequest>,
) -> impl IntoResponse {
// Check auth
if auth::check_session(&state, &jar).is_none() {
return auth::require_auth_response().into_response();
}
let project_id = match Uuid::parse_str(&project_id) {
Ok(id) => id,
Err(_) => {
return (
StatusCode::BAD_REQUEST,
Json(serde_json::json!({
"error": "Invalid project ID",
"message": "Project ID must be a valid UUID"
})),
)
.into_response();
}
};
// Parse media IDs
let media_ids: Result<Vec<Uuid>, _> = payload
.media_ids
.iter()
.map(|id| Uuid::parse_str(id))
.collect();
let media_ids = match media_ids {
Ok(ids) => ids,
Err(_) => {
return (
StatusCode::BAD_REQUEST,
Json(serde_json::json!({
"error": "Invalid media ID",
"message": "All media IDs must be valid UUIDs"
})),
)
.into_response();
}
};
// Verify project exists
match db::get_project_by_id(&state.pool, project_id).await {
Ok(None) => {
return (
StatusCode::NOT_FOUND,
Json(serde_json::json!({
"error": "Not found",
"message": "Project not found"
})),
)
.into_response();
}
Err(err) => {
tracing::error!(error = %err, "Failed to check project existence");
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({
"error": "Internal server error",
"message": "Failed to verify project"
})),
)
.into_response();
}
Ok(Some(_)) => {}
}
// Reorder media
match db::reorder_media(&state.pool, project_id, &media_ids).await {
Ok(()) => {
// Fetch updated media list
match db::get_media_for_project(&state.pool, project_id).await {
Ok(media) => {
// Invalidate cache since project data changed
state.isr_cache.invalidate("/").await;
let response: Vec<db::ApiProjectMedia> =
media.into_iter().map(|m| m.to_api_media()).collect();
Json(response).into_response()
}
Err(err) => {
tracing::error!(error = %err, "Failed to fetch updated media");
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({
"error": "Internal server error",
"message": "Reorder succeeded but failed to fetch updated list"
})),
)
.into_response()
}
}
}
Err(err) => {
tracing::error!(error = %err, "Failed to reorder media");
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({
"error": "Internal server error",
"message": "Failed to reorder media"
})),
)
.into_response()
}
}
}
// TODO: Upload handler requires multipart form handling and image processing
// This will be implemented when we add the upload functionality
// For now, media records can only be created programmatically or via seeding
+2
View File
@@ -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::*;
+20 -15
View File
@@ -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<db::ApiAdminProject> = 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<db::ApiAdminProject> = 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,7 +450,8 @@ 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 {
let (project, tags, media) =
match db::get_project_by_id_with_tags(&state.pool, project_id).await {
Ok(Some(data)) => data,
Ok(None) => {
return (
@@ -471,7 +476,7 @@ pub async fn delete_project_handler(
}
};
// 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");
+1
View File
@@ -12,6 +12,7 @@ mod github;
mod handlers;
mod health;
mod http;
mod media_processing;
mod middleware;
mod og;
mod proxy;
+229
View File
@@ -0,0 +1,229 @@
//! Image processing utilities for media uploads.
//!
//! Handles resizing, format conversion, and blurhash generation.
use image::{DynamicImage, GenericImageView, ImageFormat, imageops::FilterType};
use std::io::Cursor;
/// Target widths for image variants
pub const THUMB_WIDTH: u32 = 300;
pub const MEDIUM_WIDTH: u32 = 800;
pub const FULL_WIDTH: u32 = 1600;
/// Quality setting for WebP encoding (0-100)
const WEBP_QUALITY: u8 = 85;
/// Result of processing an uploaded image
#[derive(Debug)]
pub struct ProcessedImage {
pub thumb: ImageVariant,
pub medium: ImageVariant,
pub full: ImageVariant,
pub original: OriginalImage,
pub blurhash: String,
}
/// A processed image variant
#[derive(Debug)]
pub struct ImageVariant {
pub data: Vec<u8>,
pub width: u32,
pub height: u32,
}
/// Original image info
#[derive(Debug)]
pub struct OriginalImage {
pub data: Vec<u8>,
pub width: u32,
pub height: u32,
pub mime: String,
}
/// Errors that can occur during image processing
#[derive(Debug, thiserror::Error)]
pub enum ProcessingError {
#[error("Failed to decode image: {0}")]
DecodeError(String),
#[error("Failed to encode image: {0}")]
EncodeError(String),
#[error("Unsupported image format: {0}")]
UnsupportedFormat(String),
#[error("Image too small: minimum {min}px, got {actual}px")]
TooSmall { min: u32, actual: u32 },
#[error("BlurHash generation failed: {0}")]
BlurHashError(String),
}
/// Detect image format from bytes and filename
pub fn detect_format(
data: &[u8],
filename: &str,
) -> Result<(ImageFormat, &'static str), ProcessingError> {
// Try to detect from magic bytes first
if let Ok(format) = image::guess_format(data) {
let mime = match format {
ImageFormat::Jpeg => "image/jpeg",
ImageFormat::Png => "image/png",
ImageFormat::Gif => "image/gif",
ImageFormat::WebP => "image/webp",
ImageFormat::Avif => "image/avif",
_ => return Err(ProcessingError::UnsupportedFormat(format!("{format:?}"))),
};
return Ok((format, mime));
}
// Fall back to extension
let ext = filename.rsplit('.').next().unwrap_or("").to_lowercase();
match ext.as_str() {
"jpg" | "jpeg" => Ok((ImageFormat::Jpeg, "image/jpeg")),
"png" => Ok((ImageFormat::Png, "image/png")),
"gif" => Ok((ImageFormat::Gif, "image/gif")),
"webp" => Ok((ImageFormat::WebP, "image/webp")),
"avif" => Ok((ImageFormat::Avif, "image/avif")),
_ => Err(ProcessingError::UnsupportedFormat(ext)),
}
}
/// Process an uploaded image into all required variants
pub fn process_image(data: &[u8], filename: &str) -> Result<ProcessedImage, ProcessingError> {
// Detect format and decode
let (format, mime) = detect_format(data, filename)?;
let img = image::load_from_memory_with_format(data, format)
.map_err(|e| ProcessingError::DecodeError(e.to_string()))?;
let (orig_width, orig_height) = img.dimensions();
// Minimum size check - at least thumbnail size
let min_dim = orig_width.min(orig_height);
if min_dim < THUMB_WIDTH {
return Err(ProcessingError::TooSmall {
min: THUMB_WIDTH,
actual: min_dim,
});
}
// Generate variants (only resize if larger than target)
let thumb = resize_to_webp(&img, THUMB_WIDTH)?;
let medium = resize_to_webp(&img, MEDIUM_WIDTH)?;
let full = resize_to_webp(&img, FULL_WIDTH)?;
// Generate blurhash from thumbnail for efficiency
let blurhash = generate_blurhash(&img, 4, 3)?;
// Keep original as-is (preserve format)
let original = OriginalImage {
data: data.to_vec(),
width: orig_width,
height: orig_height,
mime: mime.to_string(),
};
Ok(ProcessedImage {
thumb,
medium,
full,
original,
blurhash,
})
}
/// Resize image to target width (maintaining aspect ratio) and encode as WebP
fn resize_to_webp(img: &DynamicImage, target_width: u32) -> Result<ImageVariant, ProcessingError> {
let (orig_width, orig_height) = img.dimensions();
// Only resize if larger than target
let (resized, width, height) = if orig_width > target_width {
let ratio = target_width as f64 / orig_width as f64;
let target_height = (orig_height as f64 * ratio).round() as u32;
let resized = img.resize(target_width, target_height, FilterType::Lanczos3);
(resized, target_width, target_height)
} else {
(img.clone(), orig_width, orig_height)
};
// Encode as WebP
let mut buf = Cursor::new(Vec::new());
resized
.write_to(&mut buf, ImageFormat::WebP)
.map_err(|e| ProcessingError::EncodeError(e.to_string()))?;
Ok(ImageVariant {
data: buf.into_inner(),
width,
height,
})
}
/// Generate a BlurHash string from an image
fn generate_blurhash(
img: &DynamicImage,
x_components: u32,
y_components: u32,
) -> Result<String, ProcessingError> {
// Resize to small size for efficient blurhash computation
let small = img.resize(32, 32, FilterType::Triangle);
let rgba = small.to_rgba8();
let (w, h) = rgba.dimensions();
let hash = blurhash::encode(x_components, y_components, w, h, rgba.as_raw())
.map_err(|e| ProcessingError::BlurHashError(format!("{e:?}")))?;
Ok(hash)
}
/// Check if a MIME type is a supported image format
pub fn is_supported_image(mime: &str) -> bool {
matches!(
mime,
"image/jpeg" | "image/png" | "image/gif" | "image/webp" | "image/avif"
)
}
/// Check if a MIME type is a supported video format
pub fn is_supported_video(mime: &str) -> bool {
matches!(mime, "video/mp4" | "video/webm" | "video/quicktime")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_detect_format_jpeg() {
// JPEG magic bytes
let data = [0xFF, 0xD8, 0xFF, 0xE0];
let (format, mime) = detect_format(&data, "test.jpg").unwrap();
assert_eq!(format, ImageFormat::Jpeg);
assert_eq!(mime, "image/jpeg");
}
#[test]
fn test_detect_format_png() {
// PNG magic bytes
let data = [0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A];
let (format, mime) = detect_format(&data, "test.png").unwrap();
assert_eq!(format, ImageFormat::Png);
assert_eq!(mime, "image/png");
}
#[test]
fn test_is_supported_image() {
assert!(is_supported_image("image/jpeg"));
assert!(is_supported_image("image/png"));
assert!(!is_supported_image("text/plain"));
assert!(!is_supported_image("video/mp4"));
}
#[test]
fn test_is_supported_video() {
assert!(is_supported_video("video/mp4"));
assert!(is_supported_video("video/webm"));
assert!(!is_supported_video("image/jpeg"));
}
}
+1 -1
View File
@@ -57,7 +57,7 @@ pub async fn generate_og_image(spec: &OGImageSpec, state: Arc<AppState>) -> Resu
.map_err(|e| format!("Failed to read response: {e}"))?
.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}"))?;
+43 -2
View File
@@ -78,13 +78,18 @@ impl R2Client {
Ok(bytes)
}
pub async fn put_object(&self, key: &str, body: Vec<u8>) -> Result<(), String> {
pub async fn put_object(
&self,
key: &str,
body: Vec<u8>,
content_type: &str,
) -> Result<(), String> {
self.client
.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<usize, String> {
let list_result = self
.client
.list_objects_v2()
.bucket(&self.bucket)
.prefix(prefix)
.send()
.await
.map_err(|e| format!("Failed to list objects in R2: {e}"))?;
let mut deleted = 0;
if let Some(contents) = list_result.contents {
for object in contents {
if let Some(key) = object.key {
self.delete_object(&key).await?;
deleted += 1;
}
}
}
Ok(deleted)
}
pub async fn object_exists(&self, key: &str) -> bool {
self.client
.head_object()
+15 -2
View File
@@ -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<Arc<AppState>> {
)
.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(
+6
View File
@@ -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=="],
+2
View File
@@ -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": {
+40
View File
@@ -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;
+63
View File
@@ -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<RelatedTag[]> {
return clientApiFetch<RelatedTag[]>(`/api/tags/${slug}/related`);
}
// Admin Media API
export async function uploadProjectMedia(
projectId: string,
file: File,
onProgress?: (percent: number) => void,
): Promise<ProjectMedia> {
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
const formData = new FormData();
formData.append("file", file);
xhr.upload.onprogress = (e) => {
if (e.lengthComputable && onProgress) {
onProgress(Math.round((e.loaded / e.total) * 100));
}
};
xhr.onload = () => {
if (xhr.status >= 200 && xhr.status < 300) {
try {
const data = JSON.parse(xhr.responseText);
resolve(data as ProjectMedia);
} catch {
reject(new Error("Invalid response from server"));
}
} else {
reject(new Error(`Upload failed: ${xhr.statusText}`));
}
};
xhr.onerror = () => reject(new Error("Network error during upload"));
xhr.open("POST", `/api/projects/${projectId}/media`);
xhr.withCredentials = true;
xhr.send(formData);
});
}
export async function deleteProjectMedia(
projectId: string,
mediaId: string,
): Promise<ProjectMedia> {
return clientApiFetch<ProjectMedia>(
`/api/projects/${projectId}/media/${mediaId}`,
{ method: "DELETE" },
);
}
export async function reorderProjectMedia(
projectId: string,
mediaIds: string[],
): Promise<ProjectMedia[]> {
return clientApiFetch<ProjectMedia[]>(
`/api/projects/${projectId}/media/reorder`,
{
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ mediaIds }),
},
);
}
// Admin Events API (currently mocked - no backend implementation yet)
export async function getAdminEvents(): Promise<AdminEvent[]> {
// TODO: Implement when events table is added to backend
+27 -42
View File
@@ -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,23 +115,23 @@
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,
)}
>
<!-- Background media layer -->
{#if hasMedia}
<div
class="pointer-events-none absolute inset-0 opacity-25 group-hover:opacity-40"
style="transition: opacity 300ms ease-in-out;"
aria-hidden="true"
>
{#if isVideo}
{#if isVideo && videoUrl}
<video
bind:this={videoElement}
src={videoUrl}
poster={videoPosterUrl}
class={cn(mediaBaseClasses, "grayscale group-hover:grayscale-0")}
style="transition: filter 300ms ease-in-out;"
muted
@@ -155,10 +139,11 @@
playsinline
preload="metadata"
></video>
{:else}
{:else if imageUrl}
<img src={imageUrl} alt="" class={mediaBaseClasses} loading="lazy" />
{/if}
</div>
{/if}
<!-- Content layer -->
<div
@@ -0,0 +1,118 @@
<script lang="ts">
import { cn } from "$lib/utils";
import { decode } from "blurhash";
import type { ProjectMedia } from "$lib/admin-types";
import VideoThumbnail from "./VideoThumbnail.svelte";
import IconX from "~icons/lucide/x";
import IconPlay from "~icons/lucide/play";
import IconFilm from "~icons/lucide/film";
import IconImage from "~icons/lucide/image";
interface Props {
media: ProjectMedia;
ondelete: () => void;
class?: string;
}
let { media, ondelete, class: className }: Props = $props();
// Get the best thumbnail URL (for images)
const thumbUrl = $derived(
media.variants.thumb?.url ??
media.variants.medium?.url ??
media.variants.full?.url ??
media.variants.poster?.url,
);
// Get video URL (for videos)
const videoUrl = $derived(media.variants.video?.url);
// Decode blurhash to canvas on mount
let canvasRef: HTMLCanvasElement | null = $state(null);
let imageLoaded = $state(false);
$effect(() => {
if (canvasRef && media.blurhash && !imageLoaded) {
try {
const pixels = decode(media.blurhash, 32, 32);
const ctx = canvasRef.getContext("2d");
if (ctx) {
const imageData = ctx.createImageData(32, 32);
imageData.data.set(pixels);
ctx.putImageData(imageData, 0, 0);
}
} catch {
// Silently fail if blurhash is invalid
}
}
});
function handleImageLoad() {
imageLoaded = true;
}
</script>
<!-- Outer wrapper allows delete button to escape bounds -->
<div class={cn("group relative", className)}>
<!-- Media container with fixed height -->
<div
class="relative h-28 rounded-lg border border-admin-border bg-admin-bg-secondary overflow-hidden"
>
<!-- Blurhash placeholder -->
{#if media.blurhash && !imageLoaded}
<canvas
bind:this={canvasRef}
width="32"
height="32"
class="absolute inset-0 w-full h-full object-cover"
></canvas>
{/if}
<!-- Actual thumbnail or video -->
{#if media.mediaType === "video" && videoUrl}
<!-- Video thumbnail - capture first frame to canvas -->
<VideoThumbnail src={videoUrl} onload={handleImageLoad} />
{:else if thumbUrl}
<img
src={thumbUrl}
alt=""
class={cn(
"absolute inset-0 w-full h-full object-cover transition-opacity duration-200",
imageLoaded ? "opacity-100" : "opacity-0",
)}
onload={handleImageLoad}
/>
{:else}
<!-- Fallback for missing thumbnail -->
<div
class="absolute inset-0 flex items-center justify-center text-admin-text-muted"
>
{#if media.mediaType === "video"}
<IconFilm class="size-6" />
{:else}
<IconImage class="size-6" />
{/if}
</div>
{/if}
<!-- Video badge -->
{#if media.mediaType === "video"}
<div
class="absolute top-2 left-2 bg-black/70 text-white text-xs px-1.5 py-0.5 rounded flex items-center gap-1"
>
<IconPlay class="size-2.5" />
<span>Video</span>
</div>
{/if}
</div>
<!-- Delete button - positioned outside the overflow-hidden container -->
<button
type="button"
onclick={ondelete}
class="absolute -top-2 -right-2 w-6 h-6 bg-red-600 hover:bg-red-500 text-white rounded-full flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity shadow-md z-10"
aria-label="Delete media"
>
<IconX class="size-3.5" />
</button>
</div>
@@ -0,0 +1,395 @@
<script lang="ts">
import { cn } from "$lib/utils";
import { dndzone } from "svelte-dnd-action";
import { flip } from "svelte/animate";
import type { ProjectMedia } from "$lib/admin-types";
import {
uploadProjectMedia,
deleteProjectMedia,
reorderProjectMedia,
} from "$lib/api";
import MediaItem from "./MediaItem.svelte";
import Modal from "./Modal.svelte";
import { getLogger } from "@logtape/logtape";
import IconCloudUpload from "~icons/lucide/cloud-upload";
import IconAlertCircle from "~icons/lucide/alert-circle";
import IconLoader from "~icons/lucide/loader-2";
import IconX from "~icons/lucide/x";
const logger = getLogger(["admin", "components", "MediaManager"]);
interface Props {
projectId: string | null;
media?: ProjectMedia[];
onchange?: (media: ProjectMedia[]) => void;
class?: string;
}
let { projectId, media = [], onchange, class: className }: Props = $props();
// Local media state (for reordering) - needs to be mutable for drag-drop
// eslint-disable-next-line svelte/prefer-writable-derived -- intentional: svelte-dnd-action requires mutable array
let mediaItems = $state<ProjectMedia[]>([]);
// Sync from props when they change
$effect(() => {
mediaItems = [...media];
});
// Upload state
interface UploadTask {
id: string;
file: File;
progress: number;
status: "uploading" | "done" | "error";
error?: string;
}
let uploadQueue = $state<UploadTask[]>([]);
// UI state
let isDraggingFile = $state(false);
let errorMessage = $state<string | null>(null);
let fileInputRef: HTMLInputElement | null = $state(null);
// Delete confirmation
let deleteModalOpen = $state(false);
let deletingMedia = $state<ProjectMedia | null>(null);
const flipDurationMs = 150;
const SUPPORTED_IMAGE_TYPES = [
"image/jpeg",
"image/png",
"image/gif",
"image/webp",
"image/avif",
];
const SUPPORTED_VIDEO_TYPES = ["video/mp4", "video/webm", "video/quicktime"];
const SUPPORTED_TYPES = [...SUPPORTED_IMAGE_TYPES, ...SUPPORTED_VIDEO_TYPES];
// Drag and drop reorder handlers
function handleDndConsider(e: CustomEvent<{ items: ProjectMedia[] }>) {
mediaItems = e.detail.items;
}
async function handleDndFinalize(e: CustomEvent<{ items: ProjectMedia[] }>) {
mediaItems = e.detail.items;
onchange?.(mediaItems);
// Call reorder API
if (projectId) {
try {
await reorderProjectMedia(
projectId,
mediaItems.map((m) => m.id),
);
logger.info("Media reordered", { projectId, count: mediaItems.length });
} catch (err) {
logger.error("Failed to reorder media", { error: err });
showError("Failed to save new order");
}
}
}
// File upload handlers
function handleDragEnter(e: DragEvent) {
e.preventDefault();
isDraggingFile = true;
}
function handleDragLeave(e: DragEvent) {
e.preventDefault();
// Only set false if leaving the drop zone entirely
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
const x = e.clientX;
const y = e.clientY;
if (x < rect.left || x > rect.right || y < rect.top || y > rect.bottom) {
isDraggingFile = false;
}
}
function handleDragOver(e: DragEvent) {
e.preventDefault();
isDraggingFile = true;
}
function handleDrop(e: DragEvent) {
e.preventDefault();
isDraggingFile = false;
const files = e.dataTransfer?.files;
if (files && files.length > 0) {
handleFiles(Array.from(files));
}
}
function handleFileInputChange(e: Event) {
const input = e.target as HTMLInputElement;
if (input.files && input.files.length > 0) {
handleFiles(Array.from(input.files));
input.value = ""; // Reset for re-upload of same file
}
}
function handleFiles(files: File[]) {
clearError();
const validFiles: File[] = [];
const invalidTypes: string[] = [];
for (const file of files) {
if (SUPPORTED_TYPES.includes(file.type)) {
validFiles.push(file);
} else {
invalidTypes.push(file.name);
}
}
if (invalidTypes.length > 0) {
showError(
`Unsupported file type${invalidTypes.length > 1 ? "s" : ""}: ${invalidTypes.join(", ")}`,
);
}
for (const file of validFiles) {
uploadFile(file);
}
}
async function uploadFile(file: File) {
if (!projectId) return;
const taskId = crypto.randomUUID();
const task: UploadTask = {
id: taskId,
file,
progress: 0,
status: "uploading",
};
uploadQueue = [...uploadQueue, task];
try {
const media = await uploadProjectMedia(projectId, file, (progress) => {
uploadQueue = uploadQueue.map((t) =>
t.id === taskId ? { ...t, progress } : t,
);
});
// Add to media items
mediaItems = [...mediaItems, media];
onchange?.(mediaItems);
// Remove from queue
uploadQueue = uploadQueue.filter((t) => t.id !== taskId);
logger.info("Media uploaded", { projectId, mediaId: media.id });
} catch (err) {
logger.error("Upload failed", { error: err, filename: file.name });
uploadQueue = uploadQueue.map((t) =>
t.id === taskId ? { ...t, status: "error", error: String(err) } : t,
);
}
}
function removeUploadTask(taskId: string) {
uploadQueue = uploadQueue.filter((t) => t.id !== taskId);
}
// Delete handlers
function handleDeleteClick(media: ProjectMedia) {
deletingMedia = media;
deleteModalOpen = true;
}
async function confirmDelete() {
if (!projectId || !deletingMedia) return;
try {
await deleteProjectMedia(projectId, deletingMedia.id);
mediaItems = mediaItems.filter((m) => m.id !== deletingMedia!.id);
onchange?.(mediaItems);
logger.info("Media deleted", { projectId, mediaId: deletingMedia.id });
} catch (err) {
logger.error("Failed to delete media", { error: err });
showError("Failed to delete media");
}
deletingMedia = null;
}
// Error handling
function showError(msg: string) {
errorMessage = msg;
setTimeout(() => {
if (errorMessage === msg) {
errorMessage = null;
}
}, 5000);
}
function clearError() {
errorMessage = null;
}
</script>
<div class={cn("space-y-1.5", className)}>
<div class="block text-sm font-medium text-admin-text">Media</div>
{#if !projectId}
<!-- Disabled state for new projects -->
<div
class="rounded-lg border-2 border-dashed border-admin-border bg-admin-bg-secondary p-8 text-center"
>
<IconCloudUpload class="size-8 text-admin-text-muted mb-2 mx-auto" />
<p class="text-sm text-admin-text-muted">
Save the project first to enable media uploads
</p>
</div>
{:else}
<!-- Media grid (if has media) -->
{#if mediaItems.length > 0}
<div
use:dndzone={{
items: mediaItems,
flipDurationMs,
dropTargetStyle: {},
}}
onconsider={handleDndConsider}
onfinalize={handleDndFinalize}
class="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-3 mb-3"
>
{#each mediaItems as item (item.id)}
<div animate:flip={{ duration: flipDurationMs }}>
<MediaItem media={item} ondelete={() => handleDeleteClick(item)} />
</div>
{/each}
</div>
{/if}
<!-- Upload drop zone -->
<div
role="button"
tabindex="0"
class={cn(
"rounded-lg border-2 border-dashed p-6 text-center cursor-pointer transition-colors",
isDraggingFile
? "border-admin-accent bg-admin-accent/10"
: "border-admin-border bg-admin-bg-secondary hover:border-admin-text-muted hover:bg-admin-surface",
)}
ondragenter={handleDragEnter}
ondragleave={handleDragLeave}
ondragover={handleDragOver}
ondrop={handleDrop}
onclick={() => fileInputRef?.click()}
onkeydown={(e) => e.key === "Enter" && fileInputRef?.click()}
>
<IconCloudUpload
class={cn(
"size-6 mb-2 mx-auto",
isDraggingFile ? "text-admin-accent" : "text-admin-text-muted",
)}
/>
<p class="text-sm text-admin-text">
{isDraggingFile
? "Drop files here"
: "Drop files here or click to upload"}
</p>
<p class="text-xs text-admin-text-muted mt-1">
JPEG, PNG, GIF, WebP, MP4, WebM
</p>
</div>
<input
bind:this={fileInputRef}
type="file"
accept={SUPPORTED_TYPES.join(",")}
multiple
class="hidden"
onchange={handleFileInputChange}
/>
<!-- Upload queue -->
{#if uploadQueue.length > 0}
<div class="space-y-2 mt-3">
{#each uploadQueue as task (task.id)}
<div
class="flex items-center gap-3 p-2 rounded-lg bg-admin-bg-secondary border border-admin-border"
>
{#if task.status === "error"}
<IconAlertCircle class="size-4 text-red-500 shrink-0" />
{:else}
<IconLoader
class="size-4 text-admin-text-muted animate-spin shrink-0"
/>
{/if}
<div class="flex-1 min-w-0">
<p class="text-sm text-admin-text truncate">{task.file.name}</p>
{#if task.status === "uploading"}
<div
class="h-1.5 bg-admin-border rounded-full mt-1 overflow-hidden"
>
<div
class="h-full bg-admin-accent transition-all duration-200"
style="width: {task.progress}%"
></div>
</div>
{:else if task.status === "error"}
<p class="text-xs text-red-500 truncate">{task.error}</p>
{/if}
</div>
{#if task.status === "error"}
<button
type="button"
onclick={() => removeUploadTask(task.id)}
class="text-admin-text-muted hover:text-admin-text p-1"
aria-label="Dismiss error"
>
<IconX class="size-4" />
</button>
{:else}
<span class="text-xs text-admin-text-muted">{task.progress}%</span
>
{/if}
</div>
{/each}
</div>
{/if}
<!-- Error message -->
{#if errorMessage}
<div
class="flex items-center gap-2 p-3 mt-2 rounded-lg bg-red-500/10 border border-red-500/30 text-red-500 text-sm"
>
<IconAlertCircle class="size-4 shrink-0" />
<span>{errorMessage}</span>
<button
type="button"
onclick={clearError}
class="ml-auto hover:text-red-400"
aria-label="Dismiss error"
>
<IconX class="size-4" />
</button>
</div>
{/if}
{/if}
<p class="text-xs text-admin-text-muted">
{#if projectId}
Drag to reorder. First image is shown as the project thumbnail.
{:else}
Media can be uploaded after saving the project.
{/if}
</p>
</div>
<!-- Delete confirmation modal -->
<Modal
bind:open={deleteModalOpen}
title="Delete Media"
description="Are you sure you want to delete this media? This action cannot be undone."
confirmText="Delete"
confirmVariant="danger"
onconfirm={confirmDelete}
oncancel={() => (deletingMedia = null)}
/>
@@ -2,6 +2,7 @@
import Button from "./Button.svelte";
import 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 Upload Placeholder -->
<div class="space-y-1.5">
<div class="block text-sm font-medium text-admin-text">Media</div>
<Button type="button" variant="secondary" disabled class="w-full">
<i class="fa-solid fa-upload mr-2"></i>
Upload Images/Videos (Coming Soon)
</Button>
<p class="text-xs text-admin-text-muted">
Media upload functionality will be available soon
</p>
</div>
<!-- Media -->
<MediaManager projectId={project?.id ?? null} media={project?.media ?? []} />
<!-- Actions -->
<div class="flex justify-end gap-3 pt-4 border-t border-admin-border">
@@ -0,0 +1,60 @@
<script lang="ts">
interface Props {
src: string;
onload?: () => void;
class?: string;
}
let { src, onload, class: className }: Props = $props();
let canvasRef: HTMLCanvasElement | null = $state(null);
let loaded = $state(false);
$effect(() => {
if (!canvasRef || !src || loaded) return;
const video = document.createElement("video");
video.crossOrigin = "anonymous";
video.muted = true;
video.playsInline = true;
video.preload = "metadata";
video.onloadeddata = () => {
// Seek to 0.1s to avoid black frames
video.currentTime = 0.1;
};
video.onseeked = () => {
if (!canvasRef) return;
const ctx = canvasRef.getContext("2d");
if (ctx) {
// Set canvas size to match video
canvasRef.width = video.videoWidth;
canvasRef.height = video.videoHeight;
// Draw the frame
ctx.drawImage(video, 0, 0, video.videoWidth, video.videoHeight);
loaded = true;
onload?.();
}
// Clean up
video.src = "";
video.load();
};
video.onerror = () => {
// Still call onload to remove loading state
loaded = true;
onload?.();
};
video.src = src;
});
</script>
<canvas
bind:this={canvasRef}
class={className ?? "absolute inset-0 w-full h-full object-cover"}
></canvas>
-98
View File
@@ -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",
},
];