5 Commits

Author SHA1 Message Date
MechaCat02
258e2bd84d feat: implement export engine
Add async ZIP and HTML offline viewer export workers, download endpoints,
and a guest-facing /export page.

Backend — export workers (tokio::spawn, run after gallery release):
- ZIP worker: streams all non-deleted originals into Gallery.zip via
  async_zip (Stored compression), organised into Photos/ and Videos/
  with {date}_{uploader}_{id}.{ext} filenames; updates progress_pct in DB
- HTML worker: renders Memories.html via minijinja template (self-contained:
  inlined CSS + JS, relative media paths); packs it with README.txt and
  all media into Memories.zip (Deflate for text, Stored for media)
- Both workers mark export_job status (running → done/failed), update
  export_zip_ready / export_html_ready on the event, and broadcast SSE
  export-progress + export-available when both complete

Backend — new endpoints (AuthUser):
- GET /export/zip   → streams Gallery.zip if export_zip_ready
- GET /export/html  → streams Memories.zip if export_html_ready
- GET /export/status → released flag + per-type status/progress (moved from admin)

Memories.html features: warm keepsake aesthetic, responsive grid, fullscreen
lightbox with captions/comments/likes, client-side hashtag filter chips,
XSS-safe JS, fully offline (no external deps)

Frontend — /export page:
- Locked state: padlock illustration + message
- Released state: ZIP and HTML cards with progress bars (SSE-driven),
  download buttons enabled only when ready
- HTML guide modal (unzip instructions + Wi-Fi tip) before download begins

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-02 20:56:21 +02:00
MechaCat02
32c16da3e2 feat: implement admin dashboard
Add Admin Dashboard at /admin for server configuration, disk usage
monitoring, and export job status, plus a public export/status endpoint.

Backend — new /api/v1/admin/* endpoints (RequireAdmin auth):
- GET  /admin/stats           → user/upload/comment counts + disk usage
- GET  /admin/config          → all config key/value pairs
- PATCH /admin/config         → update any subset of config keys; validates
                                 key whitelist and numeric values
- GET  /admin/export/jobs     → export_job rows for the event

Backend — public (AuthUser) endpoint:
- GET  /export/status         → released flag + zip/html job status/progress

Frontend — /admin page:
- Stats grid: guest count, upload count, comment count
- Disk usage bar with GB/MB formatting; red ≥ 90%, amber ≥ 75%
- Config form: labelled numeric inputs for all eight config keys,
  sends only changed values on save
- Export jobs list: type label, status badge, progress bar for running jobs,
  error message if failed; manual refresh button

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-02 20:45:37 +02:00
MechaCat02
71a2987a3e feat: implement host dashboard
Add Host Dashboard for event and guest management, accessible at /host.

Backend — new /api/v1/host/* endpoints (RequireHost auth):
- GET  /host/event                    → event name + lock/release state
- POST /host/event/close|open         → lock or unlock uploads; SSE broadcast
- POST /host/gallery/release          → set release timestamp, enqueue export jobs
- GET  /host/users                    → all guests with upload count & bytes
- POST /host/users/{id}/ban           → ban with optional upload-hide choice
- POST /host/users/{id}/unban         → lift ban
- PATCH /host/users/{id}/role         → promote guest→host or demote host→guest
- DELETE /host/upload/{id}            → host-level soft-delete + SSE
- DELETE /host/comment/{id}           → host-level soft-delete

Frontend — /host page:
- Event controls: lock/unlock toggle and release-gallery button with status badges
- Guest table: display name, role badge, upload count, storage used
- Ban flow: modal asking whether to keep or hide the user's uploads
- Promote/demote buttons respecting caller role (host can promote guests; admin can demote hosts)
- auth.ts: getRole() decodes JWT payload client-side to gate the route

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-02 20:43:09 +02:00
MechaCat02
25f4fb1810 feat: implement camera capture step
Add in-app camera capture to the upload flow. Guests can now take photos
and record videos directly via getUserMedia without leaving the app.
The captured media is immediately queued through the existing IndexedDB
upload pipeline alongside library-picked files.

- CameraCapture.svelte: fullscreen overlay with live preview, photo
  capture (JPEG via canvas), video recording (WebM/MP4 via MediaRecorder),
  front/back camera toggle, recording timer, and permission-denied error state
- Upload page: side-by-side "Gallery" and "Camera" pickers; shared
  caption/hashtags fields apply to both sources; Blob→File conversion
  with timestamped filename before enqueue
- .env.test: reference environment config for local testing

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-02 20:20:51 +02:00
fabi
964598e41d feat: implement gallery feed with social features and SSE
- Cursor-based feed endpoint using v_feed view with hashtag filtering
- Like toggle (INSERT ON CONFLICT), comments CRUD
- Feed delta endpoint for SSE-driven incremental updates
- SSE client with Page Visibility API (pause/reconnect)
- Responsive photo/video grid with infinite scroll
- Hashtag filter chips, lightbox modal with comments
- Media file serving via tower-http ServeDir

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-01 19:17:06 +02:00
27 changed files with 3409 additions and 44 deletions

45
.env.test Normal file
View File

@@ -0,0 +1,45 @@
# ── Domain ────────────────────────────────────────────────────────────────────
# Public domain Caddy will serve and obtain a TLS certificate for.
DOMAIN=my-event.example.com
# ── App server ────────────────────────────────────────────────────────────────
APP_PORT=3000
# ── Database ──────────────────────────────────────────────────────────────────
DATABASE_URL=postgres://eventsnap:secret@db:5432/eventsnap
POSTGRES_USER=eventsnap
POSTGRES_PASSWORD=secret
POSTGRES_DB=eventsnap
# ── Authentication ────────────────────────────────────────────────────────────
# Generate with: openssl rand -hex 64
JWT_SECRET=change_me_to_a_random_64_byte_hex_string
SESSION_EXPIRY_DAYS=30
# Admin dashboard password (bcrypt hash).
# Generate with: htpasswd -bnBC 12 "" yourpassword | tr -d ':\n'
ADMIN_PASSWORD_HASH=$2y$12$placeholder_replace_me
# ── Event ─────────────────────────────────────────────────────────────────────
EVENT_NAME=Max & Maria's Wedding
EVENT_SLUG=max-maria-2026
# ── Storage ───────────────────────────────────────────────────────────────────
MEDIA_PATH=/media
# ── Upload limits ─────────────────────────────────────────────────────────────
DEFAULT_MAX_IMAGE_SIZE_MB=20
DEFAULT_MAX_VIDEO_SIZE_MB=500
# ── Rate limiting ─────────────────────────────────────────────────────────────
DEFAULT_UPLOAD_RATE_PER_HOUR=10
DEFAULT_FEED_RATE_PER_MIN=60
DEFAULT_EXPORT_RATE_PER_DAY=3
# ── Capacity ──────────────────────────────────────────────────────────────────
DEFAULT_ESTIMATED_GUEST_COUNT=100
# Fraction of total storage that triggers the "low storage" warning (0.01.0)
DEFAULT_QUOTA_TOLERANCE=0.75
# ── Workers ───────────────────────────────────────────────────────────────────
COMPRESSION_WORKER_CONCURRENCY=2

1
backend/Cargo.lock generated
View File

@@ -907,6 +907,7 @@ dependencies = [
"sysinfo", "sysinfo",
"tokio", "tokio",
"tokio-stream", "tokio-stream",
"tokio-util",
"tower", "tower",
"tower-http", "tower-http",
"tower_governor", "tower_governor",

View File

@@ -17,6 +17,7 @@ bcrypt = "0.15"
uuid = { version = "1", features = ["v4", "serde"] } uuid = { version = "1", features = ["v4", "serde"] }
chrono = { version = "0.4", features = ["serde"] } chrono = { version = "0.4", features = ["serde"] }
tokio-stream = { version = "0.1", features = ["sync"] } tokio-stream = { version = "0.1", features = ["sync"] }
tokio-util = { version = "0.7", features = ["io", "compat"] }
futures = "0.3" futures = "0.3"
sha2 = "0.10" sha2 = "0.10"
rand = "0.9" rand = "0.9"

View File

@@ -0,0 +1,279 @@
use axum::extract::State;
use axum::http::StatusCode;
use axum::Json;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use sysinfo::System;
use crate::auth::middleware::RequireAdmin;
use crate::error::AppError;
use crate::state::AppState;
// ── DTOs ─────────────────────────────────────────────────────────────────────
#[derive(Serialize)]
pub struct StatsDto {
pub user_count: i64,
pub upload_count: i64,
pub comment_count: i64,
pub disk_total_bytes: u64,
pub disk_used_bytes: u64,
pub disk_free_bytes: u64,
}
#[derive(Serialize, sqlx::FromRow)]
pub struct ExportJobDto {
pub id: uuid::Uuid,
pub r#type: String,
pub status: String,
pub progress_pct: i16,
pub error_message: Option<String>,
pub created_at: chrono::DateTime<chrono::Utc>,
pub completed_at: Option<chrono::DateTime<chrono::Utc>>,
}
// ── Handlers ─────────────────────────────────────────────────────────────────
pub async fn get_stats(
State(state): State<AppState>,
RequireAdmin(_auth): RequireAdmin,
) -> Result<Json<StatsDto>, AppError> {
let event = crate::models::event::Event::find_by_slug(&state.pool, &state.config.event_slug)
.await?
.ok_or_else(|| AppError::NotFound("Event nicht gefunden.".into()))?;
let (user_count,): (i64,) =
sqlx::query_as("SELECT COUNT(*) FROM \"user\" WHERE event_id = $1")
.bind(event.id)
.fetch_one(&state.pool)
.await?;
let (upload_count,): (i64,) = sqlx::query_as(
"SELECT COUNT(*) FROM upload WHERE event_id = $1 AND deleted_at IS NULL",
)
.bind(event.id)
.fetch_one(&state.pool)
.await?;
let (comment_count,): (i64,) = sqlx::query_as(
"SELECT COUNT(*) FROM comment c
JOIN upload u ON u.id = c.upload_id
WHERE u.event_id = $1 AND c.deleted_at IS NULL",
)
.bind(event.id)
.fetch_one(&state.pool)
.await?;
// Disk usage via sysinfo
let mut sys = System::new();
sys.refresh_all();
let media_path = state.config.media_path.to_string_lossy().to_string();
let (disk_total, disk_free) = sysinfo::Disks::new_with_refreshed_list()
.iter()
.find(|d| media_path.starts_with(d.mount_point().to_string_lossy().as_ref()))
.map(|d| (d.total_space(), d.available_space()))
.unwrap_or_else(|| {
// Fall back to the root disk
sysinfo::Disks::new_with_refreshed_list()
.iter()
.find(|d| d.mount_point().to_string_lossy() == "/")
.map(|d| (d.total_space(), d.available_space()))
.unwrap_or((0, 0))
});
let disk_used = disk_total.saturating_sub(disk_free);
Ok(Json(StatsDto {
user_count,
upload_count,
comment_count,
disk_total_bytes: disk_total,
disk_used_bytes: disk_used,
disk_free_bytes: disk_free,
}))
}
pub async fn get_config(
State(state): State<AppState>,
RequireAdmin(_auth): RequireAdmin,
) -> Result<Json<HashMap<String, String>>, AppError> {
let rows: Vec<(String, String)> =
sqlx::query_as("SELECT key, value FROM config ORDER BY key")
.fetch_all(&state.pool)
.await?;
Ok(Json(rows.into_iter().collect()))
}
#[derive(Deserialize)]
pub struct PatchConfigRequest(pub HashMap<String, String>);
pub async fn patch_config(
State(state): State<AppState>,
RequireAdmin(_auth): RequireAdmin,
Json(body): Json<HashMap<String, String>>,
) -> Result<StatusCode, AppError> {
const ALLOWED_KEYS: &[&str] = &[
"max_image_size_mb",
"max_video_size_mb",
"upload_rate_per_hour",
"feed_rate_per_min",
"export_rate_per_day",
"quota_tolerance",
"estimated_guest_count",
"compression_concurrency",
];
for (key, value) in &body {
if !ALLOWED_KEYS.contains(&key.as_str()) {
return Err(AppError::BadRequest(format!("Unbekannter Konfigurationsschlüssel: {key}")));
}
// Validate numeric values
if value.parse::<f64>().is_err() {
return Err(AppError::BadRequest(format!("Ungültiger Wert für {key}: muss eine Zahl sein.")));
}
sqlx::query(
"INSERT INTO config (key, value, updated_at) VALUES ($1, $2, NOW())
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value, updated_at = NOW()",
)
.bind(key)
.bind(value)
.execute(&state.pool)
.await?;
}
Ok(StatusCode::NO_CONTENT)
}
pub async fn get_export_jobs(
State(state): State<AppState>,
RequireAdmin(_auth): RequireAdmin,
) -> Result<Json<Vec<ExportJobDto>>, AppError> {
let event = crate::models::event::Event::find_by_slug(&state.pool, &state.config.event_slug)
.await?
.ok_or_else(|| AppError::NotFound("Event nicht gefunden.".into()))?;
let jobs = sqlx::query_as::<_, ExportJobDto>(
"SELECT id, type::text, status::text, progress_pct, error_message, created_at, completed_at
FROM export_job
WHERE event_id = $1
ORDER BY created_at DESC",
)
.bind(event.id)
.fetch_all(&state.pool)
.await?;
Ok(Json(jobs))
}
// ── Export download endpoints (authenticated guests) ─────────────────────────
pub async fn download_zip(
State(state): State<AppState>,
_auth: crate::auth::middleware::AuthUser,
) -> Result<axum::response::Response, AppError> {
let event = crate::models::event::Event::find_by_slug(&state.pool, &state.config.event_slug)
.await?
.ok_or_else(|| AppError::NotFound("Event nicht gefunden.".into()))?;
if !event.export_zip_ready {
return Err(AppError::NotFound(
"Der ZIP-Export ist noch nicht verfügbar.".into(),
));
}
let path = state.config.media_path.join("exports").join("Gallery.zip");
if !path.exists() {
return Err(AppError::NotFound("Exportdatei nicht gefunden.".into()));
}
serve_file(path, "Gallery.zip", "application/zip").await
}
pub async fn download_html(
State(state): State<AppState>,
_auth: crate::auth::middleware::AuthUser,
) -> Result<axum::response::Response, AppError> {
let event = crate::models::event::Event::find_by_slug(&state.pool, &state.config.event_slug)
.await?
.ok_or_else(|| AppError::NotFound("Event nicht gefunden.".into()))?;
if !event.export_html_ready {
return Err(AppError::NotFound(
"Der HTML-Export ist noch nicht verfügbar.".into(),
));
}
let path = state.config.media_path.join("exports").join("Memories.zip");
if !path.exists() {
return Err(AppError::NotFound("Exportdatei nicht gefunden.".into()));
}
serve_file(path, "Memories.zip", "application/zip").await
}
async fn serve_file(
path: std::path::PathBuf,
filename: &str,
content_type: &str,
) -> Result<axum::response::Response, AppError> {
use axum::body::Body;
use axum::http::{header, Response, StatusCode};
use tokio_util::io::ReaderStream;
let file = tokio::fs::File::open(&path)
.await
.map_err(|e| AppError::Internal(e.into()))?;
let metadata = file
.metadata()
.await
.map_err(|e| AppError::Internal(e.into()))?;
let stream = ReaderStream::new(file);
let disposition = format!("attachment; filename=\"{filename}\"");
let response = Response::builder()
.status(StatusCode::OK)
.header(header::CONTENT_TYPE, content_type)
.header(header::CONTENT_DISPOSITION, disposition)
.header(header::CONTENT_LENGTH, metadata.len())
.body(Body::from_stream(stream))
.map_err(|e| AppError::Internal(e.into()))?;
Ok(response)
}
/// Also expose export status to all authenticated users (guests need it for the export page)
pub async fn export_status(
State(state): State<AppState>,
_auth: crate::auth::middleware::AuthUser,
) -> Result<Json<serde_json::Value>, AppError> {
let event = crate::models::event::Event::find_by_slug(&state.pool, &state.config.event_slug)
.await?
.ok_or_else(|| AppError::NotFound("Event nicht gefunden.".into()))?;
let released = event.export_released_at.is_some();
let jobs: Vec<(String, String, i16)> = sqlx::query_as(
"SELECT type::text, status::text, progress_pct FROM export_job WHERE event_id = $1",
)
.bind(event.id)
.fetch_all(&state.pool)
.await?;
let job_status = |type_name: &str| {
jobs.iter()
.find(|(t, _, _)| t == type_name)
.map(|(_, status, pct)| {
serde_json::json!({ "status": status, "progress_pct": pct })
})
.unwrap_or_else(|| serde_json::json!({ "status": "locked", "progress_pct": 0 }))
};
Ok(Json(serde_json::json!({
"released": released,
"zip": job_status("zip"),
"html": job_status("html"),
})))
}

View File

@@ -0,0 +1,258 @@
use axum::extract::{Query, State};
use axum::Json;
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use uuid::Uuid;
use crate::auth::middleware::AuthUser;
use crate::error::AppError;
use crate::state::AppState;
#[derive(Deserialize)]
pub struct FeedQuery {
pub cursor: Option<Uuid>,
pub limit: Option<i64>,
pub hashtag: Option<String>,
}
#[derive(Serialize)]
pub struct FeedUpload {
pub id: Uuid,
pub user_id: Uuid,
pub uploader_name: String,
pub preview_url: Option<String>,
pub thumbnail_url: Option<String>,
pub mime_type: String,
pub caption: Option<String>,
pub like_count: i64,
pub comment_count: i64,
pub liked_by_me: bool,
pub created_at: DateTime<Utc>,
}
#[derive(Serialize)]
pub struct FeedResponse {
pub uploads: Vec<FeedUpload>,
pub next_cursor: Option<Uuid>,
}
#[derive(sqlx::FromRow)]
struct FeedRow {
id: Uuid,
user_id: Uuid,
uploader_name: String,
preview_path: Option<String>,
thumbnail_path: Option<String>,
mime_type: String,
caption: Option<String>,
like_count: i64,
comment_count: i64,
created_at: DateTime<Utc>,
}
pub async fn feed(
State(state): State<AppState>,
auth: AuthUser,
Query(q): Query<FeedQuery>,
) -> Result<Json<FeedResponse>, AppError> {
let limit = q.limit.unwrap_or(20).min(100);
let rows = if let Some(hashtag) = &q.hashtag {
let tag = hashtag.trim().trim_start_matches('#').to_lowercase();
sqlx::query_as::<_, FeedRow>(
"SELECT v.id, v.user_id, v.uploader_name, v.preview_path, v.thumbnail_path,
v.mime_type, v.caption, v.like_count, v.comment_count, v.created_at
FROM v_feed v
JOIN upload_hashtag uh ON uh.upload_id = v.id
JOIN hashtag h ON h.id = uh.hashtag_id AND h.tag = $1
WHERE v.event_id = $2
AND ($3::timestamptz IS NULL OR v.created_at < $3)
ORDER BY v.created_at DESC
LIMIT $4",
)
.bind(&tag)
.bind(auth.event_id)
.bind(
if let Some(cursor) = q.cursor {
get_cursor_time(&state.pool, cursor).await
} else {
None
},
)
.bind(limit + 1)
.fetch_all(&state.pool)
.await?
} else {
sqlx::query_as::<_, FeedRow>(
"SELECT id, user_id, uploader_name, preview_path, thumbnail_path,
mime_type, caption, like_count, comment_count, created_at
FROM v_feed
WHERE event_id = $1
AND ($2::timestamptz IS NULL OR created_at < $2)
ORDER BY created_at DESC
LIMIT $3",
)
.bind(auth.event_id)
.bind(
if let Some(cursor) = q.cursor {
get_cursor_time(&state.pool, cursor).await
} else {
None
},
)
.bind(limit + 1)
.fetch_all(&state.pool)
.await?
};
let has_more = rows.len() as i64 > limit;
let rows: Vec<FeedRow> = rows.into_iter().take(limit as usize).collect();
let next_cursor = if has_more { rows.last().map(|r| r.id) } else { None };
// Batch check which uploads the current user has liked
let upload_ids: Vec<Uuid> = rows.iter().map(|r| r.id).collect();
let liked_set = get_liked_set(&state.pool, auth.user_id, &upload_ids).await;
let uploads = rows
.into_iter()
.map(|r| {
let preview_url = r.preview_path.map(|p| format!("/media/{p}"));
let thumbnail_url = r.thumbnail_path.map(|p| format!("/media/{p}"));
FeedUpload {
liked_by_me: liked_set.contains(&r.id),
id: r.id,
user_id: r.user_id,
uploader_name: r.uploader_name,
preview_url,
thumbnail_url,
mime_type: r.mime_type,
caption: r.caption,
like_count: r.like_count,
comment_count: r.comment_count,
created_at: r.created_at,
}
})
.collect();
Ok(Json(FeedResponse {
uploads,
next_cursor,
}))
}
#[derive(Deserialize)]
pub struct DeltaQuery {
pub since: DateTime<Utc>,
}
#[derive(Serialize)]
pub struct DeltaResponse {
pub uploads: Vec<FeedUpload>,
pub deleted_ids: Vec<Uuid>,
}
pub async fn feed_delta(
State(state): State<AppState>,
auth: AuthUser,
Query(q): Query<DeltaQuery>,
) -> Result<Json<DeltaResponse>, AppError> {
let rows = sqlx::query_as::<_, FeedRow>(
"SELECT id, user_id, uploader_name, preview_path, thumbnail_path,
mime_type, caption, like_count, comment_count, created_at
FROM v_feed
WHERE event_id = $1 AND created_at > $2
ORDER BY created_at DESC",
)
.bind(auth.event_id)
.bind(q.since)
.fetch_all(&state.pool)
.await?;
let deleted_ids: Vec<(Uuid,)> = sqlx::query_as(
"SELECT id FROM upload
WHERE event_id = $1 AND deleted_at IS NOT NULL AND deleted_at > $2",
)
.bind(auth.event_id)
.bind(q.since)
.fetch_all(&state.pool)
.await?;
let upload_ids: Vec<Uuid> = rows.iter().map(|r| r.id).collect();
let liked_set = get_liked_set(&state.pool, auth.user_id, &upload_ids).await;
let uploads = rows
.into_iter()
.map(|r| FeedUpload {
liked_by_me: liked_set.contains(&r.id),
id: r.id,
user_id: r.user_id,
uploader_name: r.uploader_name,
preview_url: r.preview_path.map(|p| format!("/media/{p}")),
thumbnail_url: r.thumbnail_path.map(|p| format!("/media/{p}")),
mime_type: r.mime_type,
caption: r.caption,
like_count: r.like_count,
comment_count: r.comment_count,
created_at: r.created_at,
})
.collect();
Ok(Json(DeltaResponse {
uploads,
deleted_ids: deleted_ids.into_iter().map(|r| r.0).collect(),
}))
}
#[derive(Serialize)]
pub struct HashtagCount {
pub tag: String,
pub count: i64,
}
pub async fn hashtags(
State(state): State<AppState>,
auth: AuthUser,
) -> Result<Json<Vec<HashtagCount>>, AppError> {
let rows: Vec<(String, i64)> = sqlx::query_as(
"SELECT tag, upload_count FROM v_hashtag_counts WHERE event_id = $1",
)
.bind(auth.event_id)
.fetch_all(&state.pool)
.await?;
Ok(Json(
rows.into_iter()
.map(|(tag, count)| HashtagCount { tag, count })
.collect(),
))
}
async fn get_cursor_time(pool: &sqlx::PgPool, cursor_id: Uuid) -> Option<DateTime<Utc>> {
let row: Option<(DateTime<Utc>,)> =
sqlx::query_as("SELECT created_at FROM upload WHERE id = $1")
.bind(cursor_id)
.fetch_optional(pool)
.await
.ok()?;
row.map(|r| r.0)
}
async fn get_liked_set(
pool: &sqlx::PgPool,
user_id: Uuid,
upload_ids: &[Uuid],
) -> std::collections::HashSet<Uuid> {
if upload_ids.is_empty() {
return std::collections::HashSet::new();
}
let rows: Vec<(Uuid,)> = sqlx::query_as(
"SELECT upload_id FROM \"like\" WHERE user_id = $1 AND upload_id = ANY($2)",
)
.bind(user_id)
.bind(upload_ids)
.fetch_all(pool)
.await
.unwrap_or_default();
rows.into_iter().map(|r| r.0).collect()
}

View File

@@ -0,0 +1,269 @@
use axum::extract::{Path, State};
use axum::http::StatusCode;
use axum::Json;
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use uuid::Uuid;
use crate::auth::middleware::RequireHost;
use crate::error::AppError;
use crate::models::comment::Comment;
use crate::models::event::Event;
use crate::models::upload::Upload;
use crate::state::AppState;
// ── DTOs ─────────────────────────────────────────────────────────────────────
#[derive(Serialize, sqlx::FromRow)]
pub struct UserSummary {
pub id: Uuid,
pub display_name: String,
pub role: String,
pub is_banned: bool,
pub uploads_hidden: bool,
pub upload_count: i64,
pub total_upload_bytes: i64,
pub created_at: DateTime<Utc>,
}
#[derive(Serialize)]
pub struct EventStatus {
pub name: String,
pub is_active: bool,
pub uploads_locked: bool,
pub export_released: bool,
}
#[derive(Deserialize)]
pub struct BanRequest {
pub hide_uploads: bool,
}
#[derive(Deserialize)]
pub struct SetRoleRequest {
pub role: String,
}
// ── Handlers ─────────────────────────────────────────────────────────────────
pub async fn get_event_status(
State(state): State<AppState>,
RequireHost(_auth): RequireHost,
) -> Result<Json<EventStatus>, AppError> {
let event = Event::find_by_slug(&state.pool, &state.config.event_slug)
.await?
.ok_or_else(|| AppError::NotFound("Event nicht gefunden.".into()))?;
Ok(Json(EventStatus {
name: event.name,
is_active: event.is_active,
uploads_locked: event.uploads_locked_at.is_some(),
export_released: event.export_released_at.is_some(),
}))
}
pub async fn list_users(
State(state): State<AppState>,
RequireHost(auth): RequireHost,
) -> Result<Json<Vec<UserSummary>>, AppError> {
let rows = sqlx::query_as::<_, UserSummary>(
"SELECT u.id,
u.display_name,
u.role::text AS role,
u.is_banned,
u.uploads_hidden,
COALESCE(COUNT(up.id), 0) AS upload_count,
u.total_upload_bytes,
u.created_at
FROM \"user\" u
LEFT JOIN upload up ON up.user_id = u.id AND up.deleted_at IS NULL
WHERE u.event_id = $1
GROUP BY u.id
ORDER BY u.created_at ASC",
)
.bind(auth.event_id)
.fetch_all(&state.pool)
.await?;
Ok(Json(rows))
}
pub async fn ban_user(
State(state): State<AppState>,
RequireHost(auth): RequireHost,
Path(user_id): Path<Uuid>,
Json(body): Json<BanRequest>,
) -> Result<StatusCode, AppError> {
// Cannot ban yourself or another host/admin
if user_id == auth.user_id {
return Err(AppError::BadRequest("Du kannst dich nicht selbst sperren.".into()));
}
let target = sqlx::query_as::<_, (String,)>(
"SELECT role::text FROM \"user\" WHERE id = $1 AND event_id = $2",
)
.bind(user_id)
.bind(auth.event_id)
.fetch_optional(&state.pool)
.await?
.ok_or_else(|| AppError::NotFound("Benutzer nicht gefunden.".into()))?;
if target.0 == "admin" || (target.0 == "host" && auth.role != crate::models::user::UserRole::Admin) {
return Err(AppError::Forbidden("Du kannst diesen Benutzer nicht sperren.".into()));
}
sqlx::query(
"UPDATE \"user\" SET is_banned = TRUE, uploads_hidden = $2 WHERE id = $1",
)
.bind(user_id)
.bind(body.hide_uploads)
.execute(&state.pool)
.await?;
Ok(StatusCode::NO_CONTENT)
}
pub async fn unban_user(
State(state): State<AppState>,
RequireHost(_auth): RequireHost,
Path(user_id): Path<Uuid>,
) -> Result<StatusCode, AppError> {
sqlx::query("UPDATE \"user\" SET is_banned = FALSE WHERE id = $1")
.bind(user_id)
.execute(&state.pool)
.await?;
Ok(StatusCode::NO_CONTENT)
}
pub async fn set_role(
State(state): State<AppState>,
RequireHost(auth): RequireHost,
Path(user_id): Path<Uuid>,
Json(body): Json<SetRoleRequest>,
) -> Result<StatusCode, AppError> {
if user_id == auth.user_id {
return Err(AppError::BadRequest("Du kannst deine eigene Rolle nicht ändern.".into()));
}
let new_role = match body.role.as_str() {
"guest" => "guest",
"host" => "host",
_ => return Err(AppError::BadRequest("Ungültige Rolle. Erlaubt: guest, host.".into())),
};
sqlx::query("UPDATE \"user\" SET role = $2::user_role WHERE id = $1 AND event_id = $3")
.bind(user_id)
.bind(new_role)
.bind(auth.event_id)
.execute(&state.pool)
.await?;
Ok(StatusCode::NO_CONTENT)
}
pub async fn host_delete_upload(
State(state): State<AppState>,
RequireHost(_auth): RequireHost,
Path(upload_id): Path<Uuid>,
) -> Result<StatusCode, AppError> {
let upload = Upload::find_by_id(&state.pool, upload_id)
.await?
.ok_or_else(|| AppError::NotFound("Upload nicht gefunden.".into()))?;
Upload::soft_delete(&state.pool, upload_id).await?;
let _ = state.sse_tx.send(crate::state::SseEvent {
event_type: "upload-deleted".to_string(),
data: serde_json::json!({ "upload_id": upload.id }).to_string(),
});
Ok(StatusCode::NO_CONTENT)
}
pub async fn host_delete_comment(
State(state): State<AppState>,
RequireHost(_auth): RequireHost,
Path(comment_id): Path<Uuid>,
) -> Result<StatusCode, AppError> {
Comment::find_by_id(&state.pool, comment_id)
.await?
.ok_or_else(|| AppError::NotFound("Kommentar nicht gefunden.".into()))?;
Comment::soft_delete(&state.pool, comment_id).await?;
Ok(StatusCode::NO_CONTENT)
}
pub async fn close_event(
State(state): State<AppState>,
RequireHost(_auth): RequireHost,
) -> Result<StatusCode, AppError> {
sqlx::query(
"UPDATE event SET uploads_locked_at = NOW() WHERE slug = $1 AND uploads_locked_at IS NULL",
)
.bind(&state.config.event_slug)
.execute(&state.pool)
.await?;
let _ = state.sse_tx.send(crate::state::SseEvent {
event_type: "event-closed".to_string(),
data: "{}".to_string(),
});
Ok(StatusCode::NO_CONTENT)
}
pub async fn open_event(
State(state): State<AppState>,
RequireHost(_auth): RequireHost,
) -> Result<StatusCode, AppError> {
sqlx::query(
"UPDATE event SET uploads_locked_at = NULL WHERE slug = $1",
)
.bind(&state.config.event_slug)
.execute(&state.pool)
.await?;
let _ = state.sse_tx.send(crate::state::SseEvent {
event_type: "event-opened".to_string(),
data: "{}".to_string(),
});
Ok(StatusCode::NO_CONTENT)
}
pub async fn release_gallery(
State(state): State<AppState>,
RequireHost(_auth): RequireHost,
) -> Result<StatusCode, AppError> {
let event = Event::find_by_slug(&state.pool, &state.config.event_slug)
.await?
.ok_or_else(|| AppError::NotFound("Event nicht gefunden.".into()))?;
if event.export_released_at.is_some() {
return Err(AppError::BadRequest("Galerie wurde bereits freigegeben.".into()));
}
sqlx::query("UPDATE event SET export_released_at = NOW() WHERE slug = $1")
.bind(&state.config.event_slug)
.execute(&state.pool)
.await?;
// Enqueue export jobs
for export_type in ["zip", "html"] {
sqlx::query(
"INSERT INTO export_job (event_id, type) VALUES ($1, $2::export_type)
ON CONFLICT (event_id, type) DO NOTHING",
)
.bind(event.id)
.bind(export_type)
.execute(&state.pool)
.await?;
}
// Spawn export workers
crate::services::export::spawn_export_jobs(
event.id,
event.name,
state.pool.clone(),
state.config.media_path.clone(),
state.sse_tx.clone(),
);
Ok(StatusCode::NO_CONTENT)
}

View File

@@ -1,2 +1,6 @@
pub mod admin;
pub mod feed;
pub mod host;
pub mod social;
pub mod sse; pub mod sse;
pub mod upload; pub mod upload;

View File

@@ -0,0 +1,136 @@
use axum::extract::{Path, State};
use axum::http::StatusCode;
use axum::Json;
use serde::Deserialize;
use uuid::Uuid;
use crate::auth::middleware::AuthUser;
use crate::error::AppError;
use crate::models::comment::{Comment, CommentDto};
use crate::models::hashtag::{self, Hashtag};
use crate::state::AppState;
pub async fn toggle_like(
State(state): State<AppState>,
auth: AuthUser,
Path(upload_id): Path<Uuid>,
) -> Result<StatusCode, AppError> {
// Check if user is banned
let user = crate::models::user::User::find_by_id(&state.pool, auth.user_id)
.await?
.ok_or_else(|| AppError::NotFound("Benutzer nicht gefunden.".into()))?;
if user.is_banned {
return Err(AppError::Forbidden("Du bist gesperrt.".into()));
}
// Try to insert; if conflict, delete (toggle)
let result = sqlx::query(
"INSERT INTO \"like\" (upload_id, user_id) VALUES ($1, $2)
ON CONFLICT (upload_id, user_id) DO NOTHING",
)
.bind(upload_id)
.bind(auth.user_id)
.execute(&state.pool)
.await?;
if result.rows_affected() == 0 {
// Already liked — remove
sqlx::query("DELETE FROM \"like\" WHERE upload_id = $1 AND user_id = $2")
.bind(upload_id)
.bind(auth.user_id)
.execute(&state.pool)
.await?;
}
// Broadcast SSE
let _ = state.sse_tx.send(crate::state::SseEvent {
event_type: "like-update".to_string(),
data: serde_json::json!({ "upload_id": upload_id }).to_string(),
});
Ok(StatusCode::NO_CONTENT)
}
pub async fn list_comments(
State(state): State<AppState>,
_auth: AuthUser,
Path(upload_id): Path<Uuid>,
) -> Result<Json<Vec<CommentDto>>, AppError> {
let comments = Comment::list_for_upload(&state.pool, upload_id).await?;
Ok(Json(comments))
}
#[derive(Deserialize)]
pub struct AddCommentRequest {
pub body: String,
}
pub async fn add_comment(
State(state): State<AppState>,
auth: AuthUser,
Path(upload_id): Path<Uuid>,
Json(body): Json<AddCommentRequest>,
) -> Result<(StatusCode, Json<CommentDto>), AppError> {
let user = crate::models::user::User::find_by_id(&state.pool, auth.user_id)
.await?
.ok_or_else(|| AppError::NotFound("Benutzer nicht gefunden.".into()))?;
if user.is_banned {
return Err(AppError::Forbidden("Du bist gesperrt.".into()));
}
let text = body.body.trim();
if text.is_empty() || text.len() > 500 {
return Err(AppError::BadRequest(
"Kommentar muss zwischen 1 und 500 Zeichen lang sein.".into(),
));
}
let comment = Comment::create(&state.pool, upload_id, auth.user_id, text).await?;
// Process hashtags in comment body
let tags = hashtag::extract_hashtags(text);
for tag in &tags {
let h = Hashtag::upsert(&state.pool, auth.event_id, tag).await?;
sqlx::query(
"INSERT INTO comment_hashtag (comment_id, hashtag_id) VALUES ($1, $2) ON CONFLICT DO NOTHING",
)
.bind(comment.id)
.bind(h.id)
.execute(&state.pool)
.await?;
}
// Broadcast SSE
let _ = state.sse_tx.send(crate::state::SseEvent {
event_type: "new-comment".to_string(),
data: serde_json::json!({ "upload_id": upload_id }).to_string(),
});
let dto = CommentDto {
id: comment.id,
upload_id,
user_id: auth.user_id,
uploader_name: user.display_name,
body: comment.body,
created_at: comment.created_at,
};
Ok((StatusCode::CREATED, Json(dto)))
}
pub async fn delete_comment(
State(state): State<AppState>,
auth: AuthUser,
Path(comment_id): Path<Uuid>,
) -> Result<StatusCode, AppError> {
let comment = Comment::find_by_id(&state.pool, comment_id)
.await?
.ok_or_else(|| AppError::NotFound("Kommentar nicht gefunden.".into()))?;
if comment.user_id != auth.user_id {
return Err(AppError::Forbidden("Nur eigene Kommentare löschen.".into()));
}
Comment::soft_delete(&state.pool, comment_id).await?;
Ok(StatusCode::NO_CONTENT)
}

View File

@@ -1,28 +1,45 @@
use std::convert::Infallible; use std::convert::Infallible;
use std::time::Duration; use std::time::Duration;
use axum::extract::State; use axum::extract::{Query, State};
use axum::response::sse::{Event, KeepAlive, Sse}; use axum::response::sse::{Event, KeepAlive, Sse};
use futures::stream::Stream; use futures::stream::Stream;
use serde::Deserialize;
use tokio_stream::wrappers::BroadcastStream; use tokio_stream::wrappers::BroadcastStream;
use tokio_stream::StreamExt; use tokio_stream::StreamExt;
use crate::auth::middleware::AuthUser; use crate::auth::jwt;
use crate::error::AppError; use crate::error::AppError;
use crate::models::session::Session;
use crate::state::AppState; use crate::state::AppState;
#[derive(Deserialize)]
pub struct SseQuery {
pub token: String,
}
/// SSE stream endpoint. Accepts JWT via query param since EventSource
/// doesn't support custom headers.
pub async fn stream( pub async fn stream(
State(state): State<AppState>, State(state): State<AppState>,
_auth: AuthUser, Query(q): Query<SseQuery>,
) -> Result<Sse<impl Stream<Item = Result<Event, Infallible>>>, AppError> { ) -> Result<Sse<impl Stream<Item = Result<Event, Infallible>>>, AppError> {
// Verify token
let _claims = jwt::verify_token(&q.token, &state.config.jwt_secret)
.map_err(|_| AppError::Unauthorized("Token ungültig.".into()))?;
let token_hash = jwt::hash_token(&q.token);
Session::find_by_token_hash(&state.pool, &token_hash)
.await
.map_err(|e| AppError::Internal(e.into()))?
.ok_or_else(|| AppError::Unauthorized("Sitzung nicht gefunden.".into()))?;
let rx = state.sse_tx.subscribe(); let rx = state.sse_tx.subscribe();
let stream = BroadcastStream::new(rx).filter_map(|msg| { let stream = BroadcastStream::new(rx).filter_map(|msg| match msg {
match msg {
Ok(sse_event) => Some(Ok(Event::default() Ok(sse_event) => Some(Ok(Event::default()
.event(sse_event.event_type) .event(sse_event.event_type)
.data(sse_event.data))), .data(sse_event.data))),
Err(_) => None, // Lagged — skip missed events Err(_) => None,
}
}); });
Ok(Sse::new(stream).keep_alive( Ok(Sse::new(stream).keep_alive(

View File

@@ -1,6 +1,7 @@
use anyhow::Result; use anyhow::Result;
use axum::routing::{delete, get, patch, post}; use axum::routing::{delete, get, patch, post};
use axum::Router; use axum::Router;
use tower_http::services::ServeDir;
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt}; use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
mod auth; mod auth;
@@ -45,12 +46,49 @@ async fn main() -> Result<()> {
"/api/v1/upload/{id}", "/api/v1/upload/{id}",
patch(handlers::upload::edit_upload).delete(handlers::upload::delete_upload), patch(handlers::upload::edit_upload).delete(handlers::upload::delete_upload),
) )
// Feed
.route("/api/v1/feed", get(handlers::feed::feed))
.route("/api/v1/feed/delta", get(handlers::feed::feed_delta))
.route("/api/v1/hashtags", get(handlers::feed::hashtags))
// Social
.route("/api/v1/upload/{id}/like", post(handlers::social::toggle_like))
.route(
"/api/v1/upload/{id}/comments",
get(handlers::social::list_comments).post(handlers::social::add_comment),
)
.route("/api/v1/comment/{id}", delete(handlers::social::delete_comment))
// SSE // SSE
.route("/api/v1/stream", get(handlers::sse::stream)); .route("/api/v1/stream", get(handlers::sse::stream))
// Host Dashboard
.route("/api/v1/host/event", get(handlers::host::get_event_status))
.route("/api/v1/host/event/close", post(handlers::host::close_event))
.route("/api/v1/host/event/open", post(handlers::host::open_event))
.route("/api/v1/host/gallery/release", post(handlers::host::release_gallery))
.route("/api/v1/host/users", get(handlers::host::list_users))
.route("/api/v1/host/users/{id}/ban", post(handlers::host::ban_user))
.route("/api/v1/host/users/{id}/unban", post(handlers::host::unban_user))
.route("/api/v1/host/users/{id}/role", patch(handlers::host::set_role))
.route("/api/v1/host/upload/{id}", delete(handlers::host::host_delete_upload))
.route("/api/v1/host/comment/{id}", delete(handlers::host::host_delete_comment))
// Export (all authenticated users)
.route("/api/v1/export/status", get(handlers::admin::export_status))
.route("/api/v1/export/zip", get(handlers::admin::download_zip))
.route("/api/v1/export/html", get(handlers::admin::download_html))
// Admin Dashboard
.route("/api/v1/admin/stats", get(handlers::admin::get_stats))
.route(
"/api/v1/admin/config",
get(handlers::admin::get_config).patch(handlers::admin::patch_config),
)
.route("/api/v1/admin/export/jobs", get(handlers::admin::get_export_jobs));
// Serve media files from disk
let media_service = ServeDir::new(&config.media_path);
let router = Router::new() let router = Router::new()
.route("/health", get(|| async { "ok" })) .route("/health", get(|| async { "ok" }))
.merge(api) .merge(api)
.nest_service("/media", media_service)
.with_state(state); .with_state(state);
let listener = tokio::net::TcpListener::bind(("0.0.0.0", config.app_port)).await?; let listener = tokio::net::TcpListener::bind(("0.0.0.0", config.app_port)).await?;

View File

@@ -0,0 +1,72 @@
use chrono::{DateTime, Utc};
use serde::Serialize;
use sqlx::PgPool;
use uuid::Uuid;
#[derive(Debug, sqlx::FromRow)]
pub struct Comment {
pub id: Uuid,
pub upload_id: Uuid,
pub user_id: Uuid,
pub body: String,
pub created_at: DateTime<Utc>,
pub deleted_at: Option<DateTime<Utc>>,
}
#[derive(Debug, Serialize, sqlx::FromRow)]
pub struct CommentDto {
pub id: Uuid,
pub upload_id: Uuid,
pub user_id: Uuid,
pub uploader_name: String,
pub body: String,
pub created_at: DateTime<Utc>,
}
impl Comment {
pub async fn create(
pool: &PgPool,
upload_id: Uuid,
user_id: Uuid,
body: &str,
) -> Result<Self, sqlx::Error> {
sqlx::query_as::<_, Self>(
"INSERT INTO comment (upload_id, user_id, body) VALUES ($1, $2, $3) RETURNING *",
)
.bind(upload_id)
.bind(user_id)
.bind(body)
.fetch_one(pool)
.await
}
pub async fn list_for_upload(pool: &PgPool, upload_id: Uuid) -> Result<Vec<CommentDto>, sqlx::Error> {
sqlx::query_as::<_, CommentDto>(
"SELECT c.id, c.upload_id, c.user_id, u.display_name AS uploader_name, c.body, c.created_at
FROM comment c
JOIN \"user\" u ON u.id = c.user_id
WHERE c.upload_id = $1 AND c.deleted_at IS NULL
ORDER BY c.created_at ASC",
)
.bind(upload_id)
.fetch_all(pool)
.await
}
pub async fn find_by_id(pool: &PgPool, id: Uuid) -> Result<Option<Self>, sqlx::Error> {
sqlx::query_as::<_, Self>(
"SELECT * FROM comment WHERE id = $1 AND deleted_at IS NULL",
)
.bind(id)
.fetch_optional(pool)
.await
}
pub async fn soft_delete(pool: &PgPool, id: Uuid) -> Result<(), sqlx::Error> {
sqlx::query("UPDATE comment SET deleted_at = NOW() WHERE id = $1")
.bind(id)
.execute(pool)
.await?;
Ok(())
}
}

View File

@@ -1,3 +1,4 @@
pub mod comment;
pub mod event; pub mod event;
pub mod hashtag; pub mod hashtag;
pub mod session; pub mod session;

View File

@@ -0,0 +1,569 @@
use std::path::{Path, PathBuf};
use anyhow::{Context, Result};
use async_zip::tokio::write::ZipFileWriter;
use async_zip::{Compression, ZipEntryBuilder};
use chrono::{DateTime, Utc};
use futures::io::{copy as fcopy, AllowStdIo};
use serde::Serialize;
use sqlx::PgPool;
use tokio::sync::broadcast;
use tokio_util::compat::TokioAsyncReadCompatExt;
use uuid::Uuid;
use crate::state::SseEvent;
// ── DB query rows ────────────────────────────────────────────────────────────
#[derive(sqlx::FromRow)]
struct ExportUploadRow {
id: Uuid,
original_path: String,
mime_type: String,
caption: Option<String>,
uploader_name: String,
like_count: i64,
created_at: DateTime<Utc>,
}
#[derive(sqlx::FromRow)]
struct ExportCommentRow {
upload_id: Uuid,
uploader_name: String,
body: String,
created_at: DateTime<Utc>,
}
// ── Template context structs ─────────────────────────────────────────────────
#[derive(Serialize)]
struct TmplComment {
uploader_name: String,
body: String,
created_at: String,
}
#[derive(Serialize)]
struct TmplUpload {
id: String,
path: String,
is_video: bool,
caption: String,
uploader_name: String,
like_count: i64,
created_at: String,
comments: Vec<TmplComment>,
hashtags: Vec<String>,
}
// ── Entry point ──────────────────────────────────────────────────────────────
pub fn spawn_export_jobs(
event_id: Uuid,
event_name: String,
pool: PgPool,
media_path: PathBuf,
sse_tx: broadcast::Sender<SseEvent>,
) {
let pool2 = pool.clone();
let media_path2 = media_path.clone();
let sse_tx2 = sse_tx.clone();
let event_name2 = event_name.clone();
tokio::spawn(async move {
if let Err(e) = run_zip_export(event_id, &pool, &media_path, &sse_tx).await {
tracing::error!("ZIP export failed for event {event_id}: {e:#}");
mark_failed(&pool, event_id, "zip", &e.to_string()).await;
}
maybe_broadcast_complete(&pool, event_id, &sse_tx).await;
});
tokio::spawn(async move {
if let Err(e) =
run_html_export(event_id, &event_name2, &pool2, &media_path2, &sse_tx2).await
{
tracing::error!("HTML export failed for event {event_id}: {e:#}");
mark_failed(&pool2, event_id, "html", &e.to_string()).await;
}
maybe_broadcast_complete(&pool2, event_id, &sse_tx2).await;
});
}
// ── ZIP export ───────────────────────────────────────────────────────────────
async fn run_zip_export(
event_id: Uuid,
pool: &PgPool,
media_path: &Path,
sse_tx: &broadcast::Sender<SseEvent>,
) -> Result<()> {
mark_running(pool, event_id, "zip").await;
let uploads = query_uploads(pool, event_id).await?;
let total = uploads.len().max(1) as f32;
let exports_dir = media_path.join("exports");
tokio::fs::create_dir_all(&exports_dir).await?;
let tmp_path = exports_dir.join("Gallery.zip.tmp");
let out_path = exports_dir.join("Gallery.zip");
{
let file = tokio::fs::File::create(&tmp_path).await?;
let mut zip = ZipFileWriter::with_tokio(file);
for (i, row) in uploads.iter().enumerate() {
let src = media_path.join(&row.original_path);
if !src.exists() {
continue;
}
let ext = ext_from_path(&row.original_path);
let date = row.created_at.format("%Y-%m-%d_%H-%M").to_string();
let name_safe = sanitize_name(&row.uploader_name);
let folder = if row.mime_type.starts_with("video/") { "Videos" } else { "Photos" };
let entry_name = format!("{folder}/{date}_{name_safe}_{}.{ext}", row.id);
let builder = ZipEntryBuilder::new(entry_name.into(), Compression::Stored);
let mut entry = zip.write_entry_stream(builder).await?;
let mut f = tokio::fs::File::open(&src).await?.compat();
fcopy(&mut f, &mut entry).await?;
entry.close().await?;
let pct = ((i + 1) as f32 / total * 100.0) as i16;
update_progress(pool, event_id, "zip", pct.min(99)).await;
}
zip.close().await?;
}
tokio::fs::rename(&tmp_path, &out_path).await?;
sqlx::query(
"UPDATE export_job SET status = 'done', progress_pct = 100, file_path = $2, completed_at = NOW()
WHERE event_id = $1 AND type = 'zip'::export_type",
)
.bind(event_id)
.bind("exports/Gallery.zip")
.execute(pool)
.await?;
sqlx::query("UPDATE event SET export_zip_ready = TRUE WHERE id = $1")
.bind(event_id)
.execute(pool)
.await?;
let _ = sse_tx.send(SseEvent {
event_type: "export-progress".to_string(),
data: serde_json::json!({ "type": "zip", "progress_pct": 100 }).to_string(),
});
tracing::info!("ZIP export complete for event {event_id}");
Ok(())
}
// ── HTML export ──────────────────────────────────────────────────────────────
async fn run_html_export(
event_id: Uuid,
event_name: &str,
pool: &PgPool,
media_path: &Path,
sse_tx: &broadcast::Sender<SseEvent>,
) -> Result<()> {
mark_running(pool, event_id, "html").await;
let uploads = query_uploads(pool, event_id).await?;
let comments = query_comments(pool, event_id).await?;
let hashtags_per_upload = query_hashtags(pool, event_id).await?;
let total = uploads.len().max(1) as f32;
let exports_dir = media_path.join("exports");
tokio::fs::create_dir_all(&exports_dir).await?;
// Build template context
let mut tmpl_uploads: Vec<TmplUpload> = Vec::new();
for (i, row) in uploads.iter().enumerate() {
let ext = ext_from_path(&row.original_path);
let date_str = row.created_at.format("%Y-%m-%d_%H-%M").to_string();
let name_safe = sanitize_name(&row.uploader_name);
let folder = if row.mime_type.starts_with("video/") { "Videos" } else { "Photos" };
let filename = format!("{date_str}_{name_safe}_{}.{ext}", row.id);
let upload_comments: Vec<TmplComment> = comments
.iter()
.filter(|c| c.upload_id == row.id)
.map(|c| TmplComment {
uploader_name: c.uploader_name.clone(),
body: c.body.clone(),
created_at: c.created_at.format("%d.%m.%Y %H:%M").to_string(),
})
.collect();
let tags: Vec<String> = hashtags_per_upload
.iter()
.filter(|(uid, _)| *uid == row.id)
.map(|(_, tag)| tag.clone())
.collect();
tmpl_uploads.push(TmplUpload {
id: row.id.to_string(),
path: format!("{folder}/{filename}"),
is_video: row.mime_type.starts_with("video/"),
caption: row.caption.clone().unwrap_or_default(),
uploader_name: row.uploader_name.clone(),
like_count: row.like_count,
created_at: row.created_at.format("%d.%m.%Y %H:%M").to_string(),
comments: upload_comments,
hashtags: tags,
});
let pct = ((i + 1) as f32 / total * 50.0) as i16;
update_progress(pool, event_id, "html", pct.min(49)).await;
}
// Render HTML
let mut env = minijinja::Environment::new();
env.add_template("memories", MEMORIES_TEMPLATE)
.context("template compile error")?;
let tmpl = env.get_template("memories").unwrap();
let html = tmpl
.render(minijinja::context!(
event_name => event_name,
uploads => minijinja::Value::from_serialize(&tmpl_uploads),
generated_at => Utc::now().format("%d.%m.%Y").to_string(),
))
.context("template render error")?;
update_progress(pool, event_id, "html", 55).await;
let tmp_path = exports_dir.join("Memories.zip.tmp");
let out_path = exports_dir.join("Memories.zip");
{
let file = tokio::fs::File::create(&tmp_path).await?;
let mut zip = ZipFileWriter::with_tokio(file);
// Memories.html
{
let builder =
ZipEntryBuilder::new("Memories/Memories.html".into(), Compression::Deflate);
let mut entry = zip.write_entry_stream(builder).await?;
let mut cursor = AllowStdIo::new(std::io::Cursor::new(html.as_bytes()));
fcopy(&mut cursor, &mut entry).await?;
entry.close().await?;
}
update_progress(pool, event_id, "html", 60).await;
// README.txt
{
let builder =
ZipEntryBuilder::new("Memories/README.txt".into(), Compression::Deflate);
let mut entry = zip.write_entry_stream(builder).await?;
let mut cursor = AllowStdIo::new(std::io::Cursor::new(README_TEXT.as_bytes()));
fcopy(&mut cursor, &mut entry).await?;
entry.close().await?;
}
// Media files
for (i, row) in uploads.iter().enumerate() {
let src = media_path.join(&row.original_path);
if !src.exists() {
continue;
}
let ext = ext_from_path(&row.original_path);
let date_str = row.created_at.format("%Y-%m-%d_%H-%M").to_string();
let name_safe = sanitize_name(&row.uploader_name);
let folder = if row.mime_type.starts_with("video/") { "Videos" } else { "Photos" };
let filename = format!("{date_str}_{name_safe}_{}.{ext}", row.id);
let entry_name = format!("Memories/{folder}/{filename}");
let builder = ZipEntryBuilder::new(entry_name.into(), Compression::Stored);
let mut entry = zip.write_entry_stream(builder).await?;
let mut f = tokio::fs::File::open(&src).await?.compat();
fcopy(&mut f, &mut entry).await?;
entry.close().await?;
let pct = 60 + ((i + 1) as f32 / total * 39.0) as i16;
update_progress(pool, event_id, "html", pct.min(99)).await;
}
zip.close().await?;
}
tokio::fs::rename(&tmp_path, &out_path).await?;
sqlx::query(
"UPDATE export_job SET status = 'done', progress_pct = 100, file_path = $2, completed_at = NOW()
WHERE event_id = $1 AND type = 'html'::export_type",
)
.bind(event_id)
.bind("exports/Memories.zip")
.execute(pool)
.await?;
sqlx::query("UPDATE event SET export_html_ready = TRUE WHERE id = $1")
.bind(event_id)
.execute(pool)
.await?;
let _ = sse_tx.send(SseEvent {
event_type: "export-progress".to_string(),
data: serde_json::json!({ "type": "html", "progress_pct": 100 }).to_string(),
});
tracing::info!("HTML export complete for event {event_id}");
Ok(())
}
// ── DB helpers ───────────────────────────────────────────────────────────────
async fn query_uploads(pool: &PgPool, event_id: Uuid) -> Result<Vec<ExportUploadRow>> {
Ok(sqlx::query_as::<_, ExportUploadRow>(
"SELECT u.id, u.original_path, u.mime_type, u.caption,
usr.display_name AS uploader_name,
COUNT(DISTINCT l.user_id) AS like_count,
u.created_at
FROM upload u
JOIN \"user\" usr ON usr.id = u.user_id
LEFT JOIN \"like\" l ON l.upload_id = u.id
WHERE u.event_id = $1 AND u.deleted_at IS NULL AND usr.uploads_hidden = FALSE
GROUP BY u.id, usr.display_name
ORDER BY u.created_at ASC",
)
.bind(event_id)
.fetch_all(pool)
.await?)
}
async fn query_comments(pool: &PgPool, event_id: Uuid) -> Result<Vec<ExportCommentRow>> {
Ok(sqlx::query_as::<_, ExportCommentRow>(
"SELECT c.upload_id, usr.display_name AS uploader_name, c.body, c.created_at
FROM comment c
JOIN \"user\" usr ON usr.id = c.user_id
JOIN upload u ON u.id = c.upload_id
WHERE u.event_id = $1 AND c.deleted_at IS NULL AND u.deleted_at IS NULL
ORDER BY c.created_at ASC",
)
.bind(event_id)
.fetch_all(pool)
.await?)
}
async fn query_hashtags(pool: &PgPool, event_id: Uuid) -> Result<Vec<(Uuid, String)>> {
let rows: Vec<(Uuid, String)> = sqlx::query_as(
"SELECT uh.upload_id, h.tag
FROM upload_hashtag uh
JOIN hashtag h ON h.id = uh.hashtag_id
JOIN upload u ON u.id = uh.upload_id
WHERE h.event_id = $1 AND u.deleted_at IS NULL",
)
.bind(event_id)
.fetch_all(pool)
.await?;
Ok(rows)
}
async fn mark_running(pool: &PgPool, event_id: Uuid, export_type: &str) {
let _ = sqlx::query(
"UPDATE export_job SET status = 'running' WHERE event_id = $1 AND type = $2::export_type",
)
.bind(event_id)
.bind(export_type)
.execute(pool)
.await;
}
async fn mark_failed(pool: &PgPool, event_id: Uuid, export_type: &str, msg: &str) {
let _ = sqlx::query(
"UPDATE export_job SET status = 'failed', error_message = $3
WHERE event_id = $1 AND type = $2::export_type",
)
.bind(event_id)
.bind(export_type)
.bind(msg)
.execute(pool)
.await;
}
async fn update_progress(pool: &PgPool, event_id: Uuid, export_type: &str, pct: i16) {
let _ = sqlx::query(
"UPDATE export_job SET progress_pct = $3 WHERE event_id = $1 AND type = $2::export_type",
)
.bind(event_id)
.bind(export_type)
.bind(pct)
.execute(pool)
.await;
}
async fn maybe_broadcast_complete(
pool: &PgPool,
event_id: Uuid,
sse_tx: &broadcast::Sender<SseEvent>,
) {
let row: Option<(bool, bool)> = sqlx::query_as(
"SELECT export_zip_ready, export_html_ready FROM event WHERE id = $1",
)
.bind(event_id)
.fetch_optional(pool)
.await
.unwrap_or(None);
if let Some((zip_ready, html_ready)) = row {
if zip_ready && html_ready {
let _ = sse_tx.send(SseEvent {
event_type: "export-available".to_string(),
data: serde_json::json!({ "types": ["zip", "html"] }).to_string(),
});
}
}
}
fn ext_from_path(path: &str) -> &str {
path.rsplit('.').next().unwrap_or("bin")
}
fn sanitize_name(name: &str) -> String {
name.chars()
.map(|c| if c.is_alphanumeric() || c == '-' { c } else { '_' })
.collect()
}
// ── Static content ───────────────────────────────────────────────────────────
const README_TEXT: &str = "Willkommen in der Event-Galerie!\n\
\n\
So geht's:\n\
1. Entpacke diese ZIP-Datei\n\
(Windows: Rechtsklick > \"Alle extrahieren\"; Mac: Doppelklick;\n\
Handy: Dateimanager-App verwenden).\n\
2. Öffne die Datei \"Memories.html\" in deinem Browser\n\
(z. B. Chrome, Safari oder Firefox).\n\
3. Stöbere durch alle Fotos und Videos.\n\
Du kannst nach Hashtags filtern — klicke einfach auf einen Hashtag.\n\
4. Eine Internetverbindung ist nicht nötig.\n\
Alles ist lokal auf deinem Gerät gespeichert.\n\
\n\
Viel Freude mit den Erinnerungen!\n";
const MEMORIES_TEMPLATE: &str = r#"<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{ event_name }} Erinnerungen</title>
<style>
*,*::before,*::after{box-sizing:border-box;margin:0;padding:0}
body{font-family:Georgia,serif;background:#faf7f2;color:#3d2b1f;min-height:100vh}
header{background:#fff8f0;border-bottom:1px solid #e8d9c8;padding:1.5rem 1rem;text-align:center}
header h1{font-size:1.75rem;font-weight:700;color:#5c3317;letter-spacing:.02em}
header p{font-size:.85rem;color:#9a7060;margin-top:.25rem}
.chips{display:flex;flex-wrap:wrap;gap:.5rem;padding:1rem;justify-content:center;border-bottom:1px solid #e8d9c8;background:#fff8f0}
.chip{cursor:pointer;padding:.3rem .8rem;border-radius:999px;border:1px solid #c8a98a;font-size:.8rem;color:#6b4c36;background:#fff;transition:all .15s}
.chip:hover,.chip.active{background:#c8a98a;color:#fff;border-color:#c8a98a}
.grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(220px,1fr));gap:1rem;padding:1.25rem;max-width:1100px;margin:0 auto}
.card{background:#fff;border-radius:.75rem;overflow:hidden;box-shadow:0 1px 4px rgba(0,0,0,.08);cursor:pointer;transition:transform .15s,box-shadow .15s}
.card:hover{transform:translateY(-2px);box-shadow:0 4px 16px rgba(0,0,0,.12)}
.card.hidden{display:none}
.thumb-wrap{position:relative;width:100%;aspect-ratio:1;overflow:hidden;background:#e8d9c8}
.thumb{width:100%;height:100%;object-fit:cover;display:block}
.vid-icon{position:absolute;inset:0;display:flex;align-items:center;justify-content:center;font-size:2.5rem;pointer-events:none}
.card-info{padding:.6rem .75rem}
.card-uploader{font-size:.75rem;color:#9a7060;margin-bottom:.2rem}
.card-caption{font-size:.85rem;color:#3d2b1f;overflow:hidden;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical}
.card-meta{display:flex;align-items:center;gap:.5rem;margin-top:.4rem;font-size:.75rem;color:#b08060}
.lb{display:none;position:fixed;inset:0;z-index:100;background:rgba(0,0,0,.88);overflow-y:auto}
.lb.open{display:flex;flex-direction:column}
.lb-close{position:fixed;top:.75rem;right:1rem;font-size:1.75rem;color:#fff;cursor:pointer;z-index:101;line-height:1}
.lb-close:hover{color:#e8c89a}
.lb-media{max-width:900px;width:100%;margin:3rem auto 0;padding:0 .5rem}
.lb-media img,.lb-media video{width:100%;border-radius:.5rem;max-height:70vh;object-fit:contain;background:#1a1a1a;display:block}
.lb-details{max-width:900px;width:100%;margin:1rem auto 2rem;padding:0 1rem}
.lb-caption{font-size:1rem;color:#fff;margin-bottom:.5rem}
.lb-meta{font-size:.8rem;color:#b08060;margin-bottom:.75rem}
.lb-tags{display:flex;flex-wrap:wrap;gap:.4rem;margin-bottom:1rem}
.lb-tag{font-size:.75rem;color:#e8c89a;background:rgba(255,255,255,.1);padding:.2rem .6rem;border-radius:999px}
.lb-likes{font-size:.85rem;color:#d4a574;margin-bottom:.75rem}
.lb-comments h3{font-size:.9rem;color:#e8d9c8;margin-bottom:.5rem;font-weight:600}
.comment{border-top:1px solid rgba(255,255,255,.1);padding:.5rem 0}
.comment-name{font-size:.75rem;color:#b08060}
.comment-body{font-size:.85rem;color:#e8d9c8;margin-top:.15rem}
.empty{text-align:center;padding:3rem 1rem;color:#b08060;font-size:.95rem}
footer{text-align:center;padding:1.5rem;font-size:.75rem;color:#b08060;border-top:1px solid #e8d9c8;margin-top:2rem}
</style>
</head>
<body>
<header>
<h1>{{ event_name }}</h1>
<p>Erinnerungen · Erstellt am {{ generated_at }}</p>
</header>
{% set ns = namespace(all_tags=[]) %}
{% for u in uploads %}{% for t in u.hashtags %}{% if t not in ns.all_tags %}{% set ns.all_tags = ns.all_tags + [t] %}{% endif %}{% endfor %}{% endfor %}
{% if ns.all_tags %}
<div class="chips" id="chips">
<span class="chip active" data-tag="">Alle</span>
{% for tag in ns.all_tags %}<span class="chip" data-tag="{{ tag }}">#{{ tag }}</span>{% endfor %}
</div>
{% endif %}
{% if uploads %}
<div class="grid" id="grid">
{% for u in uploads %}
<div class="card" data-tags="{{ u.hashtags | join(',') }}" onclick="openLb({{ loop.index0 }})">
<div class="thumb-wrap">
{% if u.is_video %}
<video class="thumb" src="{{ u.path }}" preload="none"></video>
<div class="vid-icon">▶</div>
{% else %}
<img class="thumb" src="{{ u.path }}" alt="" loading="lazy">
{% endif %}
</div>
<div class="card-info">
<div class="card-uploader">{{ u.uploader_name }} · {{ u.created_at }}</div>
{% if u.caption %}<div class="card-caption">{{ u.caption }}</div>{% endif %}
<div class="card-meta">
<span>♡ {{ u.like_count }}</span>
{% if u.comments %}<span>💬 {{ u.comments | length }}</span>{% endif %}
</div>
</div>
</div>
{% endfor %}
</div>
{% else %}
<div class="empty">Noch keine Fotos vorhanden.</div>
{% endif %}
<div class="lb" id="lb">
<span class="lb-close" onclick="closeLb()">×</span>
<div class="lb-media" id="lb-media"></div>
<div class="lb-details" id="lb-details"></div>
</div>
<footer>{{ event_name }} · Offline-Galerie · EventSnap</footer>
<script>
const uploads = {{ uploads | tojson }};
let activeTag = '';
function filterCards(){document.querySelectorAll('#grid .card').forEach((card,i)=>{const tags=(card.dataset.tags||'').split(',').filter(Boolean);card.classList.toggle('hidden',activeTag!==''&&!tags.includes(activeTag));});}
document.querySelectorAll('#chips .chip').forEach(chip=>{chip.addEventListener('click',()=>{document.querySelectorAll('#chips .chip').forEach(c=>c.classList.remove('active'));chip.classList.add('active');activeTag=chip.dataset.tag;filterCards();});});
function esc(s){return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');}
function openLb(idx){
const u=uploads[idx];
const lb=document.getElementById('lb');
const media=document.getElementById('lb-media');
const details=document.getElementById('lb-details');
if(u.is_video){media.innerHTML=`<video src="${esc(u.path)}" controls autoplay playsinline></video>`;}
else{media.innerHTML=`<img src="${esc(u.path)}" alt="${esc(u.caption)}">`;}
const tags=u.hashtags.map(t=>`<span class="lb-tag">#${esc(t)}</span>`).join('');
const comments=u.comments.map(c=>`<div class="comment"><div class="comment-name">${esc(c.uploader_name)} · ${esc(c.created_at)}</div><div class="comment-body">${esc(c.body)}</div></div>`).join('');
details.innerHTML=(u.caption?`<div class="lb-caption">${esc(u.caption)}</div>`:'')+
`<div class="lb-meta">${esc(u.uploader_name)} · ${esc(u.created_at)}</div>`+
(tags?`<div class="lb-tags">${tags}</div>`:'')+
`<div class="lb-likes">♡ ${u.like_count} Likes</div>`+
(u.comments.length?`<div class="lb-comments"><h3>Kommentare (${u.comments.length})</h3>${comments}</div>`:'');
lb.classList.add('open');document.body.style.overflow='hidden';
}
function closeLb(){document.getElementById('lb').classList.remove('open');document.getElementById('lb-media').innerHTML='';document.body.style.overflow='';}
document.getElementById('lb').addEventListener('click',e=>{if(e.target===e.currentTarget)closeLb();});
document.addEventListener('keydown',e=>{if(e.key==='Escape')closeLb();});
</script>
</body>
</html>"#;

View File

@@ -1 +1,2 @@
pub mod compression; pub mod compression;
pub mod export;

View File

@@ -1029,6 +1029,7 @@
"integrity": "sha512-MdFRjevVxmAknf2NbaUkDF16jSIzXMWd4Nfah0Qp8TtQVoSp3bV4jKt8mX7z7qTUTWvgSaxtR0EG5WJf53gcuA==", "integrity": "sha512-MdFRjevVxmAknf2NbaUkDF16jSIzXMWd4Nfah0Qp8TtQVoSp3bV4jKt8mX7z7qTUTWvgSaxtR0EG5WJf53gcuA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@standard-schema/spec": "^1.0.0", "@standard-schema/spec": "^1.0.0",
"@sveltejs/acorn-typescript": "^1.0.5", "@sveltejs/acorn-typescript": "^1.0.5",
@@ -1071,6 +1072,7 @@
"integrity": "sha512-ou/d51QSdTyN26D7h6dSpusAKaZkAiGM55/AKYi+9AGZw7q85hElbjK3kEyzXHhLSnRISHOYzVge6x0jRZ7DXA==", "integrity": "sha512-ou/d51QSdTyN26D7h6dSpusAKaZkAiGM55/AKYi+9AGZw7q85hElbjK3kEyzXHhLSnRISHOYzVge6x0jRZ7DXA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@sveltejs/vite-plugin-svelte-inspector": "^5.0.0", "@sveltejs/vite-plugin-svelte-inspector": "^5.0.0",
"deepmerge": "^4.3.1", "deepmerge": "^4.3.1",
@@ -1444,6 +1446,7 @@
"integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"bin": { "bin": {
"acorn": "bin/acorn" "acorn": "bin/acorn"
}, },
@@ -2381,6 +2384,7 @@
"integrity": "sha512-VmtB2rFU/GroZ4oL8+ZqXgSA38O6GR8KSIvWmEFv63pQ0G6KaBH9s07PO8XTXP4vI+3UJUEypOfjkGfmSBBR0w==", "integrity": "sha512-VmtB2rFU/GroZ4oL8+ZqXgSA38O6GR8KSIvWmEFv63pQ0G6KaBH9s07PO8XTXP4vI+3UJUEypOfjkGfmSBBR0w==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@types/estree": "1.0.8" "@types/estree": "1.0.8"
}, },
@@ -2516,6 +2520,7 @@
"integrity": "sha512-QjvU7EFemf6mRzdMGlAFttMWtAAVXrax61SZYHdkD6yoVGQ89VeyKfZD4H1JrV1WLmJBxWhFch9H6ig/87VGjw==", "integrity": "sha512-QjvU7EFemf6mRzdMGlAFttMWtAAVXrax61SZYHdkD6yoVGQ89VeyKfZD4H1JrV1WLmJBxWhFch9H6ig/87VGjw==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@jridgewell/remapping": "^2.3.4", "@jridgewell/remapping": "^2.3.4",
"@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/sourcemap-codec": "^1.5.0",
@@ -2616,6 +2621,7 @@
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"dev": true, "dev": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"peer": true,
"bin": { "bin": {
"tsc": "bin/tsc", "tsc": "bin/tsc",
"tsserver": "bin/tsserver" "tsserver": "bin/tsserver"
@@ -2637,6 +2643,7 @@
"integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==", "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"esbuild": "^0.27.0", "esbuild": "^0.27.0",
"fdir": "^6.5.0", "fdir": "^6.5.0",

View File

@@ -38,6 +38,17 @@ export function clearAuth(): void {
isAuthenticated.set(false); isAuthenticated.set(false);
} }
export function getRole(): 'guest' | 'host' | 'admin' | null {
const token = getToken();
if (!token) return null;
try {
const payload = JSON.parse(atob(token.split('.')[1]));
return payload.role ?? null;
} catch {
return null;
}
}
export function initAuth(): void { export function initAuth(): void {
if (!browser) return; if (!browser) return;
isAuthenticated.set(!!getToken()); isAuthenticated.set(!!getToken());

View File

@@ -0,0 +1,238 @@
<script lang="ts">
import { onMount, onDestroy } from 'svelte';
interface Props {
oncapture: (blob: Blob, type: 'photo' | 'video') => void;
onclose: () => void;
}
let { oncapture, onclose }: Props = $props();
let videoEl: HTMLVideoElement = $state()!;
let canvasEl: HTMLCanvasElement = $state()!;
let stream: MediaStream | null = $state(null);
let facingMode = $state<'environment' | 'user'>('environment');
let recording = $state(false);
let recordingTime = $state(0);
let error = $state<string | null>(null);
let mediaRecorder: MediaRecorder | null = null;
let recordedChunks: Blob[] = [];
let recordingInterval: ReturnType<typeof setInterval> | null = null;
onMount(() => {
startCamera();
});
onDestroy(() => {
stopCamera();
if (recordingInterval) clearInterval(recordingInterval);
});
async function startCamera() {
error = null;
stopCamera();
try {
stream = await navigator.mediaDevices.getUserMedia({
video: { facingMode, width: { ideal: 1920 }, height: { ideal: 1080 } },
audio: true
});
if (videoEl) {
videoEl.srcObject = stream;
}
} catch (err) {
if (err instanceof DOMException && err.name === 'NotAllowedError') {
error = 'Kamerazugriff wurde verweigert. Bitte erlaube den Zugriff in den Browsereinstellungen.';
} else if (err instanceof DOMException && err.name === 'NotFoundError') {
error = 'Keine Kamera gefunden.';
} else {
error = 'Kamera konnte nicht gestartet werden.';
}
}
}
function stopCamera() {
if (stream) {
for (const track of stream.getTracks()) {
track.stop();
}
stream = null;
}
}
async function toggleCamera() {
facingMode = facingMode === 'environment' ? 'user' : 'environment';
await startCamera();
}
function capturePhoto() {
if (!videoEl || !canvasEl) return;
const ctx = canvasEl.getContext('2d');
if (!ctx) return;
canvasEl.width = videoEl.videoWidth;
canvasEl.height = videoEl.videoHeight;
ctx.drawImage(videoEl, 0, 0);
canvasEl.toBlob(
(blob) => {
if (blob) oncapture(blob, 'photo');
},
'image/jpeg',
0.92
);
}
function startRecording() {
if (!stream) return;
recordedChunks = [];
recordingTime = 0;
const mimeType = MediaRecorder.isTypeSupported('video/webm;codecs=vp9')
? 'video/webm;codecs=vp9'
: MediaRecorder.isTypeSupported('video/webm')
? 'video/webm'
: 'video/mp4';
mediaRecorder = new MediaRecorder(stream, { mimeType });
mediaRecorder.ondataavailable = (e) => {
if (e.data.size > 0) recordedChunks.push(e.data);
};
mediaRecorder.onstop = () => {
const blob = new Blob(recordedChunks, { type: mediaRecorder?.mimeType ?? mimeType });
oncapture(blob, 'video');
recordedChunks = [];
};
mediaRecorder.start(1000);
recording = true;
recordingInterval = setInterval(() => {
recordingTime += 1;
}, 1000);
}
function stopRecording() {
if (mediaRecorder && mediaRecorder.state !== 'inactive') {
mediaRecorder.stop();
}
recording = false;
if (recordingInterval) {
clearInterval(recordingInterval);
recordingInterval = null;
}
}
function formatRecordingTime(seconds: number): string {
const m = Math.floor(seconds / 60);
const s = seconds % 60;
return `${m}:${String(s).padStart(2, '0')}`;
}
</script>
<div class="fixed inset-0 z-50 flex flex-col bg-black">
<!-- Camera preview -->
<div class="relative flex-1 overflow-hidden">
{#if error}
<div class="flex h-full items-center justify-center p-8">
<div class="rounded-lg bg-gray-900 p-6 text-center">
<svg class="mx-auto mb-3 h-12 w-12 text-red-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M15 10l-4 4m0-4l4 4m6-4a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<p class="text-sm text-white">{error}</p>
<button
onclick={onclose}
class="mt-4 rounded-lg bg-white/20 px-4 py-2 text-sm text-white"
>
Schliessen
</button>
</div>
</div>
{:else}
<!-- svelte-ignore a11y_media_has_caption -->
<video
bind:this={videoEl}
autoplay
playsinline
muted
class="h-full w-full object-cover {facingMode === 'user' ? 'scale-x-[-1]' : ''}"
></video>
{#if recording}
<div class="absolute left-4 top-4 flex items-center gap-2 rounded-full bg-red-600 px-3 py-1">
<div class="h-2 w-2 animate-pulse rounded-full bg-white"></div>
<span class="text-sm font-medium text-white">{formatRecordingTime(recordingTime)}</span>
</div>
{/if}
{/if}
</div>
<!-- Controls -->
{#if !error}
<div class="flex items-center justify-center gap-8 bg-black/80 px-4 py-6">
<!-- Close -->
<button
onclick={onclose}
class="flex h-12 w-12 items-center justify-center rounded-full bg-white/20 text-white"
aria-label="Schliessen"
>
<svg class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
<!-- Capture photo / record video -->
{#if recording}
<button
onclick={stopRecording}
class="flex h-16 w-16 items-center justify-center rounded-full border-4 border-white bg-red-600"
aria-label="Aufnahme stoppen"
>
<div class="h-6 w-6 rounded-sm bg-white"></div>
</button>
{:else}
<button
onclick={capturePhoto}
class="flex h-16 w-16 items-center justify-center rounded-full border-4 border-white bg-white/20 transition active:bg-white/40"
aria-label="Foto aufnehmen"
>
<div class="h-12 w-12 rounded-full bg-white"></div>
</button>
{/if}
<!-- Toggle camera / start recording -->
{#if recording}
<div class="h-12 w-12"></div>
{:else}
<button
onclick={toggleCamera}
class="flex h-12 w-12 items-center justify-center rounded-full bg-white/20 text-white"
aria-label="Kamera wechseln"
>
<svg class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg>
</button>
{/if}
</div>
<!-- Video record button -->
{#if !recording}
<div class="flex justify-center bg-black/80 pb-4">
<button
onclick={startRecording}
class="flex items-center gap-2 rounded-full bg-red-600/80 px-4 py-2 text-sm text-white transition hover:bg-red-600"
>
<div class="h-2.5 w-2.5 rounded-full bg-white"></div>
Video aufnehmen
</button>
</div>
{/if}
{/if}
</div>
<!-- Hidden canvas for photo capture -->
<canvas bind:this={canvasEl} class="hidden"></canvas>

View File

@@ -0,0 +1,80 @@
<script lang="ts">
import type { FeedUpload } from '$lib/types';
interface Props {
uploads: FeedUpload[];
onlike: (id: string) => void;
oncomment: (id: string) => void;
onselect: (upload: FeedUpload) => void;
}
let { uploads, onlike, oncomment, onselect }: Props = $props();
function isVideo(mime: string): boolean {
return mime.startsWith('video/');
}
function imageUrl(upload: FeedUpload): string {
if (upload.thumbnail_url) return upload.thumbnail_url;
if (upload.preview_url) return upload.preview_url;
return '';
}
</script>
<div class="grid grid-cols-2 gap-2 sm:grid-cols-3">
{#each uploads as upload (upload.id)}
<div class="group relative aspect-square cursor-pointer overflow-hidden rounded-lg bg-gray-100">
<button
onclick={() => onselect(upload)}
class="block h-full w-full"
aria-label="Upload anzeigen"
>
{#if isVideo(upload.mime_type)}
<div class="flex h-full items-center justify-center bg-gray-800">
{#if imageUrl(upload)}
<img src={imageUrl(upload)} alt="" class="h-full w-full object-cover" />
{/if}
<div class="absolute inset-0 flex items-center justify-center">
<svg class="h-10 w-10 text-white/80" fill="currentColor" viewBox="0 0 24 24">
<path d="M8 5v14l11-7z" />
</svg>
</div>
</div>
{:else if imageUrl(upload)}
<img src={imageUrl(upload)} alt="" class="h-full w-full object-cover" loading="lazy" />
{:else}
<div class="flex h-full items-center justify-center text-gray-400">
<svg class="h-8 w-8" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" />
</svg>
</div>
{/if}
</button>
<!-- Overlay with name and stats -->
<div class="pointer-events-none absolute inset-x-0 bottom-0 bg-gradient-to-t from-black/60 to-transparent p-2">
<p class="truncate text-xs font-medium text-white">{upload.uploader_name}</p>
<div class="mt-0.5 flex items-center gap-3 text-xs text-white/80">
<button
class="pointer-events-auto flex items-center gap-0.5"
onclick={(e) => { e.stopPropagation(); onlike(upload.id); }}
>
<svg class="h-3.5 w-3.5 {upload.liked_by_me ? 'fill-red-400 text-red-400' : ''}" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4.318 6.318a4.5 4.5 0 000 6.364L12 20.364l7.682-7.682a4.5 4.5 0 00-6.364-6.364L12 7.636l-1.318-1.318a4.5 4.5 0 00-6.364 0z" />
</svg>
{upload.like_count}
</button>
<button
class="pointer-events-auto flex items-center gap-0.5"
onclick={(e) => { e.stopPropagation(); oncomment(upload.id); }}
>
<svg class="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z" />
</svg>
{upload.comment_count}
</button>
</div>
</div>
</div>
{/each}
</div>

View File

@@ -0,0 +1,42 @@
<script lang="ts">
interface HashtagCount {
tag: string;
count: number;
}
interface Props {
hashtags: HashtagCount[];
selected: string | null;
onselect: (tag: string | null) => void;
}
let { hashtags, selected, onselect }: Props = $props();
</script>
{#if hashtags.length > 0}
<div class="flex gap-2 overflow-x-auto pb-2">
<button
onclick={() => onselect(null)}
class="shrink-0 rounded-full px-3 py-1 text-sm font-medium transition {
selected === null
? 'bg-blue-600 text-white'
: 'bg-gray-200 text-gray-700 hover:bg-gray-300'
}"
>
Alle
</button>
{#each hashtags as h (h.tag)}
<button
onclick={() => onselect(h.tag)}
class="shrink-0 rounded-full px-3 py-1 text-sm font-medium transition {
selected === h.tag
? 'bg-blue-600 text-white'
: 'bg-gray-200 text-gray-700 hover:bg-gray-300'
}"
>
#{h.tag}
<span class="ml-1 text-xs opacity-70">{h.count}</span>
</button>
{/each}
</div>
{/if}

View File

@@ -0,0 +1,186 @@
<script lang="ts">
import type { FeedUpload } from '$lib/types';
import { api, ApiError } from '$lib/api';
import { getUserId } from '$lib/auth';
interface CommentDto {
id: string;
upload_id: string;
user_id: string;
uploader_name: string;
body: string;
created_at: string;
}
interface Props {
upload: FeedUpload;
onclose: () => void;
onlike: (id: string) => void;
}
let { upload, onclose, onlike }: Props = $props();
let comments = $state<CommentDto[]>([]);
let newComment = $state('');
let loading = $state(false);
let userId = getUserId();
$effect(() => {
loadComments();
});
async function loadComments() {
try {
comments = await api.get<CommentDto[]>(`/upload/${upload.id}/comments`);
} catch {
// Ignore
}
}
async function submitComment() {
if (!newComment.trim()) return;
loading = true;
try {
const comment = await api.post<CommentDto>(`/upload/${upload.id}/comment`, {
body: newComment.trim()
});
comments = [...comments, comment];
newComment = '';
} catch {
// Ignore
} finally {
loading = false;
}
}
async function deleteComment(id: string) {
try {
await api.delete(`/comment/${id}`);
comments = comments.filter((c) => c.id !== id);
} catch {
// Ignore
}
}
function isVideo(mime: string): boolean {
return mime.startsWith('video/');
}
function handleKeydown(e: KeyboardEvent) {
if (e.key === 'Escape') onclose();
}
function formatTime(iso: string): string {
return new Date(iso).toLocaleString('de-DE', {
day: '2-digit', month: '2-digit', hour: '2-digit', minute: '2-digit'
});
}
</script>
<svelte:window onkeydown={handleKeydown} />
<div class="fixed inset-0 z-50 flex items-center justify-center bg-black/80 p-4" role="dialog">
<div class="flex max-h-[90vh] w-full max-w-2xl flex-col overflow-hidden rounded-xl bg-white">
<!-- Media -->
<div class="relative bg-black">
<button onclick={onclose} class="absolute right-2 top-2 z-10 rounded-full bg-black/50 p-1.5 text-white hover:bg-black/70">
<svg class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
{#if isVideo(upload.mime_type)}
<video
src={upload.preview_url ?? ''}
controls
class="max-h-[50vh] w-full object-contain"
poster={upload.thumbnail_url ?? undefined}
></video>
{:else}
<img
src={upload.preview_url ?? ''}
alt=""
class="max-h-[50vh] w-full object-contain"
/>
{/if}
</div>
<!-- Info + Comments -->
<div class="flex flex-1 flex-col overflow-hidden">
<div class="border-b border-gray-100 p-3">
<div class="flex items-center justify-between">
<div>
<span class="font-medium text-gray-900">{upload.uploader_name}</span>
<span class="ml-2 text-xs text-gray-400">{formatTime(upload.created_at)}</span>
</div>
<button
onclick={() => onlike(upload.id)}
class="flex items-center gap-1 rounded-full px-2.5 py-1 text-sm transition {
upload.liked_by_me
? 'bg-red-50 text-red-600'
: 'bg-gray-100 text-gray-600 hover:bg-gray-200'
}"
>
<svg class="h-4 w-4 {upload.liked_by_me ? 'fill-current' : ''}" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4.318 6.318a4.5 4.5 0 000 6.364L12 20.364l7.682-7.682a4.5 4.5 0 00-6.364-6.364L12 7.636l-1.318-1.318a4.5 4.5 0 00-6.364 0z" />
</svg>
{upload.like_count}
</button>
</div>
{#if upload.caption}
<p class="mt-1 text-sm text-gray-700">{upload.caption}</p>
{/if}
</div>
<!-- Comments list -->
<div class="flex-1 overflow-y-auto p-3">
{#if comments.length === 0}
<p class="text-center text-sm text-gray-400">Noch keine Kommentare.</p>
{:else}
<div class="space-y-3">
{#each comments as comment (comment.id)}
<div class="flex items-start gap-2">
<div class="flex-1">
<span class="text-sm font-medium text-gray-900">{comment.uploader_name}</span>
<span class="ml-1 text-sm text-gray-700">{comment.body}</span>
<div class="mt-0.5 text-xs text-gray-400">{formatTime(comment.created_at)}</div>
</div>
{#if comment.user_id === userId}
<button
onclick={() => deleteComment(comment.id)}
class="shrink-0 text-gray-400 hover:text-red-500"
aria-label="Löschen"
>
<svg class="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
{/if}
</div>
{/each}
</div>
{/if}
</div>
<!-- Comment input -->
<form
onsubmit={(e) => { e.preventDefault(); submitComment(); }}
class="flex gap-2 border-t border-gray-100 p-3"
>
<input
type="text"
bind:value={newComment}
placeholder="Kommentar schreiben..."
maxlength={500}
class="flex-1 rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-blue-500 focus:outline-none"
/>
<button
type="submit"
disabled={loading || !newComment.trim()}
class="rounded-lg bg-blue-600 px-3 py-2 text-sm font-medium text-white transition hover:bg-blue-700 disabled:opacity-50"
>
Senden
</button>
</form>
</div>
</div>
</div>

86
frontend/src/lib/sse.ts Normal file
View File

@@ -0,0 +1,86 @@
import { getToken } from './auth';
type EventHandler = (data: string) => void;
let eventSource: EventSource | null = null;
let lastEventTime: string | null = null;
const handlers: Map<string, EventHandler[]> = new Map();
export function onSseEvent(eventType: string, handler: EventHandler): () => void {
if (!handlers.has(eventType)) {
handlers.set(eventType, []);
}
handlers.get(eventType)!.push(handler);
// Return unsubscribe function
return () => {
const list = handlers.get(eventType);
if (list) {
const idx = list.indexOf(handler);
if (idx >= 0) list.splice(idx, 1);
}
};
}
export function connectSse(): void {
const token = getToken();
if (!token || eventSource) return;
// EventSource doesn't support custom headers, so pass token as query param
// The backend will need to accept this — or we use a polyfill / fetch-based SSE
// For simplicity, use native EventSource with token in URL
eventSource = new EventSource(`/api/v1/stream?token=${encodeURIComponent(token)}`);
eventSource.onopen = () => {
lastEventTime = new Date().toISOString();
};
eventSource.addEventListener('new-upload', (e) => dispatch('new-upload', e.data));
eventSource.addEventListener('upload-processed', (e) => dispatch('upload-processed', e.data));
eventSource.addEventListener('like-update', (e) => dispatch('like-update', e.data));
eventSource.addEventListener('new-comment', (e) => dispatch('new-comment', e.data));
eventSource.addEventListener('export-available', (e) => dispatch('export-available', e.data));
eventSource.onerror = () => {
// EventSource auto-reconnects, but we track the time for delta-fetch
disconnectSse();
// Reconnect after a short delay
setTimeout(connectSse, 3000);
};
}
export function disconnectSse(): void {
if (eventSource) {
eventSource.close();
eventSource = null;
}
}
export function getLastEventTime(): string | null {
return lastEventTime;
}
export function setLastEventTime(time: string): void {
lastEventTime = time;
}
function dispatch(eventType: string, data: string): void {
lastEventTime = new Date().toISOString();
const list = handlers.get(eventType);
if (list) {
for (const handler of list) {
handler(data);
}
}
}
// Page Visibility API integration
if (typeof document !== 'undefined') {
document.addEventListener('visibilitychange', () => {
if (document.hidden) {
disconnectSse();
} else {
connectSse();
}
});
}

23
frontend/src/lib/types.ts Normal file
View File

@@ -0,0 +1,23 @@
export interface FeedUpload {
id: string;
user_id: string;
uploader_name: string;
preview_url: string | null;
thumbnail_url: string | null;
mime_type: string;
caption: string | null;
like_count: number;
comment_count: number;
liked_by_me: boolean;
created_at: string;
}
export interface FeedResponse {
uploads: FeedUpload[];
next_cursor: string | null;
}
export interface HashtagCount {
tag: string;
count: number;
}

View File

@@ -0,0 +1,266 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { getToken, getRole } from '$lib/auth';
import { api } from '$lib/api';
import { onMount } from 'svelte';
interface StatsDto {
user_count: number;
upload_count: number;
comment_count: number;
disk_total_bytes: number;
disk_used_bytes: number;
disk_free_bytes: number;
}
interface ExportJob {
id: string;
type: string;
status: string;
progress_pct: number;
error_message: string | null;
created_at: string;
completed_at: string | null;
}
const CONFIG_LABELS: Record<string, string> = {
max_image_size_mb: 'Max. Bildgröße (MB)',
max_video_size_mb: 'Max. Videogröße (MB)',
upload_rate_per_hour: 'Upload-Limit pro Stunde',
feed_rate_per_min: 'Feed-Anfragen pro Minute',
export_rate_per_day: 'Export-Downloads pro Tag',
quota_tolerance: 'Speicherkontingent-Toleranz (01)',
estimated_guest_count: 'Geschätzte Gästezahl',
compression_concurrency: 'Kompressions-Worker'
};
let stats = $state<StatsDto | null>(null);
let config = $state<Record<string, string>>({});
let configDraft = $state<Record<string, string>>({});
let exportJobs = $state<ExportJob[]>([]);
let loading = $state(true);
let saving = $state(false);
let error = $state<string | null>(null);
let toast = $state<string | null>(null);
onMount(async () => {
const token = getToken();
const role = getRole();
if (!token || role !== 'admin') {
goto('/join');
return;
}
await reload();
});
async function reload() {
loading = true;
error = null;
try {
[stats, config, exportJobs] = await Promise.all([
api.get<StatsDto>('/admin/stats'),
api.get<Record<string, string>>('/admin/config'),
api.get<ExportJob[]>('/admin/export/jobs')
]);
configDraft = { ...config };
} catch (e: unknown) {
error = e instanceof Error ? e.message : 'Fehler beim Laden.';
} finally {
loading = false;
}
}
function showToast(msg: string) {
toast = msg;
setTimeout(() => (toast = null), 3000);
}
async function saveConfig() {
saving = true;
try {
// Only send changed values
const changes: Record<string, string> = {};
for (const key of Object.keys(configDraft)) {
if (configDraft[key] !== config[key]) {
changes[key] = configDraft[key];
}
}
if (Object.keys(changes).length === 0) {
showToast('Keine Änderungen.');
return;
}
await api.patch('/admin/config', changes);
config = { ...configDraft };
showToast('Konfiguration gespeichert.');
} catch (e: unknown) {
showToast(e instanceof Error ? e.message : 'Fehler beim Speichern.');
} finally {
saving = false;
}
}
function formatBytes(bytes: number): string {
if (bytes >= 1024 ** 3) return `${(bytes / 1024 ** 3).toFixed(1)} GB`;
if (bytes >= 1024 ** 2) return `${(bytes / 1024 ** 2).toFixed(1)} MB`;
return `${(bytes / 1024).toFixed(1)} KB`;
}
function diskPct(stats: StatsDto): number {
if (stats.disk_total_bytes === 0) return 0;
return Math.round((stats.disk_used_bytes / stats.disk_total_bytes) * 100);
}
function jobLabel(type: string): string {
return type === 'zip' ? 'ZIP-Archiv' : 'HTML-Viewer';
}
function statusBadgeClass(status: string): string {
switch (status) {
case 'done': return 'bg-green-100 text-green-700';
case 'running': return 'bg-blue-100 text-blue-700';
case 'failed': return 'bg-red-100 text-red-700';
default: return 'bg-gray-100 text-gray-600';
}
}
function statusLabel(status: string): string {
switch (status) {
case 'pending': return 'Ausstehend';
case 'running': return 'Läuft';
case 'done': return 'Fertig';
case 'failed': return 'Fehlgeschlagen';
default: return status;
}
}
</script>
<!-- Toast -->
{#if toast}
<div class="fixed bottom-6 left-1/2 z-50 -translate-x-1/2 rounded-full bg-gray-900 px-5 py-2.5 text-sm text-white shadow-lg">
{toast}
</div>
{/if}
<div class="min-h-screen bg-gray-50">
<!-- Header -->
<div class="border-b border-gray-200 bg-white">
<div class="mx-auto flex max-w-3xl items-center justify-between px-4 py-4">
<h1 class="text-xl font-bold text-gray-900">Admin Dashboard</h1>
<div class="flex items-center gap-3">
<a href="/host" class="text-sm text-blue-600 hover:underline">Host-Dashboard</a>
<a href="/feed" class="text-sm text-gray-500 hover:text-gray-700">Galerie</a>
</div>
</div>
</div>
<div class="mx-auto max-w-3xl space-y-4 p-4">
{#if loading}
<div class="py-16 text-center text-gray-400">Laden…</div>
{:else if error}
<div class="rounded-lg bg-red-50 p-4 text-sm text-red-700">{error}</div>
{:else}
<!-- Stats -->
{#if stats}
<div class="rounded-xl border border-gray-200 bg-white p-5">
<h2 class="mb-4 font-semibold text-gray-900">Statistiken</h2>
<div class="grid grid-cols-3 gap-4 text-center">
<div class="rounded-lg bg-gray-50 p-3">
<p class="text-2xl font-bold text-gray-900">{stats.user_count}</p>
<p class="text-xs text-gray-500">Gäste</p>
</div>
<div class="rounded-lg bg-gray-50 p-3">
<p class="text-2xl font-bold text-gray-900">{stats.upload_count}</p>
<p class="text-xs text-gray-500">Uploads</p>
</div>
<div class="rounded-lg bg-gray-50 p-3">
<p class="text-2xl font-bold text-gray-900">{stats.comment_count}</p>
<p class="text-xs text-gray-500">Kommentare</p>
</div>
</div>
<!-- Disk usage -->
<div class="mt-4">
<div class="mb-1 flex items-center justify-between text-xs text-gray-500">
<span>Speicher</span>
<span>{formatBytes(stats.disk_used_bytes)} / {formatBytes(stats.disk_total_bytes)} ({diskPct(stats)} %)</span>
</div>
<div class="h-2 overflow-hidden rounded-full bg-gray-200">
<div
class="h-full rounded-full transition-all {diskPct(stats) >= 90 ? 'bg-red-500' : diskPct(stats) >= 75 ? 'bg-amber-500' : 'bg-blue-500'}"
style="width: {diskPct(stats)}%"
></div>
</div>
<p class="mt-1 text-xs text-gray-400">{formatBytes(stats.disk_free_bytes)} frei</p>
</div>
</div>
{/if}
<!-- Config -->
<div class="rounded-xl border border-gray-200 bg-white p-5">
<h2 class="mb-4 font-semibold text-gray-900">Konfiguration</h2>
<div class="space-y-3">
{#each Object.entries(CONFIG_LABELS) as [key, label]}
<div class="flex items-center gap-3">
<label for={key} class="w-56 shrink-0 text-sm text-gray-700">{label}</label>
<input
id={key}
type="number"
step="any"
bind:value={configDraft[key]}
class="w-32 rounded-lg border border-gray-300 px-3 py-1.5 text-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-200"
/>
</div>
{/each}
</div>
<button
onclick={saveConfig}
disabled={saving}
class="mt-4 rounded-lg bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700 disabled:opacity-50"
>
{saving ? 'Wird gespeichert…' : 'Speichern'}
</button>
</div>
<!-- Export jobs -->
<div class="rounded-xl border border-gray-200 bg-white p-5">
<div class="mb-4 flex items-center justify-between">
<h2 class="font-semibold text-gray-900">Export-Jobs</h2>
<button onclick={reload} class="text-xs text-blue-600 hover:underline">Aktualisieren</button>
</div>
{#if exportJobs.length === 0}
<p class="text-sm text-gray-400">Noch keine Export-Jobs.</p>
{:else}
<div class="space-y-3">
{#each exportJobs as job}
<div class="rounded-lg border border-gray-100 p-3">
<div class="flex items-center justify-between">
<span class="text-sm font-medium text-gray-900">{jobLabel(job.type)}</span>
<span class="rounded-full px-2 py-0.5 text-xs font-medium {statusBadgeClass(job.status)}">
{statusLabel(job.status)}
</span>
</div>
{#if job.status === 'running'}
<div class="mt-2">
<div class="mb-1 flex justify-between text-xs text-gray-500">
<span>Fortschritt</span>
<span>{job.progress_pct} %</span>
</div>
<div class="h-1.5 overflow-hidden rounded-full bg-gray-200">
<div
class="h-full rounded-full bg-blue-500 transition-all"
style="width: {job.progress_pct}%"
></div>
</div>
</div>
{/if}
{#if job.error_message}
<p class="mt-1 text-xs text-red-600">{job.error_message}</p>
{/if}
</div>
{/each}
</div>
{/if}
</div>
{/if}
</div>
</div>

View File

@@ -0,0 +1,195 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { getToken } from '$lib/auth';
import { api } from '$lib/api';
import { onMount, onDestroy } from 'svelte';
import { connectSse, disconnectSse, onSseEvent } from '$lib/sse';
interface JobStatus {
status: 'locked' | 'pending' | 'running' | 'done' | 'failed';
progress_pct: number;
}
interface ExportStatus {
released: boolean;
zip: JobStatus;
html: JobStatus;
}
let status = $state<ExportStatus | null>(null);
let showHtmlGuide = $state(false);
let loading = $state(true);
let unsubscribers: (() => void)[] = [];
onMount(async () => {
if (!getToken()) {
goto('/join');
return;
}
await loadStatus();
connectSse();
unsubscribers.push(
onSseEvent('export-progress', async () => {
await loadStatus();
}),
onSseEvent('export-available', async () => {
await loadStatus();
})
);
});
onDestroy(() => {
disconnectSse();
for (const unsub of unsubscribers) unsub();
});
async function loadStatus() {
try {
status = await api.get<ExportStatus>('/export/status');
} catch {
// ignore
} finally {
loading = false;
}
}
function jobLabel(type: 'zip' | 'html'): string {
return type === 'zip' ? 'ZIP-Archiv (Gallery.zip)' : 'HTML-Viewer (Memories.zip)';
}
function statusText(job: JobStatus): string {
switch (job.status) {
case 'locked': return 'Noch nicht freigegeben';
case 'pending': return 'Wird vorbereitet…';
case 'running': return `Wird erstellt (${job.progress_pct} %)`;
case 'done': return 'Bereit zum Download';
case 'failed': return 'Fehlgeschlagen';
}
}
function downloadZip() {
window.location.href = '/api/v1/export/zip';
}
function downloadHtml() {
showHtmlGuide = true;
}
function confirmHtmlDownload() {
showHtmlGuide = false;
window.location.href = '/api/v1/export/html';
}
</script>
<!-- HTML guide modal -->
{#if showHtmlGuide}
<div class="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4">
<div class="w-full max-w-sm rounded-2xl bg-white p-6 shadow-xl">
<h2 class="mb-3 text-lg font-bold text-gray-900">Hinweis zum HTML-Viewer</h2>
<ol class="mb-4 space-y-2 text-sm text-gray-700">
<li class="flex gap-2"><span class="font-bold text-blue-600">1.</span> ZIP-Datei entpacken (Windows: Rechtsklick → "Alle extrahieren"; Mac: Doppelklick).</li>
<li class="flex gap-2"><span class="font-bold text-blue-600">2.</span> <strong>Memories.html</strong> im Browser öffnen.</li>
<li class="flex gap-2"><span class="font-bold text-blue-600">3.</span> Kein Internet nötig — alles ist lokal gespeichert.</li>
</ol>
<p class="mb-4 rounded-lg bg-amber-50 px-3 py-2 text-xs text-amber-700">
Tipp: Am besten im WLAN herunterladen — die Datei kann mehrere GB groß sein.
</p>
<div class="flex gap-2">
<button
onclick={() => (showHtmlGuide = false)}
class="flex-1 rounded-lg border border-gray-300 py-2 text-sm text-gray-700 hover:bg-gray-50"
>
Abbrechen
</button>
<button
onclick={confirmHtmlDownload}
class="flex-1 rounded-lg bg-blue-600 py-2 text-sm font-medium text-white hover:bg-blue-700"
>
Herunterladen
</button>
</div>
</div>
</div>
{/if}
<div class="min-h-screen bg-gray-50">
<div class="border-b border-gray-200 bg-white">
<div class="mx-auto flex max-w-lg items-center justify-between px-4 py-4">
<h1 class="text-xl font-bold text-gray-900">Export</h1>
<a href="/feed" class="text-sm text-blue-600 hover:underline">Zur Galerie</a>
</div>
</div>
<div class="mx-auto max-w-lg space-y-4 p-4">
{#if loading}
<div class="py-16 text-center text-gray-400">Laden…</div>
{:else if !status?.released}
<div class="rounded-xl border border-gray-200 bg-white p-6 text-center">
<svg class="mx-auto mb-3 h-12 w-12 text-gray-300" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
</svg>
<p class="font-medium text-gray-700">Export noch nicht verfügbar</p>
<p class="mt-1 text-sm text-gray-500">Schau nach der Veranstaltung noch einmal vorbei.</p>
</div>
{:else if status}
<p class="text-sm text-gray-500">Wähle dein bevorzugtes Format:</p>
<!-- ZIP card -->
<div class="rounded-xl border border-gray-200 bg-white p-5">
<div class="flex items-start justify-between gap-4">
<div class="min-w-0">
<h2 class="font-semibold text-gray-900">ZIP-Archiv</h2>
<p class="mt-0.5 text-sm text-gray-500">Alle Original-Fotos und Videos in strukturierten Ordnern.</p>
<p class="mt-1 text-xs {status.zip.status === 'done' ? 'text-green-600' : status.zip.status === 'failed' ? 'text-red-500' : 'text-gray-400'}">
{statusText(status.zip)}
</p>
</div>
<button
onclick={downloadZip}
disabled={status.zip.status !== 'done'}
class="shrink-0 rounded-lg px-4 py-2 text-sm font-medium {status.zip.status === 'done' ? 'bg-blue-600 text-white hover:bg-blue-700' : 'bg-gray-100 text-gray-400 cursor-not-allowed'}"
>
Download
</button>
</div>
{#if status.zip.status === 'running'}
<div class="mt-3">
<div class="h-1.5 overflow-hidden rounded-full bg-gray-200">
<div class="h-full rounded-full bg-blue-500 transition-all" style="width: {status.zip.progress_pct}%"></div>
</div>
</div>
{/if}
</div>
<!-- HTML card -->
<div class="rounded-xl border border-gray-200 bg-white p-5">
<div class="flex items-start justify-between gap-4">
<div class="min-w-0">
<h2 class="font-semibold text-gray-900">HTML-Viewer</h2>
<p class="mt-0.5 text-sm text-gray-500">Schöne Offline-Galerie mit Filterung, Kommentaren und Likes — kein Internet nötig.</p>
<p class="mt-1 text-xs {status.html.status === 'done' ? 'text-green-600' : status.html.status === 'failed' ? 'text-red-500' : 'text-gray-400'}">
{statusText(status.html)}
</p>
</div>
<button
onclick={downloadHtml}
disabled={status.html.status !== 'done'}
class="shrink-0 rounded-lg px-4 py-2 text-sm font-medium {status.html.status === 'done' ? 'bg-blue-600 text-white hover:bg-blue-700' : 'bg-gray-100 text-gray-400 cursor-not-allowed'}"
>
Download
</button>
</div>
{#if status.html.status === 'running'}
<div class="mt-3">
<div class="h-1.5 overflow-hidden rounded-full bg-gray-200">
<div class="h-full rounded-full bg-blue-500 transition-all" style="width: {status.html.progress_pct}%"></div>
</div>
</div>
{/if}
</div>
{/if}
</div>
</div>

View File

@@ -2,36 +2,225 @@
import { goto } from '$app/navigation'; import { goto } from '$app/navigation';
import { getToken, clearAuth } from '$lib/auth'; import { getToken, clearAuth } from '$lib/auth';
import { api } from '$lib/api'; import { api } from '$lib/api';
import { onMount } from 'svelte'; import { connectSse, disconnectSse, onSseEvent } from '$lib/sse';
import { onMount, onDestroy } from 'svelte';
import FeedGrid from '$lib/components/FeedGrid.svelte';
import HashtagChips from '$lib/components/HashtagChips.svelte';
import LightboxModal from '$lib/components/LightboxModal.svelte';
import type { FeedUpload, FeedResponse, HashtagCount } from '$lib/types';
onMount(() => { let uploads = $state<FeedUpload[]>([]);
let hashtags = $state<HashtagCount[]>([]);
let selectedHashtag = $state<string | null>(null);
let nextCursor = $state<string | null>(null);
let loadingMore = $state(false);
let selectedUpload = $state<FeedUpload | null>(null);
let sentinel: HTMLDivElement;
let unsubscribers: (() => void)[] = [];
onMount(async () => {
if (!getToken()) { if (!getToken()) {
goto('/join'); goto('/join');
return;
}
await Promise.all([loadFeed(), loadHashtags()]);
connectSse();
unsubscribers.push(
onSseEvent('new-upload', (data) => {
try {
const upload: FeedUpload = JSON.parse(data);
uploads = [upload, ...uploads];
} catch { /* ignore */ }
}),
onSseEvent('upload-processed', () => {
// Reload feed to get updated preview URLs
loadFeed(true);
}),
onSseEvent('like-update', () => {
loadFeed(true);
}),
onSseEvent('new-comment', () => {
loadFeed(true);
})
);
// Infinite scroll via IntersectionObserver
if (sentinel) {
const observer = new IntersectionObserver(
(entries) => {
if (entries[0].isIntersecting && nextCursor && !loadingMore) {
loadMore();
}
},
{ rootMargin: '200px' }
);
observer.observe(sentinel);
} }
}); });
async function handleLogout() { onDestroy(() => {
disconnectSse();
for (const unsub of unsubscribers) unsub();
});
async function loadFeed(refresh = false) {
try { try {
await api.delete('/session'); const params = new URLSearchParams();
} catch { if (!refresh && nextCursor) params.set('cursor', nextCursor);
// Ignore errors — clear local state regardless if (selectedHashtag) params.set('hashtag', selectedHashtag);
params.set('limit', '20');
const res = await api.get<FeedResponse>(`/feed?${params}`);
if (refresh) {
uploads = res.uploads;
} else {
uploads = res.uploads;
} }
nextCursor = res.next_cursor;
} catch {
// Ignore
}
}
async function loadMore() {
if (!nextCursor || loadingMore) return;
loadingMore = true;
try {
const params = new URLSearchParams();
params.set('cursor', nextCursor);
if (selectedHashtag) params.set('hashtag', selectedHashtag);
params.set('limit', '20');
const res = await api.get<FeedResponse>(`/feed?${params}`);
uploads = [...uploads, ...res.uploads];
nextCursor = res.next_cursor;
} catch {
// Ignore
} finally {
loadingMore = false;
}
}
async function loadHashtags() {
try {
hashtags = await api.get<HashtagCount[]>('/hashtags');
} catch {
// Ignore
}
}
function selectHashtag(tag: string | null) {
selectedHashtag = tag;
nextCursor = null;
loadFeed();
}
async function handleLike(id: string) {
try {
await api.post(`/upload/${id}/like`);
// Toggle locally for instant feedback
uploads = uploads.map((u) =>
u.id === id
? {
...u,
liked_by_me: !u.liked_by_me,
like_count: u.liked_by_me ? u.like_count - 1 : u.like_count + 1
}
: u
);
// Also update lightbox if open
if (selectedUpload?.id === id) {
selectedUpload = {
...selectedUpload,
liked_by_me: !selectedUpload.liked_by_me,
like_count: selectedUpload.liked_by_me
? selectedUpload.like_count - 1
: selectedUpload.like_count + 1
};
}
} catch {
// Ignore
}
}
function openComments(id: string) {
const u = uploads.find((u) => u.id === id);
if (u) selectedUpload = u;
}
async function handleLogout() {
try { await api.delete('/session'); } catch { /* ignore */ }
clearAuth(); clearAuth();
goto('/join'); goto('/join');
} }
</script> </script>
<div class="min-h-screen bg-gray-50 p-4"> <div class="min-h-screen bg-gray-50">
<div class="mx-auto max-w-2xl"> <!-- Header -->
<div class="mb-6 flex items-center justify-between"> <div class="sticky top-0 z-40 border-b border-gray-200 bg-white/95 backdrop-blur">
<h1 class="text-xl font-bold text-gray-900">Galerie</h1> <div class="mx-auto flex max-w-2xl items-center justify-between px-4 py-3">
<h1 class="text-lg font-bold text-gray-900">Galerie</h1>
<div class="flex items-center gap-3">
<a
href="/upload"
class="rounded-lg bg-blue-600 px-3 py-1.5 text-sm font-medium text-white transition hover:bg-blue-700"
>
Hochladen
</a>
<button <button
onclick={handleLogout} onclick={handleLogout}
class="rounded-md bg-gray-200 px-3 py-1 text-sm text-gray-700 hover:bg-gray-300" class="text-sm text-gray-500 hover:text-gray-700"
> >
Abmelden Abmelden
</button> </button>
</div> </div>
<p class="text-gray-600">Die Galerie wird bald hier angezeigt.</p> </div>
<!-- Hashtag filter chips -->
<div class="mx-auto max-w-2xl px-4 pb-2">
<HashtagChips {hashtags} selected={selectedHashtag} onselect={selectHashtag} />
</div>
</div>
<!-- Feed grid -->
<div class="mx-auto max-w-2xl p-4">
{#if uploads.length === 0}
<div class="py-16 text-center">
<p class="text-lg text-gray-400">Noch keine Fotos.</p>
<p class="mt-1 text-sm text-gray-400">Sei der Erste und lade etwas hoch!</p>
<a href="/upload" class="mt-4 inline-block rounded-lg bg-blue-600 px-4 py-2 text-sm font-medium text-white">
Jetzt hochladen
</a>
</div>
{:else}
<FeedGrid
{uploads}
onlike={handleLike}
oncomment={openComments}
onselect={(u) => (selectedUpload = u)}
/>
{/if}
<!-- Infinite scroll sentinel -->
<div bind:this={sentinel} class="h-4"></div>
{#if loadingMore}
<div class="py-4 text-center">
<div class="inline-block h-6 w-6 animate-spin rounded-full border-2 border-gray-300 border-t-blue-600"></div>
</div>
{/if}
</div> </div>
</div> </div>
<!-- Lightbox -->
{#if selectedUpload}
<LightboxModal
upload={selectedUpload}
onclose={() => (selectedUpload = null)}
onlike={handleLike}
/>
{/if}

View File

@@ -0,0 +1,318 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { getToken, getRole } from '$lib/auth';
import { api } from '$lib/api';
import { onMount } from 'svelte';
interface UserSummary {
id: string;
display_name: string;
role: string;
is_banned: boolean;
uploads_hidden: boolean;
upload_count: number;
total_upload_bytes: number;
created_at: string;
}
interface EventStatus {
name: string;
is_active: boolean;
uploads_locked: boolean;
export_released: boolean;
}
let event = $state<EventStatus | null>(null);
let users = $state<UserSummary[]>([]);
let loading = $state(true);
let error = $state<string | null>(null);
// Ban modal state
let banTarget = $state<UserSummary | null>(null);
let banHideUploads = $state(false);
let banSubmitting = $state(false);
// Toast state
let toast = $state<string | null>(null);
onMount(async () => {
const token = getToken();
const role = getRole();
if (!token || (role !== 'host' && role !== 'admin')) {
goto('/join');
return;
}
await reload();
});
async function reload() {
loading = true;
error = null;
try {
[event, users] = await Promise.all([
api.get<EventStatus>('/host/event'),
api.get<UserSummary[]>('/host/users')
]);
} catch (e: unknown) {
error = e instanceof Error ? e.message : 'Fehler beim Laden.';
} finally {
loading = false;
}
}
function showToast(msg: string) {
toast = msg;
setTimeout(() => (toast = null), 3000);
}
async function toggleEventLock() {
if (!event) return;
try {
if (event.uploads_locked) {
await api.post('/host/event/open');
showToast('Uploads wurden wieder geöffnet.');
} else {
await api.post('/host/event/close');
showToast('Uploads wurden gesperrt.');
}
await reload();
} catch (e: unknown) {
showToast(e instanceof Error ? e.message : 'Fehler.');
}
}
async function releaseGallery() {
try {
await api.post('/host/gallery/release');
showToast('Galerie wurde freigegeben. Export wird vorbereitet…');
await reload();
} catch (e: unknown) {
showToast(e instanceof Error ? e.message : 'Fehler.');
}
}
function openBanModal(user: UserSummary) {
banTarget = user;
banHideUploads = false;
}
async function confirmBan() {
if (!banTarget) return;
banSubmitting = true;
try {
await api.post(`/host/users/${banTarget.id}/ban`, { hide_uploads: banHideUploads });
showToast(`${banTarget.display_name} wurde gesperrt.`);
banTarget = null;
await reload();
} catch (e: unknown) {
showToast(e instanceof Error ? e.message : 'Fehler.');
} finally {
banSubmitting = false;
}
}
async function unban(user: UserSummary) {
try {
await api.post(`/host/users/${user.id}/unban`);
showToast(`Sperre für ${user.display_name} aufgehoben.`);
await reload();
} catch (e: unknown) {
showToast(e instanceof Error ? e.message : 'Fehler.');
}
}
async function promoteToHost(user: UserSummary) {
try {
await api.patch(`/host/users/${user.id}/role`, { role: 'host' });
showToast(`${user.display_name} ist jetzt Host.`);
await reload();
} catch (e: unknown) {
showToast(e instanceof Error ? e.message : 'Fehler.');
}
}
async function demoteToGuest(user: UserSummary) {
try {
await api.patch(`/host/users/${user.id}/role`, { role: 'guest' });
showToast(`${user.display_name} ist jetzt Gast.`);
await reload();
} catch (e: unknown) {
showToast(e instanceof Error ? e.message : 'Fehler.');
}
}
function formatBytes(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
}
const myRole = getRole();
</script>
<!-- Ban modal -->
{#if banTarget}
<div class="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4">
<div class="w-full max-w-sm rounded-2xl bg-white p-6 shadow-xl">
<h2 class="mb-1 text-lg font-bold text-gray-900">Benutzer sperren</h2>
<p class="mb-4 text-sm text-gray-600">
Was soll mit den Uploads von <strong>{banTarget.display_name}</strong> passieren?
</p>
<label class="mb-4 flex cursor-pointer items-center gap-3 rounded-lg border border-gray-200 p-3">
<input
type="checkbox"
bind:checked={banHideUploads}
class="h-4 w-4 rounded border-gray-300 text-red-600 focus:ring-red-500"
/>
<span class="text-sm text-gray-700">Uploads aus der Galerie ausblenden</span>
</label>
<div class="flex gap-2">
<button
onclick={() => (banTarget = null)}
class="flex-1 rounded-lg border border-gray-300 py-2 text-sm text-gray-700 hover:bg-gray-50"
>
Abbrechen
</button>
<button
onclick={confirmBan}
disabled={banSubmitting}
class="flex-1 rounded-lg bg-red-600 py-2 text-sm font-medium text-white hover:bg-red-700 disabled:opacity-50"
>
{banSubmitting ? 'Wird gesperrt…' : 'Sperren'}
</button>
</div>
</div>
</div>
{/if}
<!-- Toast -->
{#if toast}
<div class="fixed bottom-6 left-1/2 z-50 -translate-x-1/2 rounded-full bg-gray-900 px-5 py-2.5 text-sm text-white shadow-lg">
{toast}
</div>
{/if}
<div class="min-h-screen bg-gray-50">
<!-- Header -->
<div class="border-b border-gray-200 bg-white">
<div class="mx-auto flex max-w-3xl items-center justify-between px-4 py-4">
<div>
<h1 class="text-xl font-bold text-gray-900">Host Dashboard</h1>
{#if event}
<p class="text-sm text-gray-500">{event.name}</p>
{/if}
</div>
<a href="/feed" class="text-sm text-blue-600 hover:underline">Zur Galerie</a>
</div>
</div>
<div class="mx-auto max-w-3xl space-y-4 p-4">
{#if loading}
<div class="py-16 text-center text-gray-400">Laden…</div>
{:else if error}
<div class="rounded-lg bg-red-50 p-4 text-sm text-red-700">{error}</div>
{:else if event}
<!-- Event controls -->
<div class="rounded-xl border border-gray-200 bg-white p-5">
<h2 class="mb-4 font-semibold text-gray-900">Veranstaltung</h2>
<div class="flex flex-wrap gap-3">
<button
onclick={toggleEventLock}
class="rounded-lg px-4 py-2 text-sm font-medium {event.uploads_locked
? 'bg-green-600 text-white hover:bg-green-700'
: 'bg-amber-500 text-white hover:bg-amber-600'}"
>
{event.uploads_locked ? 'Uploads wieder öffnen' : 'Uploads sperren'}
</button>
<button
onclick={releaseGallery}
disabled={event.export_released}
class="rounded-lg px-4 py-2 text-sm font-medium {event.export_released
? 'cursor-default bg-gray-100 text-gray-400'
: 'bg-blue-600 text-white hover:bg-blue-700'}"
>
{event.export_released ? 'Galerie bereits freigegeben' : 'Galerie freigeben'}
</button>
</div>
<div class="mt-3 flex gap-4 text-xs text-gray-500">
<span class="flex items-center gap-1">
<span class="h-2 w-2 rounded-full {event.uploads_locked ? 'bg-red-500' : 'bg-green-500'}"></span>
Uploads {event.uploads_locked ? 'gesperrt' : 'offen'}
</span>
<span class="flex items-center gap-1">
<span class="h-2 w-2 rounded-full {event.export_released ? 'bg-blue-500' : 'bg-gray-300'}"></span>
Export {event.export_released ? 'freigegeben' : 'gesperrt'}
</span>
</div>
</div>
<!-- User management -->
<div class="rounded-xl border border-gray-200 bg-white">
<div class="border-b border-gray-100 px-5 py-4">
<h2 class="font-semibold text-gray-900">Gäste ({users.length})</h2>
</div>
{#if users.length === 0}
<p class="px-5 py-8 text-center text-sm text-gray-400">Noch keine Gäste.</p>
{:else}
<div class="divide-y divide-gray-100">
{#each users as user}
<div class="flex items-center gap-3 px-5 py-3">
<div class="min-w-0 flex-1">
<div class="flex items-center gap-2">
<span class="truncate font-medium text-gray-900">{user.display_name}</span>
{#if user.role === 'host'}
<span class="rounded-full bg-blue-100 px-2 py-0.5 text-xs font-medium text-blue-700">Host</span>
{:else if user.role === 'admin'}
<span class="rounded-full bg-purple-100 px-2 py-0.5 text-xs font-medium text-purple-700">Admin</span>
{/if}
{#if user.is_banned}
<span class="rounded-full bg-red-100 px-2 py-0.5 text-xs font-medium text-red-700">Gesperrt</span>
{/if}
</div>
<p class="text-xs text-gray-400">
{user.upload_count} Upload{user.upload_count !== 1 ? 's' : ''} · {formatBytes(user.total_upload_bytes)}
</p>
</div>
<div class="flex shrink-0 gap-1.5">
{#if user.role !== 'admin'}
{#if user.is_banned}
<button
onclick={() => unban(user)}
class="rounded-lg bg-gray-100 px-3 py-1.5 text-xs font-medium text-gray-700 hover:bg-gray-200"
>
Entsperren
</button>
{:else}
{#if user.role === 'guest' && (myRole === 'host' || myRole === 'admin')}
<button
onclick={() => promoteToHost(user)}
class="rounded-lg bg-blue-50 px-3 py-1.5 text-xs font-medium text-blue-700 hover:bg-blue-100"
>
Host
</button>
{/if}
{#if user.role === 'host' && myRole === 'admin'}
<button
onclick={() => demoteToGuest(user)}
class="rounded-lg bg-gray-100 px-3 py-1.5 text-xs font-medium text-gray-700 hover:bg-gray-200"
>
Degradieren
</button>
{/if}
<button
onclick={() => openBanModal(user)}
class="rounded-lg bg-red-50 px-3 py-1.5 text-xs font-medium text-red-700 hover:bg-red-100"
>
Sperren
</button>
{/if}
{/if}
</div>
</div>
{/each}
</div>
{/if}
</div>
{/if}
</div>
</div>

View File

@@ -3,11 +3,13 @@
import { getToken } from '$lib/auth'; import { getToken } from '$lib/auth';
import { addToQueue, loadQueue } from '$lib/upload-queue'; import { addToQueue, loadQueue } from '$lib/upload-queue';
import UploadQueue from '$lib/components/UploadQueue.svelte'; import UploadQueue from '$lib/components/UploadQueue.svelte';
import CameraCapture from '$lib/components/CameraCapture.svelte';
import { onMount } from 'svelte'; import { onMount } from 'svelte';
let caption = $state(''); let caption = $state('');
let hashtags = $state(''); let hashtags = $state('');
let fileInput: HTMLInputElement; let fileInput: HTMLInputElement;
let showCamera = $state(false);
onMount(() => { onMount(() => {
if (!getToken()) { if (!getToken()) {
@@ -30,8 +32,23 @@
hashtags = ''; hashtags = '';
if (fileInput) fileInput.value = ''; if (fileInput) fileInput.value = '';
} }
async function handleCapture(blob: Blob, type: 'photo' | 'video') {
const ext = type === 'photo' ? 'jpg' : blob.type.includes('mp4') ? 'mp4' : 'webm';
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
const fileName = `${type}_${timestamp}.${ext}`;
const file = new File([blob], fileName, { type: blob.type });
await addToQueue(file, caption, hashtags);
}
</script> </script>
{#if showCamera}
<CameraCapture
oncapture={handleCapture}
onclose={() => (showCamera = false)}
/>
{/if}
<div class="min-h-screen bg-gray-50 p-4"> <div class="min-h-screen bg-gray-50 p-4">
<div class="mx-auto max-w-lg"> <div class="mx-auto max-w-lg">
<div class="mb-6 flex items-center justify-between"> <div class="mb-6 flex items-center justify-between">
@@ -40,14 +57,16 @@
</div> </div>
<div class="rounded-lg border border-gray-200 bg-white p-4"> <div class="rounded-lg border border-gray-200 bg-white p-4">
<div class="grid grid-cols-2 gap-3">
<!-- File picker -->
<label <label
class="flex cursor-pointer flex-col items-center justify-center rounded-lg border-2 border-dashed border-gray-300 bg-gray-50 p-8 transition hover:border-blue-400 hover:bg-blue-50" class="flex cursor-pointer flex-col items-center justify-center rounded-lg border-2 border-dashed border-gray-300 bg-gray-50 p-6 transition hover:border-blue-400 hover:bg-blue-50"
> >
<svg class="mb-2 h-10 w-10 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor"> <svg class="mb-2 h-8 w-8 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M12 16V4m0 0l-4 4m4-4l4 4M4 20h16" /> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M12 16V4m0 0l-4 4m4-4l4 4M4 20h16" />
</svg> </svg>
<span class="text-sm font-medium text-gray-600">Fotos oder Videos auswählen</span> <span class="text-center text-sm font-medium text-gray-600">Galerie</span>
<span class="mt-1 text-xs text-gray-400">Mehrere Dateien möglich</span> <span class="mt-1 text-center text-xs text-gray-400">Mehrere Dateien</span>
<input <input
bind:this={fileInput} bind:this={fileInput}
type="file" type="file"
@@ -58,6 +77,20 @@
/> />
</label> </label>
<!-- Camera button -->
<button
onclick={() => (showCamera = true)}
class="flex flex-col items-center justify-center rounded-lg border-2 border-dashed border-gray-300 bg-gray-50 p-6 transition hover:border-blue-400 hover:bg-blue-50"
>
<svg class="mb-2 h-8 w-8 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M3 9a2 2 0 012-2h.93a2 2 0 001.664-.89l.812-1.22A2 2 0 0110.07 4h3.86a2 2 0 011.664.89l.812 1.22A2 2 0 0018.07 7H19a2 2 0 012 2v9a2 2 0 01-2 2H5a2 2 0 01-2-2V9z" />
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M15 13a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
<span class="text-sm font-medium text-gray-600">Kamera</span>
<span class="mt-1 text-xs text-gray-400">Foto & Video</span>
</button>
</div>
<div class="mt-4 space-y-3"> <div class="mt-4 space-y-3">
<input <input
type="text" type="text"