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
+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),
}