Compare commits
1 Commits
feat/html-
...
feat/camer
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2e3097ca40 |
2
.gitignore
vendored
2
.gitignore
vendored
@@ -8,8 +8,6 @@ backend/target/
|
||||
frontend/node_modules/
|
||||
frontend/.svelte-kit/
|
||||
frontend/build/
|
||||
frontend/export-viewer/node_modules/
|
||||
frontend/export-viewer/.svelte-kit/
|
||||
|
||||
# Media uploads (mounted volume in production)
|
||||
media/
|
||||
|
||||
107
TEST_GUIDE.md
107
TEST_GUIDE.md
@@ -1,107 +0,0 @@
|
||||
## Frontend Testing — Step by Step
|
||||
|
||||
Please test each step in order and report any errors (console errors, wrong text, broken UI, API errors).
|
||||
|
||||
### Step 1 — Join flow + PIN modal
|
||||
1. Open **http://localhost:5173/** in your browser (or navigate there if already open)
|
||||
2. You should land on the **join page** (`/join`) with a name input
|
||||
3. Enter your name (e.g. `Max`) and click **Beitreten**
|
||||
4. ✅ Expected: A modal appears showing your 4-digit PIN in large monospace font with a "Kopieren" button
|
||||
5. Click **Weiter zur Galerie**
|
||||
|
||||
### Step 2 — Onboarding guide
|
||||
6. You should land on the **feed page** (`/feed`)
|
||||
7. ✅ Expected: A dark overlay appears at the bottom (or center on desktop) — the onboarding guide — showing step 1 of 4 with a step indicator and the Willkommen screen
|
||||
8. Click **Weiter** through all 4 steps, then **Los geht's!**
|
||||
9. ✅ Expected: Overlay disappears
|
||||
|
||||
### Step 3 — Feed & navigation
|
||||
10. ✅ Expected: Feed shows "Noch keine Fotos." empty state with an upload button
|
||||
11. ✅ Expected: Top-right has an **upload button** (blue) and a **person icon** link
|
||||
|
||||
### Step 4 — My Account page
|
||||
12. Click the **person icon** in the top-right
|
||||
13. ✅ Expected: `/account` page shows your name (`Max`), a blue "Gast" badge, session expiry date, and your PIN displayed large in an amber box
|
||||
14. Click **Kopieren** — check clipboard contains your PIN
|
||||
15. ✅ Expected: Button briefly shows "Kopiert!"
|
||||
16. Click **Zur Galerie** to go back to the feed
|
||||
|
||||
### Step 5 — Upload
|
||||
17. Click **Hochladen** — this takes you to `/upload`
|
||||
18. Try uploading a photo from your device library
|
||||
19. ✅ Expected: Photo appears in queue with a progress bar, then completes
|
||||
20. Go back to `/feed` — ✅ Expected: your photo appears in the feed grid
|
||||
|
||||
### Step 6 — Onboarding guide not shown again
|
||||
21. Reload the page at `/feed`
|
||||
22. ✅ Expected: The onboarding overlay does **not** appear (already dismissed)
|
||||
|
||||
### Step 7 — Recover (open a private/incognito window)
|
||||
23. Open a new **private/incognito** window at **http://localhost:5173/recover**
|
||||
24. Enter the same name (`Max`) and the PIN you copied
|
||||
25. ✅ Expected: You're redirected to the feed with the same account
|
||||
|
||||
### Step 8 — Upload rate-limit auto-retry
|
||||
26. Upload more than 20 photos in one hour to trigger the rate limit
|
||||
27. ✅ Expected: When the limit is hit, remaining items stay **Wartend** (not error)
|
||||
28. ✅ Expected: An amber banner appears in the queue: "Upload-Limit erreicht. Wird in Xs automatisch fortgesetzt."
|
||||
29. ✅ Expected: The countdown ticks down and uploads resume automatically when it reaches 0
|
||||
|
||||
### Step 9 — Name uniqueness (case-insensitive)
|
||||
30. In a private/incognito window go to **http://localhost:5173/join**
|
||||
31. Enter `max` or `MAX` — the same name already taken in Step 1 (different case)
|
||||
32. ✅ Expected: Instead of creating a new account, an amber warning appears: „Max ist bereits vergeben." with name tips
|
||||
33. ✅ Expected: A PIN input and **Anmelden** button appear, plus an **Anderen Namen wählen** button
|
||||
34. Enter your PIN from Step 1 and click **Anmelden**
|
||||
35. ✅ Expected: You're signed in to the existing `Max` account and redirected to the feed
|
||||
36. Alternatively, click **Anderen Namen wählen** — ✅ Expected: the name input reappears with `max` pre-filled so you can edit it
|
||||
|
||||
---
|
||||
|
||||
## Admin & Host Features
|
||||
|
||||
For these steps you need an admin session. Go to **http://localhost:5173/admin/login** and enter the admin password (`admin123` for the dev environment). You'll be redirected to the dashboard automatically.
|
||||
|
||||
### Step 10 — Admin Dashboard: Stats & Config
|
||||
1. Go to **http://localhost:5173/admin**
|
||||
2. ✅ Expected: Stats card shows user count, upload count, comment count, and a disk-usage progress bar
|
||||
3. In the **Konfiguration** section, change **Upload-Limit pro Stunde** to a different value (e.g. `5`) and click **Speichern**
|
||||
4. ✅ Expected: Toast "Konfiguration gespeichert." appears briefly
|
||||
5. Reload — ✅ Expected: the changed value persists
|
||||
|
||||
### Step 11 — Admin Dashboard: Export Jobs
|
||||
6. The **Export-Jobs** section shows all past jobs (likely empty if gallery hasn't been released yet)
|
||||
7. Click **Aktualisieren** — ✅ Expected: list refreshes without a full page reload
|
||||
|
||||
### Step 12 — Host Dashboard: Event Controls
|
||||
8. Navigate to **http://localhost:5173/host** (or click "Host-Dashboard" from the admin page)
|
||||
9. ✅ Expected: Event name shown in the header; two status dots (Uploads open/locked, Export released/locked)
|
||||
10. Click **Uploads sperren**
|
||||
11. ✅ Expected: Toast "Uploads wurden gesperrt."; button changes to "Uploads wieder öffnen"; status dot turns red
|
||||
12. Try uploading a photo as a guest — ✅ Expected: "Uploads sind gesperrt." error
|
||||
13. Click **Uploads wieder öffnen** — ✅ Expected: dot turns green; uploads work again
|
||||
|
||||
### Step 13 — Host Dashboard: User Management
|
||||
14. The **Gäste** list shows all registered users with upload counts and sizes
|
||||
15. Find a guest and click **Host** next to their name
|
||||
16. ✅ Expected: Toast "X ist jetzt Host."; a blue "Host" badge appears next to their name
|
||||
17. As admin, a **Degradieren** button is now visible — click it
|
||||
18. ✅ Expected: Toast "X ist jetzt Gast."; badge disappears
|
||||
|
||||
### Step 14 — Host Dashboard: Ban & Unban
|
||||
19. Click **Sperren** next to a guest
|
||||
20. ✅ Expected: A confirmation modal opens asking what to do with their uploads, with a checkbox "Uploads aus der Galerie ausblenden"
|
||||
21. Leave the checkbox unchecked and click **Sperren**
|
||||
22. ✅ Expected: Toast "X wurde gesperrt."; a red "Gesperrt" badge appears; buttons change to **Entsperren**
|
||||
23. Try uploading as that banned user — ✅ Expected: "Du bist gesperrt." error
|
||||
24. Click **Entsperren** — ✅ Expected: ban lifted; badge gone
|
||||
|
||||
### Step 15 — Gallery Release & Export
|
||||
25. Make sure you have at least a few photos uploaded, then on the Host Dashboard click **Galerie freigeben**
|
||||
26. ✅ Expected: Toast "Galerie wurde freigegeben. Export wird vorbereitet…"; button becomes disabled "Galerie bereits freigegeben"
|
||||
27. Navigate to **http://localhost:5173/export** as any logged-in user
|
||||
28. ✅ Expected: Two cards — **ZIP-Archiv** and **HTML-Viewer** — both initially showing "Wird vorbereitet…" or a progress bar
|
||||
29. Wait for both to show "Bereit zum Download" (reload or wait for SSE to update the UI)
|
||||
30. Click **Download** on the ZIP card — ✅ Expected: `Gallery.zip` downloads
|
||||
31. Click **Download** on the HTML card — ✅ Expected: A guide modal appears explaining how to open the file; click **Herunterladen** to get `Memories.zip`
|
||||
32. In the Admin Dashboard → **Export-Jobs**, click **Aktualisieren** — ✅ Expected: both jobs show "Fertig" with green badges
|
||||
38
backend/Cargo.lock
generated
38
backend/Cargo.lock
generated
@@ -896,8 +896,8 @@ dependencies = [
|
||||
"dotenvy",
|
||||
"futures",
|
||||
"image",
|
||||
"include_dir",
|
||||
"jsonwebtoken",
|
||||
"minijinja",
|
||||
"oxipng",
|
||||
"rand 0.9.2",
|
||||
"serde",
|
||||
@@ -907,7 +907,6 @@ dependencies = [
|
||||
"sysinfo",
|
||||
"tokio",
|
||||
"tokio-stream",
|
||||
"tokio-util",
|
||||
"tower",
|
||||
"tower-http",
|
||||
"tower_governor",
|
||||
@@ -1577,25 +1576,6 @@ version = "1.12.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e7c5cedc30da3a610cac6b4ba17597bdf7152cf974e8aab3afb3d54455e371c8"
|
||||
|
||||
[[package]]
|
||||
name = "include_dir"
|
||||
version = "0.7.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "923d117408f1e49d914f1a379a309cffe4f18c05cf4e3d12e613a15fc81bd0dd"
|
||||
dependencies = [
|
||||
"include_dir_macros",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "include_dir_macros"
|
||||
version = "0.7.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7cab85a7ed0bd5f0e76d93846e0147172bed2e2d3f859bcc33a8d9699cad1a75"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "indexmap"
|
||||
version = "2.13.0"
|
||||
@@ -1851,6 +1831,12 @@ version = "2.8.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79"
|
||||
|
||||
[[package]]
|
||||
name = "memo-map"
|
||||
version = "0.3.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "38d1115007560874e373613744c6fba374c17688327a71c1476d1a5954cc857b"
|
||||
|
||||
[[package]]
|
||||
name = "mime"
|
||||
version = "0.3.17"
|
||||
@@ -1867,6 +1853,16 @@ dependencies = [
|
||||
"unicase",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "minijinja"
|
||||
version = "2.18.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "328251e58ad8e415be6198888fc207502727dc77945806421ab34f35bf012e7d"
|
||||
dependencies = [
|
||||
"memo-map",
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "miniz_oxide"
|
||||
version = "0.8.9"
|
||||
|
||||
@@ -17,7 +17,6 @@ bcrypt = "0.15"
|
||||
uuid = { version = "1", features = ["v4", "serde"] }
|
||||
chrono = { version = "0.4", features = ["serde"] }
|
||||
tokio-stream = { version = "0.1", features = ["sync"] }
|
||||
tokio-util = { version = "0.7", features = ["io", "compat"] }
|
||||
futures = "0.3"
|
||||
sha2 = "0.10"
|
||||
rand = "0.9"
|
||||
@@ -29,7 +28,7 @@ sysinfo = "0.32"
|
||||
image = "0.25"
|
||||
oxipng = "9"
|
||||
async_zip = { version = "0.0.17", features = ["tokio", "deflate"] }
|
||||
include_dir = "0.7"
|
||||
minijinja = "2"
|
||||
|
||||
[profile.release]
|
||||
opt-level = 3
|
||||
|
||||
@@ -11,7 +11,6 @@ RUN mkdir src && echo "fn main(){}" > src/main.rs && \
|
||||
rm -rf src
|
||||
|
||||
COPY src ./src
|
||||
COPY static ./static
|
||||
RUN touch src/main.rs && cargo build --release
|
||||
|
||||
# --- Runtime stage ---
|
||||
|
||||
@@ -1,4 +0,0 @@
|
||||
DROP INDEX IF EXISTS idx_user_event_name_ci;
|
||||
|
||||
CREATE INDEX idx_user_event_name
|
||||
ON "user"(event_id, display_name);
|
||||
@@ -1,15 +0,0 @@
|
||||
-- Deduplicate users with the same name (case-insensitive) per event,
|
||||
-- keeping the oldest account so no real data is lost.
|
||||
DELETE FROM "user"
|
||||
WHERE id NOT IN (
|
||||
SELECT DISTINCT ON (event_id, LOWER(display_name)) id
|
||||
FROM "user"
|
||||
ORDER BY event_id, LOWER(display_name), created_at ASC
|
||||
);
|
||||
|
||||
-- Drop the old non-unique index (replaced below)
|
||||
DROP INDEX IF EXISTS idx_user_event_name;
|
||||
|
||||
-- Unique index enforces one account per name per event (case-insensitive)
|
||||
CREATE UNIQUE INDEX idx_user_event_name_ci
|
||||
ON "user" (event_id, LOWER(display_name));
|
||||
@@ -1,9 +1,7 @@
|
||||
use std::time::Duration;
|
||||
|
||||
use axum::extract::State;
|
||||
use axum::http::{HeaderMap, StatusCode};
|
||||
use axum::http::StatusCode;
|
||||
use axum::Json;
|
||||
use chrono::Utc;
|
||||
use chrono::{Duration, Utc};
|
||||
use rand::Rng;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use uuid::Uuid;
|
||||
@@ -14,7 +12,6 @@ use crate::error::AppError;
|
||||
use crate::models::event::Event;
|
||||
use crate::models::session::Session;
|
||||
use crate::models::user::{User, UserRole};
|
||||
use crate::services::rate_limiter::client_ip;
|
||||
use crate::state::AppState;
|
||||
|
||||
#[derive(Deserialize)]
|
||||
@@ -32,17 +29,8 @@ pub struct JoinResponse {
|
||||
|
||||
pub async fn join(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
Json(body): Json<JoinRequest>,
|
||||
) -> Result<(StatusCode, Json<JoinResponse>), AppError> {
|
||||
let ip = client_ip(&headers, "unknown");
|
||||
if !state.rate_limiter.check(format!("join:{ip}"), 5, Duration::from_secs(60)) {
|
||||
return Err(AppError::TooManyRequests(
|
||||
"Zu viele Anfragen. Bitte warte kurz und versuche es erneut.".into(),
|
||||
None,
|
||||
));
|
||||
}
|
||||
|
||||
let display_name = body.display_name.trim();
|
||||
if display_name.is_empty() || display_name.len() > 50 {
|
||||
return Err(AppError::BadRequest(
|
||||
@@ -57,14 +45,6 @@ pub async fn join(
|
||||
)
|
||||
.await?;
|
||||
|
||||
// Reject if a user with this name (case-insensitive) already exists
|
||||
if User::name_taken(&state.pool, event.id, display_name).await? {
|
||||
return Err(AppError::Conflict(format!(
|
||||
"Der Name \"{}\" ist bereits vergeben.",
|
||||
display_name
|
||||
)));
|
||||
}
|
||||
|
||||
// Generate a 4-digit PIN
|
||||
let pin: String = format!("{:04}", rand::rng().random_range(0..10000u32));
|
||||
let pin_hash =
|
||||
@@ -82,7 +62,7 @@ pub async fn join(
|
||||
.map_err(|e| AppError::Internal(anyhow::anyhow!(e)))?;
|
||||
|
||||
let token_hash = jwt::hash_token(&token);
|
||||
let expires_at = Utc::now() + chrono::Duration::days(state.config.session_expiry_days);
|
||||
let expires_at = Utc::now() + Duration::days(state.config.session_expiry_days);
|
||||
Session::create(&state.pool, user.id, &token_hash, expires_at).await?;
|
||||
|
||||
Ok((
|
||||
@@ -133,7 +113,6 @@ pub async fn recover(
|
||||
if Utc::now() < locked_until {
|
||||
return Err(AppError::TooManyRequests(
|
||||
"Zu viele Versuche. Bitte warte 15 Minuten.".into(),
|
||||
None,
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -155,7 +134,7 @@ pub async fn recover(
|
||||
.map_err(|e| AppError::Internal(anyhow::anyhow!(e)))?;
|
||||
|
||||
let token_hash = jwt::hash_token(&token);
|
||||
let expires_at = Utc::now() + chrono::Duration::days(state.config.session_expiry_days);
|
||||
let expires_at = Utc::now() + Duration::days(state.config.session_expiry_days);
|
||||
Session::create(&state.pool, user.id, &token_hash, expires_at).await?;
|
||||
|
||||
return Ok(Json(RecoverResponse {
|
||||
@@ -167,7 +146,7 @@ pub async fn recover(
|
||||
// Wrong PIN — increment failure count
|
||||
let attempts = User::increment_failed_pin(&state.pool, user.id).await?;
|
||||
if attempts >= 3 {
|
||||
let lockout = Utc::now() + chrono::Duration::minutes(15);
|
||||
let lockout = Utc::now() + Duration::minutes(15);
|
||||
User::lock_pin(&state.pool, user.id, lockout).await?;
|
||||
}
|
||||
}
|
||||
@@ -238,7 +217,7 @@ pub async fn admin_login(
|
||||
.map_err(|e| AppError::Internal(anyhow::anyhow!(e)))?;
|
||||
|
||||
let token_hash = jwt::hash_token(&token);
|
||||
let expires_at = Utc::now() + chrono::Duration::days(1);
|
||||
let expires_at = Utc::now() + Duration::days(1);
|
||||
Session::create(&state.pool, admin_user.id, &token_hash, expires_at).await?;
|
||||
|
||||
Ok(Json(AdminLoginResponse { jwt: token }))
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
use axum::http::StatusCode;
|
||||
use axum::response::{IntoResponse, Response};
|
||||
use serde_json::json;
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum AppError {
|
||||
@@ -7,9 +8,7 @@ pub enum AppError {
|
||||
Unauthorized(String),
|
||||
Forbidden(String),
|
||||
NotFound(String),
|
||||
Conflict(String),
|
||||
/// Second field: optional retry-after seconds to include in the response.
|
||||
TooManyRequests(String, Option<u64>),
|
||||
TooManyRequests(String),
|
||||
Internal(anyhow::Error),
|
||||
}
|
||||
|
||||
@@ -20,8 +19,7 @@ impl AppError {
|
||||
Self::Unauthorized(_) => (StatusCode::UNAUTHORIZED, "unauthorized"),
|
||||
Self::Forbidden(_) => (StatusCode::FORBIDDEN, "forbidden"),
|
||||
Self::NotFound(_) => (StatusCode::NOT_FOUND, "not_found"),
|
||||
Self::Conflict(_) => (StatusCode::CONFLICT, "conflict"),
|
||||
Self::TooManyRequests(..) => (StatusCode::TOO_MANY_REQUESTS, "too_many_requests"),
|
||||
Self::TooManyRequests(_) => (StatusCode::TOO_MANY_REQUESTS, "too_many_requests"),
|
||||
Self::Internal(_) => (StatusCode::INTERNAL_SERVER_ERROR, "internal_error"),
|
||||
}
|
||||
}
|
||||
@@ -32,8 +30,7 @@ impl AppError {
|
||||
| Self::Unauthorized(msg)
|
||||
| Self::Forbidden(msg)
|
||||
| Self::NotFound(msg)
|
||||
| Self::Conflict(msg) => msg.clone(),
|
||||
Self::TooManyRequests(msg, _) => msg.clone(),
|
||||
| Self::TooManyRequests(msg) => msg.clone(),
|
||||
Self::Internal(err) => {
|
||||
tracing::error!("internal error: {err:#}");
|
||||
"Ein interner Fehler ist aufgetreten.".to_string()
|
||||
@@ -45,29 +42,13 @@ impl AppError {
|
||||
impl IntoResponse for AppError {
|
||||
fn into_response(self) -> Response {
|
||||
let (status, code) = self.status_and_code();
|
||||
let retry_after_secs = if let Self::TooManyRequests(_, Some(secs)) = &self {
|
||||
Some(*secs)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let message = self.message();
|
||||
|
||||
let mut body = serde_json::json!({
|
||||
let body = json!({
|
||||
"error": code,
|
||||
"message": message,
|
||||
"status": status.as_u16(),
|
||||
});
|
||||
if let Some(secs) = retry_after_secs {
|
||||
body["retry_after_secs"] = secs.into();
|
||||
}
|
||||
|
||||
let mut resp = (status, axum::Json(body)).into_response();
|
||||
if let Some(secs) = retry_after_secs {
|
||||
if let Ok(val) = axum::http::HeaderValue::from_str(&secs.to_string()) {
|
||||
resp.headers_mut().insert(axum::http::header::RETRY_AFTER, val);
|
||||
}
|
||||
}
|
||||
resp
|
||||
(status, axum::Json(body)).into_response()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,306 +0,0 @@
|
||||
use std::collections::HashMap;
|
||||
use std::time::Duration;
|
||||
|
||||
use axum::extract::State;
|
||||
use axum::http::{HeaderMap, StatusCode};
|
||||
use axum::Json;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sysinfo::System;
|
||||
|
||||
use crate::auth::middleware::RequireAdmin;
|
||||
use crate::error::AppError;
|
||||
use crate::services::rate_limiter::client_ip;
|
||||
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,
|
||||
headers: HeaderMap,
|
||||
) -> Result<axum::response::Response, AppError> {
|
||||
let ip = client_ip(&headers, "unknown");
|
||||
let limit = get_config_usize(&state.pool, "export_rate_per_day", 3).await;
|
||||
if !state.rate_limiter.check(format!("export:{ip}"), limit, Duration::from_secs(86400)) {
|
||||
return Err(AppError::TooManyRequests("Zu viele Anfragen. Bitte warte kurz und versuche es erneut.".into(), None));
|
||||
}
|
||||
|
||||
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,
|
||||
headers: HeaderMap,
|
||||
) -> Result<axum::response::Response, AppError> {
|
||||
let ip = client_ip(&headers, "unknown");
|
||||
let limit = get_config_usize(&state.pool, "export_rate_per_day", 3).await;
|
||||
if !state.rate_limiter.check(format!("export:{ip}"), limit, Duration::from_secs(86400)) {
|
||||
return Err(AppError::TooManyRequests("Zu viele Anfragen. Bitte warte kurz und versuche es erneut.".into(), None));
|
||||
}
|
||||
|
||||
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"),
|
||||
})))
|
||||
}
|
||||
|
||||
async fn get_config_usize(pool: &sqlx::PgPool, key: &str, default: usize) -> usize {
|
||||
let row: Option<(String,)> =
|
||||
sqlx::query_as("SELECT value FROM config WHERE key = $1")
|
||||
.bind(key)
|
||||
.fetch_optional(pool)
|
||||
.await
|
||||
.unwrap_or(None);
|
||||
row.and_then(|r| r.0.parse().ok()).unwrap_or(default)
|
||||
}
|
||||
@@ -1,7 +1,4 @@
|
||||
use std::time::Duration;
|
||||
|
||||
use axum::extract::{Query, State};
|
||||
use axum::http::HeaderMap;
|
||||
use axum::Json;
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
@@ -9,7 +6,6 @@ use uuid::Uuid;
|
||||
|
||||
use crate::auth::middleware::AuthUser;
|
||||
use crate::error::AppError;
|
||||
use crate::services::rate_limiter::client_ip;
|
||||
use crate::state::AppState;
|
||||
|
||||
#[derive(Deserialize)]
|
||||
@@ -57,15 +53,8 @@ struct FeedRow {
|
||||
pub async fn feed(
|
||||
State(state): State<AppState>,
|
||||
auth: AuthUser,
|
||||
headers: HeaderMap,
|
||||
Query(q): Query<FeedQuery>,
|
||||
) -> Result<Json<FeedResponse>, AppError> {
|
||||
let ip = client_ip(&headers, "unknown");
|
||||
let rate_limit = get_config_usize(&state.pool, "feed_rate_per_min", 60).await;
|
||||
if !state.rate_limiter.check(format!("feed:{ip}"), rate_limit, Duration::from_secs(60)) {
|
||||
return Err(AppError::TooManyRequests("Zu viele Anfragen. Bitte warte kurz und versuche es erneut.".into(), None));
|
||||
}
|
||||
|
||||
let limit = q.limit.unwrap_or(20).min(100);
|
||||
|
||||
let rows = if let Some(hashtag) = &q.hashtag {
|
||||
@@ -238,16 +227,6 @@ pub async fn hashtags(
|
||||
))
|
||||
}
|
||||
|
||||
async fn get_config_usize(pool: &sqlx::PgPool, key: &str, default: usize) -> usize {
|
||||
let row: Option<(String,)> =
|
||||
sqlx::query_as("SELECT value FROM config WHERE key = $1")
|
||||
.bind(key)
|
||||
.fetch_optional(pool)
|
||||
.await
|
||||
.unwrap_or(None);
|
||||
row.and_then(|r| r.0.parse().ok()).unwrap_or(default)
|
||||
}
|
||||
|
||||
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")
|
||||
|
||||
@@ -1,269 +0,0 @@
|
||||
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)
|
||||
}
|
||||
@@ -1,6 +1,4 @@
|
||||
pub mod admin;
|
||||
pub mod feed;
|
||||
pub mod host;
|
||||
pub mod social;
|
||||
pub mod sse;
|
||||
pub mod upload;
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
use std::time::Duration;
|
||||
|
||||
use axum::extract::{Multipart, Path, State};
|
||||
use axum::http::StatusCode;
|
||||
use axum::Json;
|
||||
@@ -18,25 +16,11 @@ pub async fn upload(
|
||||
auth: AuthUser,
|
||||
mut multipart: Multipart,
|
||||
) -> Result<(StatusCode, Json<UploadDto>), AppError> {
|
||||
// Rate limit: N uploads per hour per user
|
||||
let upload_rate = get_config_i64(&state.pool, "upload_rate_per_hour", 10).await as usize;
|
||||
if let Err(retry_after_secs) = state
|
||||
.rate_limiter
|
||||
.check_with_retry(format!("upload:{}", auth.user_id), upload_rate, Duration::from_secs(3600))
|
||||
{
|
||||
drain_multipart(multipart).await;
|
||||
return Err(AppError::TooManyRequests(
|
||||
"Du hast dein Upload-Limit für diese Stunde erreicht.".into(),
|
||||
Some(retry_after_secs),
|
||||
));
|
||||
}
|
||||
|
||||
// Check if user is banned
|
||||
let user = User::find_by_id(&state.pool, auth.user_id)
|
||||
.await?
|
||||
.ok_or_else(|| AppError::NotFound("Benutzer nicht gefunden.".into()))?;
|
||||
if user.is_banned {
|
||||
drain_multipart(multipart).await;
|
||||
return Err(AppError::Forbidden("Du bist gesperrt.".into()));
|
||||
}
|
||||
|
||||
@@ -45,7 +29,6 @@ pub async fn upload(
|
||||
.await?
|
||||
.ok_or_else(|| AppError::NotFound("Event nicht gefunden.".into()))?;
|
||||
if event.uploads_locked_at.is_some() {
|
||||
drain_multipart(multipart).await;
|
||||
return Err(AppError::Forbidden("Uploads sind gesperrt.".into()));
|
||||
}
|
||||
|
||||
@@ -243,15 +226,6 @@ pub async fn delete_upload(
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
|
||||
/// Drain a multipart body so the HTTP connection stays clean when returning an early error.
|
||||
/// Without draining, the client may still be sending the body after we've sent our response,
|
||||
/// which can corrupt the keep-alive connection for subsequent requests.
|
||||
async fn drain_multipart(mut mp: Multipart) {
|
||||
while let Ok(Some(mut field)) = mp.next_field().await {
|
||||
while field.chunk().await.ok().flatten().is_some() {}
|
||||
}
|
||||
}
|
||||
|
||||
async fn get_config_i64(pool: &sqlx::PgPool, key: &str, default: i64) -> i64 {
|
||||
let row: Option<(String,)> =
|
||||
sqlx::query_as("SELECT value FROM config WHERE key = $1")
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
use anyhow::Result;
|
||||
use axum::extract::DefaultBodyLimit;
|
||||
use axum::routing::{delete, get, patch, post};
|
||||
use axum::Router;
|
||||
use tower_http::services::ServeDir;
|
||||
use tower_http::trace::TraceLayer;
|
||||
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
|
||||
|
||||
mod auth;
|
||||
@@ -42,9 +40,8 @@ async fn main() -> Result<()> {
|
||||
.route("/api/v1/recover", post(auth::handlers::recover))
|
||||
.route("/api/v1/admin/login", post(auth::handlers::admin_login))
|
||||
.route("/api/v1/session", delete(auth::handlers::logout))
|
||||
// Upload — body limit disabled; size validation is done inside the handler
|
||||
.route("/api/v1/upload", post(handlers::upload::upload)
|
||||
.route_layer(DefaultBodyLimit::disable()))
|
||||
// Upload
|
||||
.route("/api/v1/upload", post(handlers::upload::upload))
|
||||
.route(
|
||||
"/api/v1/upload/{id}",
|
||||
patch(handlers::upload::edit_upload).delete(handlers::upload::delete_upload),
|
||||
@@ -61,29 +58,7 @@ async fn main() -> Result<()> {
|
||||
)
|
||||
.route("/api/v1/comment/{id}", delete(handlers::social::delete_comment))
|
||||
// SSE
|
||||
.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));
|
||||
.route("/api/v1/stream", get(handlers::sse::stream));
|
||||
|
||||
// Serve media files from disk
|
||||
let media_service = ServeDir::new(&config.media_path);
|
||||
@@ -92,7 +67,6 @@ async fn main() -> Result<()> {
|
||||
.route("/health", get(|| async { "ok" }))
|
||||
.merge(api)
|
||||
.nest_service("/media", media_service)
|
||||
.layer(TraceLayer::new_for_http())
|
||||
.with_state(state);
|
||||
|
||||
let listener = tokio::net::TcpListener::bind(("0.0.0.0", config.app_port)).await?;
|
||||
|
||||
@@ -4,7 +4,6 @@ use sqlx::PgPool;
|
||||
use uuid::Uuid;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, sqlx::Type)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
#[sqlx(type_name = "user_role", rename_all = "lowercase")]
|
||||
pub enum UserRole {
|
||||
Guest,
|
||||
@@ -59,7 +58,7 @@ impl User {
|
||||
display_name: &str,
|
||||
) -> Result<Vec<Self>, sqlx::Error> {
|
||||
sqlx::query_as::<_, Self>(
|
||||
"SELECT * FROM \"user\" WHERE event_id = $1 AND LOWER(display_name) = LOWER($2)",
|
||||
"SELECT * FROM \"user\" WHERE event_id = $1 AND display_name = $2",
|
||||
)
|
||||
.bind(event_id)
|
||||
.bind(display_name)
|
||||
@@ -67,21 +66,6 @@ impl User {
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn name_taken(
|
||||
pool: &PgPool,
|
||||
event_id: Uuid,
|
||||
display_name: &str,
|
||||
) -> Result<bool, sqlx::Error> {
|
||||
let row: (bool,) = sqlx::query_as(
|
||||
"SELECT EXISTS(SELECT 1 FROM \"user\" WHERE event_id = $1 AND LOWER(display_name) = LOWER($2))",
|
||||
)
|
||||
.bind(event_id)
|
||||
.bind(display_name)
|
||||
.fetch_one(pool)
|
||||
.await?;
|
||||
Ok(row.0)
|
||||
}
|
||||
|
||||
pub async fn increment_failed_pin(pool: &PgPool, id: Uuid) -> Result<i16, sqlx::Error> {
|
||||
let row: (i16,) = sqlx::query_as(
|
||||
"UPDATE \"user\"
|
||||
|
||||
@@ -1,617 +0,0 @@
|
||||
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 include_dir::{include_dir, Dir};
|
||||
use serde::Serialize;
|
||||
use sqlx::PgPool;
|
||||
use tokio::sync::broadcast;
|
||||
use tokio_util::compat::TokioAsyncReadCompatExt;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::state::SseEvent;
|
||||
|
||||
// ── Embedded viewer assets (pre-built SvelteKit static output) ──────────────
|
||||
|
||||
static VIEWER_DIR: Dir<'_> = include_dir!("$CARGO_MANIFEST_DIR/static/export-viewer");
|
||||
|
||||
// ── 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>,
|
||||
}
|
||||
|
||||
// ── Viewer JSON structs (serialised to data.json) ───────────────────────────
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct ViewerData {
|
||||
event: ViewerEvent,
|
||||
posts: Vec<ViewerPost>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct ViewerEvent {
|
||||
name: String,
|
||||
exported_at: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct ViewerPost {
|
||||
id: String,
|
||||
uploader: String,
|
||||
caption: String,
|
||||
tags: Vec<String>,
|
||||
timestamp: String,
|
||||
likes: i64,
|
||||
comments: Vec<ViewerComment>,
|
||||
media: ViewerMedia,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct ViewerComment {
|
||||
author: String,
|
||||
text: String,
|
||||
timestamp: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct ViewerMedia {
|
||||
#[serde(rename = "type")]
|
||||
media_type: String,
|
||||
thumb: String,
|
||||
full: 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 viewer 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;
|
||||
|
||||
// 1. Query data
|
||||
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;
|
||||
|
||||
update_progress(pool, event_id, "html", 5).await;
|
||||
|
||||
let exports_dir = media_path.join("exports");
|
||||
tokio::fs::create_dir_all(&exports_dir).await?;
|
||||
|
||||
// 2. Create temp directory for media processing
|
||||
let tmp_dir = exports_dir.join(format!("viewer_tmp_{event_id}"));
|
||||
let media_tmp = tmp_dir.join("media");
|
||||
tokio::fs::create_dir_all(&media_tmp).await?;
|
||||
|
||||
// 3. Process media and build post data
|
||||
let mut viewer_posts: Vec<ViewerPost> = Vec::new();
|
||||
|
||||
for (i, row) in uploads.iter().enumerate() {
|
||||
let src = media_path.join(&row.original_path);
|
||||
if !src.exists() {
|
||||
continue;
|
||||
}
|
||||
|
||||
let is_video = row.mime_type.starts_with("video/");
|
||||
let id_str = row.id.to_string();
|
||||
|
||||
// Generate thumbnail and full variant
|
||||
let (thumb_name, full_name) = if is_video {
|
||||
let thumb = format!("{id_str}_thumb.jpg");
|
||||
let full_ext = ext_from_path(&row.original_path);
|
||||
let full = format!("{id_str}.{full_ext}");
|
||||
|
||||
// Video thumbnail via ffmpeg
|
||||
let thumb_path = media_tmp.join(&thumb);
|
||||
let ffmpeg_result = tokio::process::Command::new("ffmpeg")
|
||||
.args([
|
||||
"-i",
|
||||
src.to_str().unwrap_or_default(),
|
||||
"-vframes",
|
||||
"1",
|
||||
"-ss",
|
||||
"00:00:01",
|
||||
"-vf",
|
||||
"scale=400:-1",
|
||||
"-y",
|
||||
thumb_path.to_str().unwrap_or_default(),
|
||||
])
|
||||
.output()
|
||||
.await;
|
||||
|
||||
match ffmpeg_result {
|
||||
Ok(output) if output.status.success() => {}
|
||||
_ => {
|
||||
tracing::warn!("ffmpeg thumbnail failed for upload {}, skipping thumb", row.id);
|
||||
// Create empty thumb entry — viewer handles missing thumbs gracefully
|
||||
}
|
||||
}
|
||||
|
||||
// Copy video as-is
|
||||
tokio::fs::copy(&src, media_tmp.join(&full)).await?;
|
||||
|
||||
(thumb, full)
|
||||
} else {
|
||||
let thumb = format!("{id_str}_thumb.jpg");
|
||||
let ext = ext_from_path(&row.original_path);
|
||||
let full = format!("{id_str}_full.{ext}");
|
||||
|
||||
// Image thumbnail: resize to 400px wide
|
||||
let src_clone = src.clone();
|
||||
let thumb_path = media_tmp.join(&thumb);
|
||||
let thumb_path_clone = thumb_path.clone();
|
||||
|
||||
let thumb_result = tokio::task::spawn_blocking(move || -> Result<()> {
|
||||
let img = image::open(&src_clone).context("failed to open image for thumbnail")?;
|
||||
let resized = img.resize(400, 400, image::imageops::FilterType::Lanczos3);
|
||||
resized
|
||||
.save_with_format(&thumb_path_clone, image::ImageFormat::Jpeg)
|
||||
.context("failed to save thumbnail")?;
|
||||
Ok(())
|
||||
})
|
||||
.await?;
|
||||
|
||||
if let Err(e) = thumb_result {
|
||||
tracing::warn!("thumbnail generation failed for upload {}: {e:#}", row.id);
|
||||
}
|
||||
|
||||
// Full variant: compress if >5MB, otherwise copy original
|
||||
let src_meta = tokio::fs::metadata(&src).await?;
|
||||
let full_path = media_tmp.join(&full);
|
||||
|
||||
if src_meta.len() > 5_000_000 {
|
||||
// Resize to max 2000px
|
||||
let src_clone = src.clone();
|
||||
let full_path_clone = full_path.clone();
|
||||
|
||||
let compress_result = tokio::task::spawn_blocking(move || -> Result<()> {
|
||||
let img =
|
||||
image::open(&src_clone).context("failed to open image for compression")?;
|
||||
let resized = img.resize(2000, 2000, image::imageops::FilterType::Lanczos3);
|
||||
resized
|
||||
.save_with_format(&full_path_clone, image::ImageFormat::Jpeg)
|
||||
.context("failed to save compressed full image")?;
|
||||
Ok(())
|
||||
})
|
||||
.await?;
|
||||
|
||||
if let Err(e) = compress_result {
|
||||
tracing::warn!("compression failed for upload {}, copying original: {e:#}", row.id);
|
||||
tokio::fs::copy(&src, &full_path).await?;
|
||||
}
|
||||
} else {
|
||||
tokio::fs::copy(&src, &full_path).await?;
|
||||
}
|
||||
|
||||
(thumb, full)
|
||||
};
|
||||
|
||||
// Build comments for this upload
|
||||
let post_comments: Vec<ViewerComment> = comments
|
||||
.iter()
|
||||
.filter(|c| c.upload_id == row.id)
|
||||
.map(|c| ViewerComment {
|
||||
author: c.uploader_name.clone(),
|
||||
text: c.body.clone(),
|
||||
timestamp: c.created_at.to_rfc3339(),
|
||||
})
|
||||
.collect();
|
||||
|
||||
// Build tags for this upload
|
||||
let tags: Vec<String> = hashtags_per_upload
|
||||
.iter()
|
||||
.filter(|(uid, _)| *uid == row.id)
|
||||
.map(|(_, tag)| tag.clone())
|
||||
.collect();
|
||||
|
||||
viewer_posts.push(ViewerPost {
|
||||
id: id_str,
|
||||
uploader: row.uploader_name.clone(),
|
||||
caption: row.caption.clone().unwrap_or_default(),
|
||||
tags,
|
||||
timestamp: row.created_at.to_rfc3339(),
|
||||
likes: row.like_count,
|
||||
comments: post_comments,
|
||||
media: ViewerMedia {
|
||||
media_type: if is_video {
|
||||
"video".to_string()
|
||||
} else {
|
||||
"image".to_string()
|
||||
},
|
||||
thumb: format!("media/{thumb_name}"),
|
||||
full: format!("media/{full_name}"),
|
||||
},
|
||||
});
|
||||
|
||||
let pct = 10 + ((i + 1) as f32 / total * 60.0) as i16;
|
||||
update_progress(pool, event_id, "html", pct.min(69)).await;
|
||||
}
|
||||
|
||||
// 4. Build data.json
|
||||
let viewer_data = ViewerData {
|
||||
event: ViewerEvent {
|
||||
name: event_name.to_string(),
|
||||
exported_at: Utc::now().to_rfc3339(),
|
||||
},
|
||||
posts: viewer_posts,
|
||||
};
|
||||
let data_json =
|
||||
serde_json::to_string_pretty(&viewer_data).context("failed to serialize data.json")?;
|
||||
|
||||
update_progress(pool, event_id, "html", 72).await;
|
||||
|
||||
// 5. Create ZIP
|
||||
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);
|
||||
|
||||
// Write embedded viewer assets (index.html, _app/*, etc.)
|
||||
write_dir_to_zip(&VIEWER_DIR, &mut zip).await?;
|
||||
|
||||
update_progress(pool, event_id, "html", 75).await;
|
||||
|
||||
// Write data.json
|
||||
{
|
||||
let builder = ZipEntryBuilder::new("data.json".into(), Compression::Deflate);
|
||||
let mut entry = zip.write_entry_stream(builder).await?;
|
||||
let mut cursor = AllowStdIo::new(std::io::Cursor::new(data_json.as_bytes()));
|
||||
fcopy(&mut cursor, &mut entry).await?;
|
||||
entry.close().await?;
|
||||
}
|
||||
|
||||
// Write README.txt
|
||||
{
|
||||
let builder = ZipEntryBuilder::new("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?;
|
||||
}
|
||||
|
||||
update_progress(pool, event_id, "html", 78).await;
|
||||
|
||||
// Write media files from temp directory
|
||||
let mut media_entries = tokio::fs::read_dir(&media_tmp).await?;
|
||||
let mut file_count = 0u32;
|
||||
let mut files_written = 0u32;
|
||||
|
||||
// Count files first
|
||||
{
|
||||
let mut counter = tokio::fs::read_dir(&media_tmp).await?;
|
||||
while counter.next_entry().await?.is_some() {
|
||||
file_count += 1;
|
||||
}
|
||||
}
|
||||
let file_total = file_count.max(1) as f32;
|
||||
|
||||
while let Some(dir_entry) = media_entries.next_entry().await? {
|
||||
let filename = dir_entry.file_name();
|
||||
let entry_name = format!("media/{}", filename.to_string_lossy());
|
||||
|
||||
let builder = ZipEntryBuilder::new(entry_name.into(), Compression::Stored);
|
||||
let mut zip_entry = zip.write_entry_stream(builder).await?;
|
||||
let mut f = tokio::fs::File::open(dir_entry.path()).await?.compat();
|
||||
fcopy(&mut f, &mut zip_entry).await?;
|
||||
zip_entry.close().await?;
|
||||
|
||||
files_written += 1;
|
||||
let pct = 78 + (files_written as f32 / file_total * 20.0) as i16;
|
||||
update_progress(pool, event_id, "html", pct.min(98)).await;
|
||||
}
|
||||
|
||||
zip.close().await?;
|
||||
}
|
||||
|
||||
// 6. Finalise
|
||||
tokio::fs::rename(&tmp_path, &out_path).await?;
|
||||
|
||||
// Clean up temp directory
|
||||
let _ = tokio::fs::remove_dir_all(&tmp_dir).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 viewer 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(),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Recursively write all files from an embedded `include_dir::Dir` into a ZIP.
|
||||
async fn write_dir_to_zip(
|
||||
dir: &include_dir::Dir<'_>,
|
||||
zip: &mut ZipFileWriter<tokio::fs::File>,
|
||||
) -> Result<()> {
|
||||
for file in dir.files() {
|
||||
let path = file.path().to_string_lossy().to_string();
|
||||
let contents = file.contents();
|
||||
let builder = ZipEntryBuilder::new(path.into(), Compression::Deflate);
|
||||
let mut entry = zip.write_entry_stream(builder).await?;
|
||||
let mut cursor = AllowStdIo::new(std::io::Cursor::new(contents));
|
||||
fcopy(&mut cursor, &mut entry).await?;
|
||||
entry.close().await?;
|
||||
}
|
||||
for sub_dir in dir.dirs() {
|
||||
Box::pin(write_dir_to_zip(sub_dir, zip)).await?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
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 = "EventSnap Offline-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 \"index.html\" im Browser\n\
|
||||
(z. B. Chrome, Safari oder Firefox).\n\
|
||||
3. Stöbere durch alle Fotos und Videos.\n\
|
||||
Du kannst zwischen Listen- und Rasteransicht wechseln,\n\
|
||||
nach Hashtags filtern und nach Nutzern suchen.\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";
|
||||
@@ -1,3 +1 @@
|
||||
pub mod compression;
|
||||
pub mod export;
|
||||
pub mod rate_limiter;
|
||||
|
||||
@@ -1,55 +0,0 @@
|
||||
use std::collections::HashMap;
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
/// Thread-safe sliding-window rate limiter backed by an in-memory HashMap.
|
||||
/// Each key (e.g. `"join:{ip}"` or `"upload:{user_id}"`) tracks timestamps
|
||||
/// of recent requests and rejects new ones once the window is full.
|
||||
#[derive(Clone)]
|
||||
pub struct RateLimiter {
|
||||
windows: Arc<Mutex<HashMap<String, Vec<Instant>>>>,
|
||||
}
|
||||
|
||||
impl RateLimiter {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
windows: Arc::new(Mutex::new(HashMap::new())),
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns `true` if the request is allowed, `false` if rate-limited.
|
||||
pub fn check(&self, key: impl Into<String>, max: usize, window: Duration) -> bool {
|
||||
self.check_with_retry(key, max, window).is_ok()
|
||||
}
|
||||
|
||||
/// Returns `Ok(())` if allowed, `Err(retry_after_secs)` if rate-limited.
|
||||
/// `retry_after_secs` is how long until the oldest slot in the window expires.
|
||||
pub fn check_with_retry(&self, key: impl Into<String>, max: usize, window: Duration) -> Result<(), u64> {
|
||||
let now = Instant::now();
|
||||
let key = key.into();
|
||||
let mut map = self.windows.lock().unwrap();
|
||||
let timestamps = map.entry(key).or_default();
|
||||
timestamps.retain(|&t| now.duration_since(t) < window);
|
||||
if timestamps.len() < max {
|
||||
timestamps.push(now);
|
||||
Ok(())
|
||||
} else {
|
||||
// The oldest timestamp expires at oldest + window; compute remaining seconds
|
||||
let oldest = timestamps[0];
|
||||
let elapsed = now.duration_since(oldest);
|
||||
let remaining = window.saturating_sub(elapsed);
|
||||
Err(remaining.as_secs().max(1))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Extract the client IP from X-Forwarded-For (Caddy sets this) or fall back
|
||||
/// to a provided socket address string.
|
||||
pub fn client_ip(headers: &axum::http::HeaderMap, fallback: &str) -> String {
|
||||
headers
|
||||
.get("x-forwarded-for")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.and_then(|s| s.split(',').next())
|
||||
.map(|s| s.trim().to_owned())
|
||||
.unwrap_or_else(|| fallback.to_owned())
|
||||
}
|
||||
@@ -3,7 +3,6 @@ use tokio::sync::broadcast;
|
||||
|
||||
use crate::config::AppConfig;
|
||||
use crate::services::compression::CompressionWorker;
|
||||
use crate::services::rate_limiter::RateLimiter;
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct SseEvent {
|
||||
@@ -17,7 +16,6 @@ pub struct AppState {
|
||||
pub config: AppConfig,
|
||||
pub sse_tx: broadcast::Sender<SseEvent>,
|
||||
pub compression: CompressionWorker,
|
||||
pub rate_limiter: RateLimiter,
|
||||
}
|
||||
|
||||
impl AppState {
|
||||
@@ -30,7 +28,6 @@ impl AppState {
|
||||
config,
|
||||
sse_tx,
|
||||
compression,
|
||||
rate_limiter: RateLimiter::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
export const env={}
|
||||
File diff suppressed because one or more lines are too long
@@ -1 +0,0 @@
|
||||
import{u as o,n as t,o as c}from"./CcONa1Mr.js";function u(e){throw new Error("https://svelte.dev/e/lifecycle_outside_component")}function r(e){t===null&&u(),o(()=>{const n=c(e);if(typeof n=="function")return n})}export{r as o};
|
||||
@@ -1 +0,0 @@
|
||||
import{f as l,g as o,p as u,i as n,j as d,k as m,h as p,e as _,m as v,l as k}from"./CcONa1Mr.js";class w{anchor;#t=new Map;#s=new Map;#e=new Map;#i=new Set;#f=!0;constructor(t,s=!0){this.anchor=t,this.#f=s}#a=t=>{if(this.#t.has(t)){var s=this.#t.get(t),e=this.#s.get(s);if(e)l(e),this.#i.delete(s);else{var f=this.#e.get(s);f&&(this.#s.set(s,f.effect),this.#e.delete(s),f.fragment.lastChild.remove(),this.anchor.before(f.fragment),e=f.effect)}for(const[i,a]of this.#t){if(this.#t.delete(i),i===t)break;const r=this.#e.get(a);r&&(o(r.effect),this.#e.delete(a))}for(const[i,a]of this.#s){if(i===s||this.#i.has(i))continue;const r=()=>{if(Array.from(this.#t.values()).includes(i)){var c=document.createDocumentFragment();v(a,c),c.append(n()),this.#e.set(i,{effect:a,fragment:c})}else o(a);this.#i.delete(i),this.#s.delete(i)};this.#f||!e?(this.#i.add(i),u(a,r,!1)):r()}}};#r=t=>{this.#t.delete(t);const s=Array.from(this.#t.values());for(const[e,f]of this.#e)s.includes(e)||(o(f.effect),this.#e.delete(e))};ensure(t,s){var e=m,f=k();if(s&&!this.#s.has(t)&&!this.#e.has(t))if(f){var i=document.createDocumentFragment(),a=n();i.append(a),this.#e.set(t,{effect:d(()=>s(a)),fragment:i})}else this.#s.set(t,d(()=>s(this.anchor)));if(this.#t.set(e,t),f){for(const[r,h]of this.#s)r===t?e.unskip_effect(h):e.skip_effect(h);for(const[r,h]of this.#e)r===t?e.unskip_effect(h.effect):e.skip_effect(h.effect);e.oncommit(this.#a),e.ondiscard(this.#r)}else p&&(this.anchor=_),this.#a(e)}}export{w as B};
|
||||
File diff suppressed because one or more lines are too long
@@ -1 +0,0 @@
|
||||
import{b as c,h as o,a as l,E as b,r as p,s as v,c as g,d,e as m}from"./CcONa1Mr.js";import{B as y}from"./BRDva_z9.js";function k(f,h,_=!1){var n;o&&(n=m,l());var s=new y(f),u=_?b:0;function t(a,r){if(o){var e=p(n);if(a!==parseInt(e.substring(1))){var i=v();g(i),s.anchor=i,d(!1),s.ensure(a,r),d(!0);return}}s.ensure(a,r)}c(()=>{var a=!1;h((r,e=0)=>{a=!0,t(e,r)}),a||t(-1,null)},u)}export{k as i};
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1 +0,0 @@
|
||||
import{A as v,i as d,B as l,C as u,D as T,T as p,F as h,h as i,e as s,R as E,a as y,G as g,c as w,H as N}from"./CcONa1Mr.js";const A=globalThis?.window?.trustedTypes&&globalThis.window.trustedTypes.createPolicy("svelte-trusted-html",{createHTML:t=>t});function M(t){return A?.createHTML(t)??t}function x(t){var r=v("template");return r.innerHTML=M(t.replaceAll("<!>","<!---->")),r.content}function n(t,r){var e=l;e.nodes===null&&(e.nodes={start:t,end:r,a:null,t:null})}function b(t,r){var e=(r&p)!==0,f=(r&h)!==0,a,_=!t.startsWith("<!>");return()=>{if(i)return n(s,null),s;a===void 0&&(a=x(_?t:"<!>"+t),e||(a=u(a)));var o=f||T?document.importNode(a,!0):a.cloneNode(!0);if(e){var c=u(o),m=o.lastChild;n(c,m)}else n(o,o);return o}}function C(t=""){if(!i){var r=d(t+"");return n(r,r),r}var e=s;return e.nodeType!==g?(e.before(e=d()),w(e)):N(e),n(e,e),e}function O(){if(i)return n(s,null),s;var t=document.createDocumentFragment(),r=document.createComment(""),e=d();return t.append(r,e),n(r,e),t}function P(t,r){if(i){var e=l;((e.f&E)===0||e.nodes.end===null)&&(e.nodes.end=s),y();return}t!==null&&t.before(r)}const L="5";typeof window<"u"&&((window.__svelte??={}).v??=new Set).add(L);export{P as a,n as b,O as c,b as f,C as t};
|
||||
File diff suppressed because one or more lines are too long
@@ -1 +0,0 @@
|
||||
import{l as o,a as r}from"../chunks/Dy1jDy4J.js";export{o as load_css,r as start};
|
||||
@@ -1 +0,0 @@
|
||||
import{c as s,a as c}from"../chunks/RsTAN2PN.js";import{b as l,E as p,t as i}from"../chunks/CcONa1Mr.js";import{B as m}from"../chunks/BRDva_z9.js";function u(n,r,...e){var o=new m(n);l(()=>{const t=r()??null;o.ensure(t,t&&(a=>t(a,...e)))},p)}const f=!0,_=!1,g=Object.freeze(Object.defineProperty({__proto__:null,prerender:f,ssr:_},Symbol.toStringTag,{value:"Module"}));function h(n,r){var e=s(),o=i(e);u(o,()=>r.children),c(n,e)}export{h as component,g as universal};
|
||||
@@ -1 +0,0 @@
|
||||
import{a as i,f as h}from"../chunks/RsTAN2PN.js";import{q as g,t as v,v as d,w as l,x as s,y as a,z as x}from"../chunks/CcONa1Mr.js";import{s as o}from"../chunks/Bb9JxzU7.js";import{s as _,p}from"../chunks/Dy1jDy4J.js";const $={get error(){return p.error},get status(){return p.status}};_.updated.check;const m=$;var k=h("<h1> </h1> <p> </p>",1);function z(c,f){g(f,!0);var t=k(),r=v(t),n=s(r,!0);a(r);var e=x(r,2),u=s(e,!0);a(e),d(()=>{o(n,m.status),o(u,m.error?.message)}),i(c,t),l()}export{z as component};
|
||||
File diff suppressed because one or more lines are too long
@@ -1 +0,0 @@
|
||||
{"version":"1775501323159"}
|
||||
@@ -1,37 +0,0 @@
|
||||
<!doctype html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<link href="/_app/immutable/entry/start.ctwmcI8C.js" rel="modulepreload">
|
||||
<link href="/_app/immutable/chunks/Dy1jDy4J.js" rel="modulepreload">
|
||||
<link href="/_app/immutable/chunks/CcONa1Mr.js" rel="modulepreload">
|
||||
<link href="/_app/immutable/chunks/BJ__EZ0W.js" rel="modulepreload">
|
||||
<link href="/_app/immutable/entry/app.jfkZT8Zg.js" rel="modulepreload">
|
||||
<link href="/_app/immutable/chunks/Bb9JxzU7.js" rel="modulepreload">
|
||||
<link href="/_app/immutable/chunks/RsTAN2PN.js" rel="modulepreload">
|
||||
<link href="/_app/immutable/chunks/Bxn6SmGf.js" rel="modulepreload">
|
||||
<link href="/_app/immutable/chunks/BRDva_z9.js" rel="modulepreload">
|
||||
|
||||
</head>
|
||||
<body>
|
||||
<div style="display: contents">
|
||||
<script>
|
||||
{
|
||||
__sveltekit_ftrcoq = {
|
||||
base: ""
|
||||
};
|
||||
|
||||
const element = document.currentScript.parentElement;
|
||||
|
||||
Promise.all([
|
||||
import("/_app/immutable/entry/start.ctwmcI8C.js"),
|
||||
import("/_app/immutable/entry/app.jfkZT8Zg.js")
|
||||
]).then(([kit, app]) => {
|
||||
kit.start(app, element);
|
||||
});
|
||||
}
|
||||
</script>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,278 +0,0 @@
|
||||
# HTML Viewer Export Concept
|
||||
|
||||
## Overview
|
||||
|
||||
The HTML Viewer export produces a **self-contained offline ZIP** that is a read-only clone
|
||||
of the live EventSnap feed. Opening `index.html` in any modern browser shows the full event
|
||||
gallery — list view, grid view, search, filter, lightbox — with no internet connection or
|
||||
server required.
|
||||
|
||||
It **replaces the current HTML export job type**. The old HTML export produced a raw
|
||||
minijinja-rendered template; the new viewer supersedes it entirely. The existing `html`
|
||||
job variant in the backend is repurposed to run this flow instead of being kept alongside
|
||||
it.
|
||||
|
||||
It is distinct from the ZIP archive export (which is raw media files). The viewer is a
|
||||
polished, navigable web app bundled with the event's content.
|
||||
|
||||
---
|
||||
|
||||
## User-Facing Behavior
|
||||
|
||||
- Download `event_name_viewer.zip`, unzip, open `index.html`
|
||||
- Full list view (chronological, newest first) and grid view with search/filter
|
||||
- Likes, comments, and reaction counts shown (static snapshot from export time)
|
||||
- Read-only: no uploads, no auth, no dashboards
|
||||
- Works offline, no CDN or external resources
|
||||
|
||||
---
|
||||
|
||||
## Architecture
|
||||
|
||||
### Separate Static SvelteKit App
|
||||
|
||||
A new mini SvelteKit project lives at `frontend/export-viewer/` within the same monorepo.
|
||||
It uses `adapter-static` and is kept completely independent of the main app.
|
||||
|
||||
**Why separate rather than a shared route:**
|
||||
- The viewer must be distributable as a standalone static bundle; the main app uses
|
||||
`adapter-node` and cannot be mixed
|
||||
- Keeping it separate avoids auth, store, and routing dependencies leaking in
|
||||
- Simpler to reason about: the viewer has exactly two concerns (list view, grid view)
|
||||
|
||||
**Why same repo:**
|
||||
- Shares Tailwind config and design tokens → visual parity with the main app
|
||||
- Single `pnpm` workspace, no separate CI needed
|
||||
- Backend can reference the pre-built output by relative path
|
||||
|
||||
### Pre-Built Output Committed to Repo
|
||||
|
||||
The viewer is built once and its output committed to `backend/static/export-viewer/`.
|
||||
The backend export job **does not run a Node build** at runtime — it just copies the
|
||||
pre-built assets and injects event data alongside them.
|
||||
|
||||
When the viewer source changes, a developer rebuilds it locally (`pnpm build` in
|
||||
`frontend/export-viewer/`) and commits the updated `backend/static/export-viewer/` output.
|
||||
|
||||
---
|
||||
|
||||
## ZIP Structure
|
||||
|
||||
```
|
||||
event_name_viewer.zip/
|
||||
├── index.html ← entry point; open this in any browser
|
||||
├── _app/
|
||||
│ └── immutable/
|
||||
│ ├── viewer.[hash].js ← all Svelte/app logic, single bundle
|
||||
│ └── viewer.[hash].css ← all styles including Tailwind output
|
||||
├── data.json ← injected by backend at export time
|
||||
└── media/
|
||||
├── abc123_thumb.jpg ← ~400 px wide, used in grid cells
|
||||
├── abc123_full.jpg ← original or capped (see Media Strategy)
|
||||
├── def456_thumb.jpg
|
||||
└── def456.mp4 ← videos included as-is
|
||||
```
|
||||
|
||||
No external font CDN, no Google Fonts, no remote scripts. All assets are local.
|
||||
|
||||
---
|
||||
|
||||
## data.json Schema
|
||||
|
||||
Generated by the backend export job. The viewer fetches this file on startup via
|
||||
`fetch('./data.json')` (relative path, works from filesystem).
|
||||
|
||||
```json
|
||||
{
|
||||
"event": {
|
||||
"name": "Sommerfest 2025",
|
||||
"exported_at": "2025-07-15T20:00:00Z"
|
||||
},
|
||||
"posts": [
|
||||
{
|
||||
"id": "abc123",
|
||||
"uploader": "MaxMustermann",
|
||||
"caption": "Tolle Stimmung! #party #spaß",
|
||||
"tags": ["party", "spaß"],
|
||||
"timestamp": "2025-07-15T18:30:00Z",
|
||||
"likes": 12,
|
||||
"comments": [
|
||||
{
|
||||
"author": "AnnaSchulz",
|
||||
"text": "So schön!",
|
||||
"timestamp": "2025-07-15T18:35:00Z"
|
||||
}
|
||||
],
|
||||
"media": [
|
||||
{
|
||||
"type": "image",
|
||||
"thumb": "media/abc123_thumb.jpg",
|
||||
"full": "media/abc123_full.jpg",
|
||||
"width": 1920,
|
||||
"height": 1080
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
All post data (likes, comments, tags) reflects the state at export time. No live updates.
|
||||
|
||||
---
|
||||
|
||||
## Media Strategy
|
||||
|
||||
### Images
|
||||
|
||||
| Variant | Purpose | Max dimension | Format |
|
||||
|---------|---------|---------------|--------|
|
||||
| `_thumb` | Grid cells, list post thumbnail | 400 px wide | JPEG q75 |
|
||||
| `_full` | Lightbox / full-screen view | Original, or 2000 px cap if >5 MB | JPEG q85 |
|
||||
|
||||
The backend applies compression only when the original exceeds a threshold (e.g. >5 MB for
|
||||
images). Below that threshold the original is used as `_full` unchanged.
|
||||
|
||||
The full-resolution original is always available via the separate ZIP archive export.
|
||||
|
||||
### Videos
|
||||
|
||||
Included as-is (no server-side transcoding). The viewer uses a standard `<video>` element.
|
||||
The `_thumb` variant for videos is a JPEG frame extracted at the 1-second mark.
|
||||
|
||||
---
|
||||
|
||||
## SvelteKit SSG Configuration
|
||||
|
||||
```
|
||||
// frontend/export-viewer/src/routes/+layout.ts
|
||||
export const prerender = true;
|
||||
export const ssr = false;
|
||||
```
|
||||
|
||||
`ssr = false` produces a pure client-side SPA: SvelteKit emits a minimal `index.html` shell
|
||||
and the JavaScript bundle hydrates it entirely in the browser. This is correct for a ZIP
|
||||
distribution where no server exists to handle SSR.
|
||||
|
||||
```
|
||||
// frontend/export-viewer/svelte.config.js
|
||||
import adapter from '@sveltejs/adapter-static';
|
||||
|
||||
export default {
|
||||
kit: {
|
||||
adapter: adapter({
|
||||
fallback: 'index.html',
|
||||
pages: '../../backend/static/export-viewer',
|
||||
assets: '../../backend/static/export-viewer',
|
||||
})
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
The build output is written directly into `backend/static/export-viewer/` so the backend
|
||||
can reference it without a copy step.
|
||||
|
||||
### Shared Tailwind Config
|
||||
|
||||
```
|
||||
// frontend/export-viewer/tailwind.config.js
|
||||
import baseConfig from '../tailwind.config.js';
|
||||
export default { ...baseConfig, content: ['./src/**/*.{svelte,ts}'] };
|
||||
```
|
||||
|
||||
Imports the main app's Tailwind config to guarantee visual parity. Only the `content` glob
|
||||
is overridden.
|
||||
|
||||
---
|
||||
|
||||
## Viewer Feature Set
|
||||
|
||||
| Feature | Included | Notes |
|
||||
|---------|----------|-------|
|
||||
| List view (chronological, newest first) | ✓ | Full-width cards, same layout as live app |
|
||||
| Grid view (3-column) | ✓ | Square cells, video duration badge |
|
||||
| List/grid toggle | ✓ | Same toggle icons as live app |
|
||||
| Search bar (grid view only) | ✓ | Appears only in grid view |
|
||||
| Tag filter chips | ✓ | Built from tags in data.json |
|
||||
| Uploader filter | ✓ | Dropdown from uploaders in data.json |
|
||||
| Autocomplete suggestions | ✓ | From data.json — no network requests |
|
||||
| Lightbox (tap to expand) | ✓ | Swipe left/right navigates filtered set |
|
||||
| Like counts (static) | ✓ | Snapshot from export time |
|
||||
| Comment list (static) | ✓ | Expandable under each post |
|
||||
| Like/comment actions | ✗ | Read-only export |
|
||||
| Upload button / FAB | ✗ | |
|
||||
| Account / Host / Admin | ✗ | |
|
||||
| Authentication | ✗ | No JWT, no PIN |
|
||||
| Service Worker (offline cache) | Future | Could be added later for PWA behavior |
|
||||
|
||||
---
|
||||
|
||||
## Backend Export Job Flow
|
||||
|
||||
The `html` job variant is repurposed. The old minijinja template rendering path is removed
|
||||
and replaced entirely by the steps below.
|
||||
|
||||
```
|
||||
1. Query all posts, media, reactions, and comments for the event from the DB
|
||||
2. Copy pre-built viewer assets:
|
||||
backend/static/export-viewer/ → tmp/{job_id}/
|
||||
3. Generate data.json:
|
||||
- Build the JSON structure from queried data
|
||||
- Write to tmp/{job_id}/data.json
|
||||
4. Process and copy media:
|
||||
For each media file:
|
||||
a. Copy original; if image >5 MB, also produce compressed _full variant
|
||||
b. Generate _thumb (resize to 400 px wide via image library)
|
||||
c. For video, extract JPEG frame for _thumb
|
||||
d. Write to tmp/{job_id}/media/
|
||||
5. Create ZIP:
|
||||
zip -r event_name_viewer.zip tmp/{job_id}/
|
||||
6. Store ZIP path, mark job as complete
|
||||
7. Clean up tmp/{job_id}/
|
||||
```
|
||||
|
||||
The backend needs an image processing dependency (e.g. `image` crate in Rust) for thumbnail
|
||||
generation and compression. Video frame extraction requires `ffmpeg` available in the
|
||||
deployment environment (already used for video handling if applicable, otherwise add to
|
||||
docker-compose).
|
||||
|
||||
---
|
||||
|
||||
## Monorepo Structure After Implementation
|
||||
|
||||
```
|
||||
EventSnap/
|
||||
├── backend/
|
||||
│ ├── static/
|
||||
│ │ └── export-viewer/ ← pre-built viewer output (committed)
|
||||
│ │ ├── index.html
|
||||
│ │ └── _app/...
|
||||
│ └── src/
|
||||
│ └── handlers/
|
||||
│ └── export.rs ← export job assembles ZIP
|
||||
├── frontend/
|
||||
│ ├── export-viewer/ ← new mini SvelteKit project
|
||||
│ │ ├── package.json
|
||||
│ │ ├── svelte.config.js ← adapter-static, output → backend/static/export-viewer
|
||||
│ │ ├── tailwind.config.js ← extends ../tailwind.config.js
|
||||
│ │ └── src/
|
||||
│ │ └── routes/
|
||||
│ │ ├── +layout.ts ← prerender=true, ssr=false
|
||||
│ │ └── +page.svelte ← list/grid feed, lightbox, search
|
||||
│ └── src/ ← existing main app (unchanged)
|
||||
└── docs/
|
||||
├── CONCEPT_MOBILE_UI.md
|
||||
└── CONCEPT_HTML_VIEWER.md
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Open Questions for Implementation
|
||||
|
||||
1. **Image processing library**: The `image` crate handles JPEG resize/compress; is it
|
||||
already a backend dependency, or does it need to be added?
|
||||
2. **Video thumbnail extraction**: Is `ffmpeg` available in the Docker environment?
|
||||
If not, a fallback (no video thumb, use a placeholder) is needed.
|
||||
3. **Viewer rebuild workflow**: Add a `make build-viewer` or `pnpm --filter export-viewer build`
|
||||
step to the developer workflow docs and CI so the committed output stays in sync.
|
||||
4. **ZIP file naming**: `{event_slug}_viewer_{date}.zip` or a fixed name?
|
||||
@@ -1,420 +0,0 @@
|
||||
# Mobile-First UI/UX Redesign Concept
|
||||
|
||||
## Overview
|
||||
|
||||
EventSnap is intended for mobile use at live events, but the current UI is desktop-oriented.
|
||||
This document describes a full mobile-first redesign covering navigation, the feed/gallery,
|
||||
account page, host dashboard, and admin dashboard.
|
||||
|
||||
---
|
||||
|
||||
## 1. Navigation: Bottom Tab Bar
|
||||
|
||||
Replace all per-page top-right icon links with a single **persistent bottom tab bar** present
|
||||
on every page. The bar sits at the very bottom with proper `padding-bottom` for iPhone home
|
||||
indicator (safe-area-inset-bottom).
|
||||
|
||||
### Tab Composition by Role
|
||||
|
||||
| Role | Tabs |
|
||||
|-------|------|
|
||||
| Guest | 🏠 Feed · [📷+] · 👤 Account |
|
||||
| Host | 🏠 Feed · [📷+] · 👤 Account |
|
||||
| Admin | 🏠 Feed · [📷+] · 👤 Account |
|
||||
|
||||
All roles see the same three tabs. Role-specific dashboard links (Host, Admin) live inside
|
||||
the Account page — not as separate tabs. This keeps the bar simple and avoids conditional
|
||||
tab rendering.
|
||||
|
||||
### Visual Style
|
||||
|
||||
- Frosted glass background: `bg-white/85 backdrop-blur-md`
|
||||
- Thin top border: `border-t border-gray-200`
|
||||
- Subtle shadow upward
|
||||
- Active tab: colored icon + small label below
|
||||
- Inactive tab: gray icon, small gray label
|
||||
|
||||
### Upload FAB (Floating Action Button)
|
||||
|
||||
The center tab is an elevated circular button, not a flat tab icon:
|
||||
|
||||
- Circle ~56 px diameter, `bg-blue-600`
|
||||
- Icon: camera outline with a small `+` badge overlaid at bottom-right
|
||||
- Raised above the bar with a drop shadow
|
||||
- Press: slight scale-down (`scale-95`) + haptic feedback where available
|
||||
- Communicates "capture new or upload existing"
|
||||
|
||||
---
|
||||
|
||||
## 2. Feed / Gallery Page
|
||||
|
||||
### Header
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────┐
|
||||
│ Sommerfest 2025 ≡ ⊞ │
|
||||
└─────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
- Event name left-aligned
|
||||
- List/grid view toggle icons right-aligned (≡ list, ⊞ grid)
|
||||
- Header collapses on downward scroll (only toggle remains visible), expands on upward scroll
|
||||
|
||||
---
|
||||
|
||||
### View A — Chronological List (default)
|
||||
|
||||
Full-width post cards, newest at top, infinite scroll.
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────┐
|
||||
│ 👤 MaxMustermann · vor 2 Min │
|
||||
│ ┌───────────────────────────────────┐ │
|
||||
│ │ │ │
|
||||
│ │ [photo / video] │ │
|
||||
│ │ │ │
|
||||
│ └───────────────────────────────────┘ │
|
||||
│ Tolle Stimmung! #party #spaß │
|
||||
│ ❤️ 12 💬 3 │
|
||||
└─────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
- Media: full-width, native aspect ratio, capped at 80 vh
|
||||
- Avatar: colored initial circle, no photo
|
||||
- Timestamp: relative ("vor 2 Min", "vor 1 Std")
|
||||
- Tap media → fullscreen lightbox, swipe left/right navigates feed
|
||||
- No search bar in list view
|
||||
|
||||
---
|
||||
|
||||
### View B — Grid View
|
||||
|
||||
Transition animation when toggling: list collapses, grid fades/scales in (~200 ms).
|
||||
|
||||
#### Search Bar (grid view only)
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────┐
|
||||
│ 🔍 Nutzer oder #Tag suchen… × │
|
||||
└─────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
- Appears below the header only in grid view
|
||||
- Slides in as part of the view transition
|
||||
- `×` clears current input
|
||||
- Auto-focuses when grid view is activated
|
||||
|
||||
#### Autocomplete Dropdown
|
||||
|
||||
Appears immediately on focus and updates on every keystroke. Data source: the already-loaded
|
||||
posts in memory — **no extra API calls**.
|
||||
|
||||
Two suggestion lists are derived at load time:
|
||||
- `allTags`: unique hashtags from all post captions, sorted by frequency descending
|
||||
- `allUploaders`: unique display names, sorted alphabetically
|
||||
|
||||
| User input | Suggestions shown |
|
||||
|------------|-------------------|
|
||||
| (focus, empty) | Top 3 tags by frequency + top 3 uploaders |
|
||||
| `#` | All tags, frequency-sorted |
|
||||
| `#par` | Tags with prefix "par": `#party`, `#parade` |
|
||||
| `Max` | Uploaders matching "max" (case-insensitive) |
|
||||
| `a` | Uploaders containing "a" + tags containing "a" |
|
||||
|
||||
Dropdown layout:
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────┐
|
||||
│ 👤 Nutzer │
|
||||
│ MaxMustermann │
|
||||
│ AnnaSchulz │
|
||||
│ # Tags │
|
||||
│ #party #tanz #spaß │
|
||||
└─────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
Max ~5 total suggestions. Tapping a suggestion adds it as an active filter chip and clears
|
||||
the search bar for another entry.
|
||||
|
||||
#### Active Filter Chips
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────┐
|
||||
│ 👤 MaxMustermann × # party × │
|
||||
│ Alle Filter löschen │ ← shown when 2+ chips active
|
||||
└─────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
Filter combination logic:
|
||||
|
||||
| Combination | Logic |
|
||||
|-------------|-------|
|
||||
| Two tags: `#party` + `#tanz` | OR — posts with either tag |
|
||||
| Two uploaders: Max + Anna | OR — posts from either |
|
||||
| Uploader + tag: Max + `#party` | AND — posts by Max that also have `#party` |
|
||||
|
||||
#### Grid Layout
|
||||
|
||||
```
|
||||
┌───────┬───────┬───────┐
|
||||
│ │ │ │
|
||||
│ │ │ │ 3-column, equal square cells
|
||||
├───────┼───────┼───────┤ small gap (2 px)
|
||||
│ │ ▶ │ │ ← video: small ▶ badge + duration
|
||||
│ │ 0:42 │ │
|
||||
└───────┴───────┴───────┘
|
||||
```
|
||||
|
||||
- Tap cell → fullscreen lightbox, swipe navigates filtered set only
|
||||
- Virtualized grid for performance on large events
|
||||
|
||||
---
|
||||
|
||||
## 3. Upload Flow
|
||||
|
||||
### Step 1 — Source Selection (Bottom Sheet)
|
||||
|
||||
Tapping the FAB slides up a bottom sheet (~300 ms spring animation).
|
||||
Frosted glass, rounded top corners, drag handle at top. Tap outside or swipe down to dismiss.
|
||||
|
||||
```
|
||||
┌──────────────────────────────────┐
|
||||
│ ▬ (drag handle) │
|
||||
│ │
|
||||
│ 📸 Kamera │
|
||||
│ Jetzt aufnehmen │
|
||||
│ │
|
||||
│ 🖼 Galerie │
|
||||
│ Foto oder Video wählen │
|
||||
│ │
|
||||
│ [ Abbrechen ] │
|
||||
└──────────────────────────────────┘
|
||||
```
|
||||
|
||||
### Step 2a — Camera
|
||||
|
||||
Triggers `<input type="file" accept="image/*,video/*" capture="environment">`.
|
||||
Native camera opens. After capture → Step 3.
|
||||
|
||||
### Step 2b — Gallery
|
||||
|
||||
Triggers `<input type="file" accept="image/*,video/*" multiple>`.
|
||||
Native gallery picker with multi-select (up to ~10 items). After selection → Step 3.
|
||||
|
||||
### Step 3 — Preview & Metadata Screen
|
||||
|
||||
Full-screen, pushes in from right. Bottom nav hidden (immersive).
|
||||
|
||||
```
|
||||
┌──────────────────────────────────┐
|
||||
│ × Abbrechen Hochladen → │
|
||||
├──────────────────────────────────┤
|
||||
│ │
|
||||
│ ┌────┐ ┌────┐ ┌────┐ → │ ← horizontal scroll, tap to preview
|
||||
│ │img │ │img │ │ × │ │ × on each thumbnail to remove
|
||||
│ └────┘ └────┘ └────┘ │
|
||||
│ │
|
||||
│ Beschreibung (optional) │
|
||||
│ ┌────────────────────────────┐ │
|
||||
│ │ │ │ ← auto-focused
|
||||
│ └────────────────────────────┘ │
|
||||
│ │
|
||||
│ # Schnell-Tags │
|
||||
│ [#Feier] [#Spaß] [#Party] … │ ← tap to append to caption
|
||||
│ │
|
||||
├──────────────────────────────────┤
|
||||
│ ┌────────────────────────────┐ │
|
||||
│ │ 📤 Hochladen │ │ ← sticky, disabled until ≥1 file
|
||||
│ └────────────────────────────┘ │
|
||||
└──────────────────────────────────┘
|
||||
```
|
||||
|
||||
### Step 4 — Background Upload + Feedback
|
||||
|
||||
- Tapping "Hochladen" immediately returns to the feed (optimistic UX)
|
||||
- Slim progress bar above the bottom tab bar while queue is active
|
||||
- FAB gets a small spinning ring badge while uploads are in progress
|
||||
- On completion: brief toast near the bottom ("✓ Hochgeladen")
|
||||
- Rate-limit countdown banner anchored above the bottom bar (existing behavior)
|
||||
|
||||
---
|
||||
|
||||
## 4. Account Page
|
||||
|
||||
Single entry point for profile info and role-based dashboard navigation.
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────┐
|
||||
│ Mein Account │
|
||||
├─────────────────────────────────────────┤
|
||||
│ │
|
||||
│ ┌───────┐ │
|
||||
│ │ M │ MaxMustermann │
|
||||
│ └───────┘ 🏷 Gast │
|
||||
│ Sommerfest 2025 │
|
||||
│ 7 Uploads │
|
||||
│ │
|
||||
├── Dashboards ───────────────────────────┤ (entire section absent for guests)
|
||||
│ │
|
||||
│ ⭐ Host-Dashboard → │ (host + admin only)
|
||||
│ 🛡 Admin-Dashboard → │ (admin only)
|
||||
│ │
|
||||
├── Konto ────────────────────────────────┤
|
||||
│ │
|
||||
│ ✏️ Anzeigename ändern → │
|
||||
│ 🔑 PIN ändern → │
|
||||
│ 🚪 Event verlassen → │ (red text, confirm sheet)
|
||||
│ │
|
||||
└─────────────────────────────────────────┘
|
||||
│ 🏠 Feed · [📷+] · 👤 Account │
|
||||
└─────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
- "Dashboards" section is entirely absent in the DOM for plain guests — not just hidden
|
||||
- "Event verlassen" triggers a bottom-sheet confirmation before action
|
||||
- Avatar: colored circle with initial letter
|
||||
|
||||
---
|
||||
|
||||
## 5. Host Dashboard
|
||||
|
||||
Accessed via Account → ⭐ Host-Dashboard. Full-screen page, bottom nav visible.
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────┐
|
||||
│ ← 🎉 Host-Dashboard │
|
||||
├─────────────────────────────────────────┤
|
||||
│ │
|
||||
│ ── Statistiken ────────────────────── │
|
||||
│ ┌──────────┐ ┌──────────┐ │
|
||||
│ │ 24 │ │ 156 │ │
|
||||
│ │ Nutzer │ │ Uploads │ │
|
||||
│ └──────────┘ └──────────┘ │
|
||||
│ │
|
||||
│ ── Event-Einstellungen ────────────── │ ← collapsible section
|
||||
│ │
|
||||
│ Neue Uploads sperren │
|
||||
│ ○────────────● Gesperrt │ ← toggle
|
||||
│ Keine neuen Uploads möglich │
|
||||
│ │
|
||||
│ ── Nutzerverwaltung ───────────────── │ ← collapsible section
|
||||
│ │
|
||||
│ 🔍 Nutzer suchen… │
|
||||
│ ┌───────────────────────────────────┐ │
|
||||
│ │ 👤 MaxMustermann Gast [🚫] │ │
|
||||
│ │ 👤 AnnaSchulz Gast [🚫] │ │
|
||||
│ │ 👤 GesperrterNutzer [↩] │ │ ← banned: undo icon
|
||||
│ └───────────────────────────────────┘ │
|
||||
│ │
|
||||
└─────────────────────────────────────────┘
|
||||
│ 🏠 Feed · [📷+] · 👤 Account │
|
||||
└─────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
- Sections have a chevron toggle to collapse/expand (helps on small phones)
|
||||
- Ban/unban: icon tap + bottom sheet confirmation ("Nutzer wirklich sperren?")
|
||||
- User list virtualized for large events
|
||||
|
||||
---
|
||||
|
||||
## 6. Admin Dashboard
|
||||
|
||||
Most complex page. Uses an **inner tab bar** directly below the header to divide the four
|
||||
functional areas. The inner tabs are independent of the bottom nav.
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────┐
|
||||
│ ← 🛡 Admin-Dashboard │
|
||||
├─────────────────────────────────────────┤
|
||||
│ [Stats] [Config] [Export] [Nutzer] │ ← inner tab bar (scrollable if needed)
|
||||
├─────────────────────────────────────────┤
|
||||
│ │
|
||||
│ [Tab content] │
|
||||
│ │
|
||||
└─────────────────────────────────────────┘
|
||||
│ 🏠 Feed · [📷+] · 👤 Account │
|
||||
└─────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### Stats Tab
|
||||
|
||||
```
|
||||
┌──────────┐ ┌──────────┐
|
||||
│ 156 │ │ 24 │
|
||||
│ Uploads │ │ Nutzer │
|
||||
└──────────┘ └──────────┘
|
||||
┌──────────┐ ┌──────────┐
|
||||
│ 2.1 GB │ │ 3 │
|
||||
│ Speicher │ │ Gesperrt │
|
||||
└──────────┘ └──────────┘
|
||||
```
|
||||
|
||||
2×2 metric card grid. Values large and prominent. Optionally expandable to show time-series
|
||||
charts on tap.
|
||||
|
||||
### Config Tab
|
||||
|
||||
```
|
||||
Upload-Limit / Nutzer
|
||||
┌────────────────────────────────┐
|
||||
│ 10 │
|
||||
└────────────────────────────────┘
|
||||
|
||||
Zeitfenster (Sek.)
|
||||
┌────────────────────────────────┐
|
||||
│ 60 │
|
||||
└────────────────────────────────┘
|
||||
|
||||
Max. Dateigröße (MB)
|
||||
┌────────────────────────────────┐
|
||||
│ 50 │
|
||||
└────────────────────────────────┘
|
||||
|
||||
┌────────────────────────────────┐
|
||||
│ 💾 Speichern │ ← sticky at bottom of tab scroll area
|
||||
└────────────────────────────────┘
|
||||
```
|
||||
|
||||
Each setting: full-width label + input. Save button always reachable without scrolling.
|
||||
|
||||
### Export Tab
|
||||
|
||||
```
|
||||
── Galerie ──────────────────────────
|
||||
[ 🔓 Galerie freigeben ]
|
||||
|
||||
── Export-Jobs ──────────────────────
|
||||
[ 🔄 Aktualisieren ]
|
||||
|
||||
┌───────────────────────────────────┐
|
||||
│ HTML-Viewer ● Fertig [↓ ZIP] │
|
||||
│ JSON-Export ⏳ Läuft… │
|
||||
│ ZIP-Archiv ✗ Fehler [↺] │
|
||||
└───────────────────────────────────┘
|
||||
|
||||
[ + Neuer Export-Job ]
|
||||
```
|
||||
|
||||
- Status chips: green (Fertig), amber (Läuft), red (Fehler)
|
||||
- Download button inline per completed job
|
||||
- Only the jobs list refreshes on "Aktualisieren" — no full page re-render
|
||||
|
||||
### Nutzer Tab
|
||||
|
||||
Same structure as Host Nutzerverwaltung, with any additional admin-only actions
|
||||
(e.g. role assignment) added as extra controls per row.
|
||||
|
||||
---
|
||||
|
||||
## Design Principles Summary
|
||||
|
||||
| Principle | Application |
|
||||
|-----------|-------------|
|
||||
| Thumb zone | All primary actions in bottom ~20% of screen |
|
||||
| One-hand operation | FAB centered, bottom sheets dismissable with swipe |
|
||||
| Minimal taps to upload | Source → picker → preview → upload: 4 taps |
|
||||
| Immediate feedback | Optimistic return to feed, background upload |
|
||||
| Progressive disclosure | Caption/tags optional; CTA always reachable |
|
||||
| No role clutter in nav | Role links only in Account, bar stays clean |
|
||||
| Collapsible sections | Long management pages stay usable on small phones |
|
||||
| Inner tabs for complex pages | Admin dashboard split across 4 focused tabs |
|
||||
2153
frontend/export-viewer/package-lock.json
generated
2153
frontend/export-viewer/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -1,22 +0,0 @@
|
||||
{
|
||||
"name": "export-viewer",
|
||||
"private": true,
|
||||
"version": "0.0.1",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite dev",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"prepare": "svelte-kit sync || echo ''"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@sveltejs/adapter-static": "^3.0.0",
|
||||
"@sveltejs/kit": "^2.50.2",
|
||||
"@sveltejs/vite-plugin-svelte": "^6.2.4",
|
||||
"@tailwindcss/vite": "^4.2.2",
|
||||
"svelte": "^5.54.0",
|
||||
"tailwindcss": "^4.2.2",
|
||||
"typescript": "^5.9.3",
|
||||
"vite": "^7.3.1"
|
||||
}
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
@import "tailwindcss";
|
||||
@@ -1,11 +0,0 @@
|
||||
<!doctype html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
%sveltekit.head%
|
||||
</head>
|
||||
<body>
|
||||
<div style="display: contents">%sveltekit.body%</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,28 +0,0 @@
|
||||
export interface ViewerData {
|
||||
event: {
|
||||
name: string;
|
||||
exported_at: string;
|
||||
};
|
||||
posts: ViewerPost[];
|
||||
}
|
||||
|
||||
export interface ViewerPost {
|
||||
id: string;
|
||||
uploader: string;
|
||||
caption: string;
|
||||
tags: string[];
|
||||
timestamp: string;
|
||||
likes: number;
|
||||
comments: ViewerComment[];
|
||||
media: {
|
||||
type: 'image' | 'video';
|
||||
thumb: string;
|
||||
full: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface ViewerComment {
|
||||
author: string;
|
||||
text: string;
|
||||
timestamp: string;
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
<script>
|
||||
import '../app.css';
|
||||
let { children } = $props();
|
||||
</script>
|
||||
|
||||
{@render children()}
|
||||
@@ -1,2 +0,0 @@
|
||||
export const prerender = true;
|
||||
export const ssr = false;
|
||||
@@ -1,613 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import type { ViewerData, ViewerPost, ViewerComment } from '$lib/types';
|
||||
|
||||
let data = $state<ViewerData | null>(null);
|
||||
let loading = $state(true);
|
||||
let error = $state('');
|
||||
|
||||
// View mode
|
||||
let viewMode = $state<'list' | 'grid'>('list');
|
||||
|
||||
// Grid search / filter state
|
||||
let searchQuery = $state('');
|
||||
let showAutocomplete = $state(false);
|
||||
|
||||
interface Filter { type: 'tag' | 'user'; value: string }
|
||||
let activeFilters = $state<Filter[]>([]);
|
||||
|
||||
// Lightbox state
|
||||
let selectedPost = $state<ViewerPost | null>(null);
|
||||
let lightboxIndex = $state(0);
|
||||
let touchStartX = 0;
|
||||
|
||||
// List view hashtag filter
|
||||
let selectedHashtag = $state<string | null>(null);
|
||||
|
||||
// ── Derived data ────────────────────────────────────────────────────────────
|
||||
|
||||
let posts = $derived(data?.posts ?? []);
|
||||
|
||||
let allTags = $derived.by(() => {
|
||||
const freq = new Map<string, number>();
|
||||
for (const p of posts) {
|
||||
for (const t of p.tags) {
|
||||
const lower = t.toLowerCase();
|
||||
freq.set(lower, (freq.get(lower) ?? 0) + 1);
|
||||
}
|
||||
}
|
||||
return [...freq.entries()].sort((a, b) => b[1] - a[1]);
|
||||
});
|
||||
|
||||
let allUploaders = $derived([...new Set(posts.map((p) => p.uploader))].sort());
|
||||
|
||||
let suggestions = $derived.by((): Filter[] => {
|
||||
const q = searchQuery.trim();
|
||||
if (!q) {
|
||||
if (!showAutocomplete) return [];
|
||||
return [
|
||||
...allUploaders.slice(0, 3).map((u) => ({ type: 'user' as const, value: u })),
|
||||
...allTags.slice(0, 3).map(([t]) => ({ type: 'tag' as const, value: t })),
|
||||
];
|
||||
}
|
||||
if (q.startsWith('#')) {
|
||||
const prefix = q.slice(1).toLowerCase();
|
||||
return allTags
|
||||
.filter(([t]) => t.startsWith(prefix))
|
||||
.slice(0, 8)
|
||||
.map(([t]) => ({ type: 'tag' as const, value: t }));
|
||||
}
|
||||
const lower = q.toLowerCase();
|
||||
return [
|
||||
...allUploaders
|
||||
.filter((u) => u.toLowerCase().includes(lower))
|
||||
.slice(0, 4)
|
||||
.map((u) => ({ type: 'user' as const, value: u })),
|
||||
...allTags
|
||||
.filter(([t]) => t.includes(lower))
|
||||
.slice(0, 4)
|
||||
.map(([t]) => ({ type: 'tag' as const, value: t })),
|
||||
];
|
||||
});
|
||||
|
||||
// List view: filter by selected hashtag
|
||||
let listPosts = $derived.by(() => {
|
||||
if (!selectedHashtag) return posts;
|
||||
return posts.filter((p) => p.tags.some((t) => t.toLowerCase() === selectedHashtag));
|
||||
});
|
||||
|
||||
// Grid view: filter by active filters (OR within type, AND across types)
|
||||
let filteredPosts = $derived.by(() => {
|
||||
if (activeFilters.length === 0) return posts;
|
||||
const tags = activeFilters.filter((f) => f.type === 'tag').map((f) => f.value);
|
||||
const users = activeFilters.filter((f) => f.type === 'user').map((f) => f.value);
|
||||
return posts.filter((p) => {
|
||||
const postTags = p.tags.map((t) => t.toLowerCase());
|
||||
const passTag = !tags.length || tags.some((t) => postTags.includes(t));
|
||||
const passUser = !users.length || users.includes(p.uploader);
|
||||
return passTag && passUser;
|
||||
});
|
||||
});
|
||||
|
||||
let displayPosts = $derived(viewMode === 'list' ? listPosts : filteredPosts);
|
||||
|
||||
// ── Data loading ────────────────────────────────────────────────────────────
|
||||
|
||||
onMount(async () => {
|
||||
try {
|
||||
const res = await fetch('./data.json');
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
data = await res.json();
|
||||
} catch (e) {
|
||||
error = 'Daten konnten nicht geladen werden. Stelle sicher, dass data.json im selben Ordner liegt.';
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
});
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
function formatDate(iso: string): string {
|
||||
const d = new Date(iso);
|
||||
return d.toLocaleString('de-DE', {
|
||||
day: '2-digit', month: '2-digit', year: 'numeric',
|
||||
hour: '2-digit', minute: '2-digit',
|
||||
});
|
||||
}
|
||||
|
||||
function formatShortDate(iso: string): string {
|
||||
return new Date(iso).toLocaleString('de-DE', {
|
||||
day: '2-digit', month: '2-digit', hour: '2-digit', minute: '2-digit',
|
||||
});
|
||||
}
|
||||
|
||||
function initial(name: string): string {
|
||||
return name[0]?.toUpperCase() ?? '?';
|
||||
}
|
||||
|
||||
const COLORS = [
|
||||
'bg-blue-100 text-blue-700',
|
||||
'bg-purple-100 text-purple-700',
|
||||
'bg-green-100 text-green-700',
|
||||
'bg-amber-100 text-amber-700',
|
||||
'bg-rose-100 text-rose-700',
|
||||
'bg-teal-100 text-teal-700',
|
||||
];
|
||||
function avatarColor(name: string): string {
|
||||
let hash = 0;
|
||||
for (const ch of name) hash = (hash * 31 + ch.charCodeAt(0)) & 0xff;
|
||||
return COLORS[hash % COLORS.length];
|
||||
}
|
||||
|
||||
// ── View switching ──────────────────────────────────────────────────────────
|
||||
|
||||
function switchView(mode: 'list' | 'grid') {
|
||||
viewMode = mode;
|
||||
if (mode === 'list') {
|
||||
searchQuery = '';
|
||||
showAutocomplete = false;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Search / filter ─────────────────────────────────────────────────────────
|
||||
|
||||
function selectSuggestion(item: Filter) {
|
||||
if (!activeFilters.some((f) => f.type === item.type && f.value === item.value)) {
|
||||
activeFilters = [...activeFilters, item];
|
||||
}
|
||||
searchQuery = '';
|
||||
showAutocomplete = false;
|
||||
}
|
||||
|
||||
function removeFilter(item: Filter) {
|
||||
activeFilters = activeFilters.filter((f) => !(f.type === item.type && f.value === item.value));
|
||||
}
|
||||
|
||||
function clearFilters() {
|
||||
activeFilters = [];
|
||||
searchQuery = '';
|
||||
}
|
||||
|
||||
function selectHashtag(tag: string | null) {
|
||||
selectedHashtag = tag;
|
||||
}
|
||||
|
||||
// ── Lightbox ────────────────────────────────────────────────────────────────
|
||||
|
||||
function openLightbox(post: ViewerPost) {
|
||||
const idx = displayPosts.indexOf(post);
|
||||
lightboxIndex = idx >= 0 ? idx : 0;
|
||||
selectedPost = post;
|
||||
}
|
||||
|
||||
function closeLightbox() {
|
||||
selectedPost = null;
|
||||
}
|
||||
|
||||
function navigateLightbox(delta: number) {
|
||||
const len = displayPosts.length;
|
||||
if (len === 0) return;
|
||||
lightboxIndex = (lightboxIndex + delta + len) % len;
|
||||
selectedPost = displayPosts[lightboxIndex];
|
||||
}
|
||||
|
||||
function handleKeydown(e: KeyboardEvent) {
|
||||
if (!selectedPost) return;
|
||||
if (e.key === 'Escape') closeLightbox();
|
||||
else if (e.key === 'ArrowLeft') navigateLightbox(-1);
|
||||
else if (e.key === 'ArrowRight') navigateLightbox(1);
|
||||
}
|
||||
|
||||
function handleTouchStart(e: TouchEvent) {
|
||||
touchStartX = e.touches[0].clientX;
|
||||
}
|
||||
|
||||
function handleTouchEnd(e: TouchEvent) {
|
||||
const diff = e.changedTouches[0].clientX - touchStartX;
|
||||
if (Math.abs(diff) > 50) {
|
||||
navigateLightbox(diff > 0 ? -1 : 1);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:window onkeydown={handleKeydown} />
|
||||
|
||||
{#if loading}
|
||||
<div class="flex min-h-screen items-center justify-center bg-gray-50">
|
||||
<div class="inline-block h-8 w-8 animate-spin rounded-full border-2 border-gray-300 border-t-blue-600"></div>
|
||||
</div>
|
||||
{:else if error}
|
||||
<div class="flex min-h-screen items-center justify-center bg-gray-50 p-4">
|
||||
<div class="rounded-xl border border-red-200 bg-red-50 p-6 text-center">
|
||||
<p class="text-sm text-red-700">{error}</p>
|
||||
</div>
|
||||
</div>
|
||||
{:else if data}
|
||||
<div class="min-h-screen bg-gray-50 pb-8">
|
||||
<!-- Sticky header -->
|
||||
<div class="sticky top-0 z-30 border-b border-gray-200 bg-white/95 backdrop-blur">
|
||||
<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">{data.event.name}</h1>
|
||||
|
||||
<!-- List / Grid toggle -->
|
||||
<div class="flex items-center gap-1 rounded-lg bg-gray-100 p-1">
|
||||
<button
|
||||
onclick={() => switchView('list')}
|
||||
class="rounded-md p-1.5 transition-colors {viewMode === 'list' ? 'bg-white text-gray-900 shadow-sm' : 'text-gray-400 hover:text-gray-600'}"
|
||||
aria-label="Listenansicht"
|
||||
>
|
||||
<svg class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M3.75 6.75h16.5M3.75 12h16.5m-16.5 5.25h16.5" />
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
onclick={() => switchView('grid')}
|
||||
class="rounded-md p-1.5 transition-colors {viewMode === 'grid' ? 'bg-white text-gray-900 shadow-sm' : 'text-gray-400 hover:text-gray-600'}"
|
||||
aria-label="Rasteransicht"
|
||||
>
|
||||
<svg class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M3.75 6A2.25 2.25 0 0 1 6 3.75h2.25A2.25 2.25 0 0 1 10.5 6v2.25a2.25 2.25 0 0 1-2.25 2.25H6a2.25 2.25 0 0 1-2.25-2.25V6ZM3.75 15.75A2.25 2.25 0 0 1 6 13.5h2.25a2.25 2.25 0 0 1 2.25 2.25V18a2.25 2.25 0 0 1-2.25 2.25H6A2.25 2.25 0 0 1 3.75 18v-2.25ZM13.5 6a2.25 2.25 0 0 1 2.25-2.25H18A2.25 2.25 0 0 1 20.25 6v2.25A2.25 2.25 0 0 1 18 10.5h-2.25a2.25 2.25 0 0 1-2.25-2.25V6ZM13.5 15.75a2.25 2.25 0 0 1 2.25-2.25H18a2.25 2.25 0 0 1 2.25 2.25V18A2.25 2.25 0 0 1 18 20.25h-2.25A2.25 2.25 0 0 1 13.5 18v-2.25Z" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- List view: hashtag chips -->
|
||||
{#if viewMode === 'list' && allTags.length > 0}
|
||||
<div class="mx-auto max-w-2xl px-4 pb-2">
|
||||
<div class="flex gap-2 overflow-x-auto pb-2">
|
||||
<button
|
||||
onclick={() => selectHashtag(null)}
|
||||
class="shrink-0 rounded-full px-3 py-1 text-sm font-medium transition {
|
||||
selectedHashtag === null
|
||||
? 'bg-blue-600 text-white'
|
||||
: 'bg-gray-200 text-gray-700 hover:bg-gray-300'
|
||||
}"
|
||||
>
|
||||
Alle
|
||||
</button>
|
||||
{#each allTags as [tag, count] (tag)}
|
||||
<button
|
||||
onclick={() => selectHashtag(tag)}
|
||||
class="shrink-0 rounded-full px-3 py-1 text-sm font-medium transition {
|
||||
selectedHashtag === tag
|
||||
? 'bg-blue-600 text-white'
|
||||
: 'bg-gray-200 text-gray-700 hover:bg-gray-300'
|
||||
}"
|
||||
>
|
||||
#{tag}
|
||||
<span class="ml-1 text-xs opacity-70">{count}</span>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Grid view: search bar + autocomplete -->
|
||||
{#if viewMode === 'grid'}
|
||||
<div class="mx-auto max-w-2xl px-4 pb-3">
|
||||
<div class="relative">
|
||||
<div class="flex items-center gap-2 rounded-xl border border-gray-200 bg-gray-50 px-3 py-2 focus-within:border-blue-400 focus-within:bg-white focus-within:ring-1 focus-within:ring-blue-200">
|
||||
<svg class="h-4 w-4 shrink-0 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M21 21l-5.197-5.197m0 0A7.5 7.5 0 1 0 5.196 5.196a7.5 7.5 0 0 0 10.607 10.607Z" />
|
||||
</svg>
|
||||
<input
|
||||
type="search"
|
||||
placeholder="Nutzer oder #Tag suchen..."
|
||||
bind:value={searchQuery}
|
||||
onfocus={() => (showAutocomplete = true)}
|
||||
onblur={() => setTimeout(() => (showAutocomplete = false), 150)}
|
||||
class="min-w-0 flex-1 bg-transparent text-sm text-gray-900 placeholder-gray-400 outline-none"
|
||||
/>
|
||||
{#if searchQuery}
|
||||
<button
|
||||
onclick={() => { searchQuery = ''; }}
|
||||
class="shrink-0 text-gray-400 hover:text-gray-600"
|
||||
aria-label="Suche loschen"
|
||||
>
|
||||
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18 18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Autocomplete dropdown -->
|
||||
{#if showAutocomplete && suggestions.length > 0}
|
||||
<div class="absolute left-0 right-0 top-full z-50 mt-1 overflow-hidden rounded-xl border border-gray-200 bg-white shadow-lg">
|
||||
{#each suggestions as item}
|
||||
<button
|
||||
class="flex w-full items-center gap-3 px-4 py-2.5 text-left text-sm hover:bg-gray-50"
|
||||
onmousedown={() => selectSuggestion(item)}
|
||||
>
|
||||
{#if item.type === 'user'}
|
||||
<svg class="h-4 w-4 shrink-0 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M15.75 6a3.75 3.75 0 1 1-7.5 0 3.75 3.75 0 0 1 7.5 0ZM4.501 20.118a7.5 7.5 0 0 1 14.998 0A17.933 17.933 0 0 1 12 21.75c-2.676 0-5.216-.584-7.499-1.632Z" />
|
||||
</svg>
|
||||
<span class="font-medium text-gray-900">{item.value}</span>
|
||||
{:else}
|
||||
<span class="font-medium text-blue-500">#</span>
|
||||
<span class="font-medium text-gray-900">{item.value}</span>
|
||||
{/if}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Active filter chips -->
|
||||
{#if activeFilters.length > 0}
|
||||
<div class="mt-2 flex flex-wrap items-center gap-1.5">
|
||||
{#each activeFilters as filter}
|
||||
<span class="flex items-center gap-1 rounded-full bg-blue-100 px-2.5 py-0.5 text-xs font-medium text-blue-700">
|
||||
{filter.type === 'tag' ? '#' : ''}{filter.value}
|
||||
<button onclick={() => removeFilter(filter)} class="ml-0.5 hover:text-blue-900" aria-label="Filter entfernen">
|
||||
<svg class="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2.5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18 18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</span>
|
||||
{/each}
|
||||
{#if activeFilters.length >= 2}
|
||||
<button onclick={clearFilters} class="text-xs text-gray-400 hover:text-gray-600">
|
||||
Alle loschen
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Content -->
|
||||
{#if displayPosts.length === 0}
|
||||
<div class="py-20 text-center">
|
||||
<p class="text-lg text-gray-400">Noch keine Fotos.</p>
|
||||
{#if activeFilters.length > 0}
|
||||
<button onclick={clearFilters} class="mt-2 text-sm text-blue-600 hover:underline">Filter zurucksetzen</button>
|
||||
{/if}
|
||||
</div>
|
||||
{:else if viewMode === 'list'}
|
||||
<!-- List view: chronological full-width cards -->
|
||||
<div class="mx-auto max-w-2xl">
|
||||
{#each displayPosts as post (post.id)}
|
||||
<article class="bg-white">
|
||||
<!-- Uploader row -->
|
||||
<div class="flex items-center gap-3 px-4 py-3">
|
||||
<div
|
||||
class="flex h-9 w-9 shrink-0 items-center justify-center rounded-full text-sm font-bold
|
||||
{avatarColor(post.uploader)}"
|
||||
>
|
||||
{initial(post.uploader)}
|
||||
</div>
|
||||
<div class="min-w-0">
|
||||
<p class="truncate text-sm font-semibold text-gray-900">{post.uploader}</p>
|
||||
<p class="text-xs text-gray-400">{formatDate(post.timestamp)}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Media -->
|
||||
<button
|
||||
class="block w-full"
|
||||
onclick={() => openLightbox(post)}
|
||||
aria-label="Bild vergrößern"
|
||||
>
|
||||
{#if post.media.type === 'video'}
|
||||
<div class="relative aspect-video w-full bg-gray-900">
|
||||
{#if post.media.thumb}
|
||||
<img src={post.media.thumb} alt="" class="h-full w-full object-cover opacity-80" />
|
||||
{/if}
|
||||
<div class="absolute inset-0 flex items-center justify-center">
|
||||
<span class="flex h-14 w-14 items-center justify-center rounded-full bg-black/50 text-white">
|
||||
<svg class="h-7 w-7 pl-0.5" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M8 5v14l11-7z" />
|
||||
</svg>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{:else if post.media.full}
|
||||
<img
|
||||
src={post.media.full}
|
||||
alt=""
|
||||
class="w-full object-cover"
|
||||
style="max-height: 80svh"
|
||||
loading="lazy"
|
||||
/>
|
||||
{:else}
|
||||
<div class="flex aspect-square w-full items-center justify-center bg-gray-100">
|
||||
<svg class="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="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>
|
||||
|
||||
<!-- Stats row (read-only) -->
|
||||
<div class="flex items-center gap-4 px-4 py-2">
|
||||
<span class="flex items-center gap-1.5 text-sm font-medium text-gray-500">
|
||||
<svg class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" 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>
|
||||
{post.likes}
|
||||
</span>
|
||||
<span class="flex items-center gap-1.5 text-sm font-medium text-gray-500">
|
||||
<svg class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" 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>
|
||||
{post.comments.length}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Caption -->
|
||||
{#if post.caption}
|
||||
<p class="px-4 pb-3 text-sm text-gray-800 [overflow-wrap:anywhere]">
|
||||
{post.caption}
|
||||
</p>
|
||||
{/if}
|
||||
|
||||
<div class="border-b border-gray-100"></div>
|
||||
</article>
|
||||
{/each}
|
||||
</div>
|
||||
{:else}
|
||||
<!-- Grid view: 3-col -->
|
||||
<div class="mx-auto max-w-2xl">
|
||||
<div class="grid grid-cols-3 gap-0.5">
|
||||
{#each displayPosts as post (post.id)}
|
||||
<div class="group relative aspect-square cursor-pointer overflow-hidden rounded-lg bg-gray-100">
|
||||
<button
|
||||
onclick={() => openLightbox(post)}
|
||||
class="block h-full w-full"
|
||||
aria-label="Upload anzeigen"
|
||||
>
|
||||
{#if post.media.type === 'video'}
|
||||
<div class="flex h-full items-center justify-center bg-gray-800">
|
||||
{#if post.media.thumb}
|
||||
<img src={post.media.thumb} 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 post.media.thumb}
|
||||
<img src={post.media.thumb} 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">{post.uploader}</p>
|
||||
<div class="mt-0.5 flex items-center gap-3 text-xs text-white/80">
|
||||
<span class="flex items-center gap-0.5">
|
||||
<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="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>
|
||||
{post.likes}
|
||||
</span>
|
||||
<span class="flex items-center gap-0.5">
|
||||
<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>
|
||||
{post.comments.length}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Footer -->
|
||||
<footer class="mt-8 border-t border-gray-200 py-6 text-center text-xs text-gray-400">
|
||||
<p>{data.event.name} · Offline-Galerie · EventSnap</p>
|
||||
<p class="mt-1">Exportiert am {formatDate(data.event.exported_at)}</p>
|
||||
</footer>
|
||||
</div>
|
||||
|
||||
<!-- Lightbox -->
|
||||
{#if selectedPost}
|
||||
<div
|
||||
class="fixed inset-0 z-50 flex items-center justify-center bg-black/80"
|
||||
role="dialog"
|
||||
tabindex="-1"
|
||||
onclick={(e) => { if (e.target === e.currentTarget) closeLightbox(); }}
|
||||
ontouchstart={handleTouchStart}
|
||||
ontouchend={handleTouchEnd}
|
||||
>
|
||||
<div class="flex max-h-[95vh] w-full max-w-2xl flex-col overflow-hidden rounded-xl bg-white sm:m-4">
|
||||
<!-- Media -->
|
||||
<div class="relative bg-black">
|
||||
<button onclick={closeLightbox} 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>
|
||||
|
||||
<!-- Nav arrows -->
|
||||
{#if displayPosts.length > 1}
|
||||
<button
|
||||
onclick={() => navigateLightbox(-1)}
|
||||
class="absolute left-2 top-1/2 z-10 -translate-y-1/2 rounded-full bg-black/50 p-1.5 text-white hover:bg-black/70"
|
||||
aria-label="Vorheriges"
|
||||
>
|
||||
<svg class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M15.75 19.5 8.25 12l7.5-7.5" />
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
onclick={() => navigateLightbox(1)}
|
||||
class="absolute right-2 top-1/2 z-10 -translate-y-1/2 rounded-full bg-black/50 p-1.5 text-white hover:bg-black/70"
|
||||
aria-label="Nachstes"
|
||||
>
|
||||
<svg class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="m8.25 4.5 7.5 7.5-7.5 7.5" />
|
||||
</svg>
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
{#if selectedPost.media.type === 'video'}
|
||||
<video
|
||||
src={selectedPost.media.full}
|
||||
controls
|
||||
class="max-h-[50vh] w-full object-contain"
|
||||
poster={selectedPost.media.thumb || undefined}
|
||||
></video>
|
||||
{:else}
|
||||
<img
|
||||
src={selectedPost.media.full}
|
||||
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">{selectedPost.uploader}</span>
|
||||
<span class="ml-2 text-xs text-gray-400">{formatShortDate(selectedPost.timestamp)}</span>
|
||||
</div>
|
||||
<span class="flex items-center gap-1 rounded-full bg-gray-100 px-2.5 py-1 text-sm text-gray-600">
|
||||
<svg class="h-4 w-4" 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>
|
||||
{selectedPost.likes}
|
||||
</span>
|
||||
</div>
|
||||
{#if selectedPost.caption}
|
||||
<p class="mt-1 text-sm text-gray-700 [overflow-wrap:anywhere]">{selectedPost.caption}</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Comments list -->
|
||||
<div class="flex-1 overflow-y-auto p-3">
|
||||
{#if selectedPost.comments.length === 0}
|
||||
<p class="text-center text-sm text-gray-400">Keine Kommentare.</p>
|
||||
{:else}
|
||||
<div class="space-y-3">
|
||||
{#each selectedPost.comments as comment}
|
||||
<div>
|
||||
<span class="text-sm font-medium text-gray-900">{comment.author}</span>
|
||||
<span class="ml-1 text-sm text-gray-700">{comment.text}</span>
|
||||
<div class="mt-0.5 text-xs text-gray-400">{formatShortDate(comment.timestamp)}</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
@@ -1,21 +0,0 @@
|
||||
import adapter from '@sveltejs/adapter-static';
|
||||
|
||||
/** @type {import('@sveltejs/kit').Config} */
|
||||
const config = {
|
||||
compilerOptions: {
|
||||
runes: true
|
||||
},
|
||||
kit: {
|
||||
adapter: adapter({
|
||||
pages: '../../backend/static/export-viewer',
|
||||
assets: '../../backend/static/export-viewer',
|
||||
fallback: 'index.html',
|
||||
strict: false
|
||||
}),
|
||||
paths: {
|
||||
relative: true
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export default config;
|
||||
@@ -1,15 +0,0 @@
|
||||
{
|
||||
"extends": "./.svelte-kit/tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"rewriteRelativeImportExtensions": true,
|
||||
"allowJs": true,
|
||||
"checkJs": true,
|
||||
"esModuleInterop": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"resolveJsonModule": true,
|
||||
"skipLibCheck": true,
|
||||
"sourceMap": true,
|
||||
"strict": true,
|
||||
"moduleResolution": "bundler"
|
||||
}
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
import { sveltekit } from '@sveltejs/kit/vite';
|
||||
import tailwindcss from '@tailwindcss/vite';
|
||||
import { defineConfig } from 'vite';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [tailwindcss(), sveltekit()]
|
||||
});
|
||||
7
frontend/package-lock.json
generated
7
frontend/package-lock.json
generated
@@ -1029,7 +1029,6 @@
|
||||
"integrity": "sha512-MdFRjevVxmAknf2NbaUkDF16jSIzXMWd4Nfah0Qp8TtQVoSp3bV4jKt8mX7z7qTUTWvgSaxtR0EG5WJf53gcuA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@standard-schema/spec": "^1.0.0",
|
||||
"@sveltejs/acorn-typescript": "^1.0.5",
|
||||
@@ -1072,7 +1071,6 @@
|
||||
"integrity": "sha512-ou/d51QSdTyN26D7h6dSpusAKaZkAiGM55/AKYi+9AGZw7q85hElbjK3kEyzXHhLSnRISHOYzVge6x0jRZ7DXA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@sveltejs/vite-plugin-svelte-inspector": "^5.0.0",
|
||||
"deepmerge": "^4.3.1",
|
||||
@@ -1446,7 +1444,6 @@
|
||||
"integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"acorn": "bin/acorn"
|
||||
},
|
||||
@@ -2384,7 +2381,6 @@
|
||||
"integrity": "sha512-VmtB2rFU/GroZ4oL8+ZqXgSA38O6GR8KSIvWmEFv63pQ0G6KaBH9s07PO8XTXP4vI+3UJUEypOfjkGfmSBBR0w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@types/estree": "1.0.8"
|
||||
},
|
||||
@@ -2520,7 +2516,6 @@
|
||||
"integrity": "sha512-QjvU7EFemf6mRzdMGlAFttMWtAAVXrax61SZYHdkD6yoVGQ89VeyKfZD4H1JrV1WLmJBxWhFch9H6ig/87VGjw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@jridgewell/remapping": "^2.3.4",
|
||||
"@jridgewell/sourcemap-codec": "^1.5.0",
|
||||
@@ -2621,7 +2616,6 @@
|
||||
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
"tsserver": "bin/tsserver"
|
||||
@@ -2643,7 +2637,6 @@
|
||||
"integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"esbuild": "^0.27.0",
|
||||
"fdir": "^6.5.0",
|
||||
|
||||
@@ -4,7 +4,6 @@ import { browser } from '$app/environment';
|
||||
const TOKEN_KEY = 'eventsnap_jwt';
|
||||
const PIN_KEY = 'eventsnap_pin';
|
||||
const USER_ID_KEY = 'eventsnap_user_id';
|
||||
const DISPLAY_NAME_KEY = 'eventsnap_display_name';
|
||||
|
||||
export const isAuthenticated = writable(false);
|
||||
|
||||
@@ -23,28 +22,11 @@ export function getUserId(): string | null {
|
||||
return localStorage.getItem(USER_ID_KEY);
|
||||
}
|
||||
|
||||
export function getDisplayName(): string | null {
|
||||
if (!browser) return null;
|
||||
return localStorage.getItem(DISPLAY_NAME_KEY);
|
||||
}
|
||||
|
||||
export function getExpiry(): Date | null {
|
||||
const token = getToken();
|
||||
if (!token) return null;
|
||||
try {
|
||||
const payload = JSON.parse(atob(token.split('.')[1]));
|
||||
return payload.exp ? new Date(payload.exp * 1000) : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function setAuth(jwt: string, pin: string | null, userId: string, displayName?: string): void {
|
||||
export function setAuth(jwt: string, pin: string | null, userId: string): void {
|
||||
if (!browser) return;
|
||||
localStorage.setItem(TOKEN_KEY, jwt);
|
||||
if (pin) localStorage.setItem(PIN_KEY, pin);
|
||||
localStorage.setItem(USER_ID_KEY, userId);
|
||||
if (displayName) localStorage.setItem(DISPLAY_NAME_KEY, displayName);
|
||||
isAuthenticated.set(true);
|
||||
}
|
||||
|
||||
@@ -56,17 +38,6 @@ export function clearAuth(): void {
|
||||
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 {
|
||||
if (!browser) return;
|
||||
isAuthenticated.set(!!getToken());
|
||||
|
||||
@@ -1,65 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { page } from '$app/stores';
|
||||
import { uploadSheetOpen, uploadBadgeCount } from '$lib/ui-store';
|
||||
|
||||
function isActive(path: string): boolean {
|
||||
return $page.url.pathname.startsWith(path);
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- Bottom navigation bar — fixed, full-width, safe-area aware -->
|
||||
<nav
|
||||
class="fixed bottom-0 left-0 right-0 z-40 border-t border-gray-200 bg-white/90 backdrop-blur-md"
|
||||
style="padding-bottom: env(safe-area-inset-bottom)"
|
||||
>
|
||||
<div class="mx-auto flex h-14 max-w-2xl items-end justify-around px-4 pb-1">
|
||||
<!-- Feed tab -->
|
||||
<a
|
||||
href="/feed"
|
||||
class="flex flex-col items-center gap-0.5 px-4 py-1 text-xs font-medium transition-colors
|
||||
{isActive('/feed') ? 'text-blue-600' : 'text-gray-400 hover:text-gray-600'}"
|
||||
aria-label="Galerie"
|
||||
>
|
||||
<svg class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="m2.25 15.75 5.159-5.159a2.25 2.25 0 0 1 3.182 0l5.159 5.159m-1.5-1.5 1.409-1.409a2.25 2.25 0 0 1 3.182 0l2.909 2.909m-18 3.75h16.5a1.5 1.5 0 0 0 1.5-1.5V6a1.5 1.5 0 0 0-1.5-1.5H3.75A1.5 1.5 0 0 0 2.25 6v12a1.5 1.5 0 0 0 1.5 1.5Zm10.5-11.25h.008v.008h-.008V8.25Zm.375 0a.375.375 0 1 1-.75 0 .375.375 0 0 1 .75 0Z" />
|
||||
</svg>
|
||||
<span>Galerie</span>
|
||||
</a>
|
||||
|
||||
<!-- Upload FAB (center, elevated) -->
|
||||
<div class="relative -translate-y-3">
|
||||
<button
|
||||
onclick={() => ($uploadSheetOpen = true)}
|
||||
class="relative flex h-14 w-14 items-center justify-center rounded-full bg-blue-600 text-white shadow-lg transition active:scale-95 hover:bg-blue-700"
|
||||
aria-label="Hochladen"
|
||||
>
|
||||
<!-- Camera + plus icon -->
|
||||
<svg class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M6.827 6.175A2.31 2.31 0 0 1 5.186 7.23c-.38.054-.757.112-1.134.175C2.999 7.58 2.25 8.507 2.25 9.574V18a2.25 2.25 0 0 0 2.25 2.25h15A2.25 2.25 0 0 0 21.75 18V9.574c0-1.067-.75-1.994-1.802-2.169a47.865 47.865 0 0 0-1.134-.175 2.31 2.31 0 0 1-1.64-1.055l-.822-1.316a2.192 2.192 0 0 0-1.736-1.039 48.774 48.774 0 0 0-5.232 0 2.192 2.192 0 0 0-1.736 1.039l-.821 1.316Z" />
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M16.5 12.75a4.5 4.5 0 1 1-9 0 4.5 4.5 0 0 1 9 0ZM18.75 10.5h.008v.008h-.008V10.5Z" />
|
||||
</svg>
|
||||
<!-- Badge -->
|
||||
{#if $uploadBadgeCount > 0}
|
||||
<span
|
||||
class="absolute -right-1 -top-1 flex h-5 w-5 items-center justify-center rounded-full bg-red-500 text-[10px] font-bold text-white"
|
||||
>
|
||||
{$uploadBadgeCount > 9 ? '9+' : $uploadBadgeCount}
|
||||
</span>
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Account tab -->
|
||||
<a
|
||||
href="/account"
|
||||
class="flex flex-col items-center gap-0.5 px-4 py-1 text-xs font-medium transition-colors
|
||||
{isActive('/account') ? 'text-blue-600' : 'text-gray-400 hover:text-gray-600'}"
|
||||
aria-label="Konto"
|
||||
>
|
||||
<svg class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M15.75 6a3.75 3.75 0 1 1-7.5 0 3.75 3.75 0 0 1 7.5 0ZM4.501 20.118a7.5 7.5 0 0 1 14.998 0A17.933 17.933 0 0 1 12 21.75c-2.676 0-5.216-.584-7.499-1.632Z" />
|
||||
</svg>
|
||||
<span>Konto</span>
|
||||
</a>
|
||||
</div>
|
||||
</nav>
|
||||
@@ -8,8 +8,8 @@
|
||||
|
||||
let { oncapture, onclose }: Props = $props();
|
||||
|
||||
let videoEl: HTMLVideoElement = $state()!;
|
||||
let canvasEl: HTMLCanvasElement = $state()!;
|
||||
let videoEl: HTMLVideoElement;
|
||||
let canvasEl: HTMLCanvasElement;
|
||||
let stream: MediaStream | null = $state(null);
|
||||
let facingMode = $state<'environment' | 'user'>('environment');
|
||||
let recording = $state(false);
|
||||
|
||||
@@ -6,10 +6,9 @@
|
||||
onlike: (id: string) => void;
|
||||
oncomment: (id: string) => void;
|
||||
onselect: (upload: FeedUpload) => void;
|
||||
threeCol?: boolean;
|
||||
}
|
||||
|
||||
let { uploads, onlike, oncomment, onselect, threeCol = false }: Props = $props();
|
||||
let { uploads, onlike, oncomment, onselect }: Props = $props();
|
||||
|
||||
function isVideo(mime: string): boolean {
|
||||
return mime.startsWith('video/');
|
||||
@@ -22,7 +21,7 @@
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="grid gap-0.5 {threeCol ? 'grid-cols-3' : 'grid-cols-2 sm:grid-cols-3'}">
|
||||
<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
|
||||
|
||||
@@ -1,140 +0,0 @@
|
||||
<script lang="ts">
|
||||
import type { FeedUpload } from '$lib/types';
|
||||
|
||||
interface Props {
|
||||
upload: FeedUpload;
|
||||
onlike: (id: string) => void;
|
||||
oncomment: (id: string) => void;
|
||||
onselect: (upload: FeedUpload) => void;
|
||||
}
|
||||
|
||||
let { upload, onlike, oncomment, onselect }: Props = $props();
|
||||
|
||||
function isVideo(mime: string): boolean {
|
||||
return mime.startsWith('video/');
|
||||
}
|
||||
|
||||
function mediaUrl(u: FeedUpload): string {
|
||||
return u.preview_url ?? u.thumbnail_url ?? '';
|
||||
}
|
||||
|
||||
function relativeTime(iso: string): string {
|
||||
const diff = Date.now() - new Date(iso).getTime();
|
||||
const mins = Math.floor(diff / 60000);
|
||||
if (mins < 1) return 'gerade eben';
|
||||
if (mins < 60) return `vor ${mins} Min.`;
|
||||
const hrs = Math.floor(mins / 60);
|
||||
if (hrs < 24) return `vor ${hrs} Std.`;
|
||||
const days = Math.floor(hrs / 24);
|
||||
return `vor ${days} Tag${days === 1 ? '' : 'en'}`;
|
||||
}
|
||||
|
||||
function initial(name: string): string {
|
||||
return name[0]?.toUpperCase() ?? '?';
|
||||
}
|
||||
|
||||
// Deterministic color from name
|
||||
const COLORS = [
|
||||
'bg-blue-100 text-blue-700',
|
||||
'bg-purple-100 text-purple-700',
|
||||
'bg-green-100 text-green-700',
|
||||
'bg-amber-100 text-amber-700',
|
||||
'bg-rose-100 text-rose-700',
|
||||
'bg-teal-100 text-teal-700',
|
||||
];
|
||||
function avatarColor(name: string): string {
|
||||
let hash = 0;
|
||||
for (const ch of name) hash = (hash * 31 + ch.charCodeAt(0)) & 0xff;
|
||||
return COLORS[hash % COLORS.length];
|
||||
}
|
||||
</script>
|
||||
|
||||
<article class="bg-white">
|
||||
<!-- Uploader row -->
|
||||
<div class="flex items-center gap-3 px-4 py-3">
|
||||
<div
|
||||
class="flex h-9 w-9 shrink-0 items-center justify-center rounded-full text-sm font-bold
|
||||
{avatarColor(upload.uploader_name)}"
|
||||
>
|
||||
{initial(upload.uploader_name)}
|
||||
</div>
|
||||
<div class="min-w-0">
|
||||
<p class="truncate text-sm font-semibold text-gray-900">{upload.uploader_name}</p>
|
||||
<p class="text-xs text-gray-400">{relativeTime(upload.created_at)}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Media -->
|
||||
<button
|
||||
class="block w-full"
|
||||
onclick={() => onselect(upload)}
|
||||
aria-label="Bild vergrößern"
|
||||
>
|
||||
{#if isVideo(upload.mime_type)}
|
||||
<div class="relative aspect-video w-full bg-gray-900">
|
||||
{#if mediaUrl(upload)}
|
||||
<img src={mediaUrl(upload)} alt="" class="h-full w-full object-cover opacity-80" />
|
||||
{/if}
|
||||
<div class="absolute inset-0 flex items-center justify-center">
|
||||
<span class="flex h-14 w-14 items-center justify-center rounded-full bg-black/50 text-white">
|
||||
<svg class="h-7 w-7 pl-0.5" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M8 5v14l11-7z" />
|
||||
</svg>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{:else if mediaUrl(upload)}
|
||||
<img
|
||||
src={mediaUrl(upload)}
|
||||
alt=""
|
||||
class="w-full object-cover"
|
||||
style="max-height: 80svh"
|
||||
loading="lazy"
|
||||
/>
|
||||
{:else}
|
||||
<div class="flex aspect-square w-full items-center justify-center bg-gray-100">
|
||||
<svg class="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="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>
|
||||
|
||||
<!-- Actions row -->
|
||||
<div class="flex items-center gap-4 px-4 py-2">
|
||||
<button
|
||||
onclick={() => onlike(upload.id)}
|
||||
class="flex items-center gap-1.5 text-sm font-medium transition-colors
|
||||
{upload.liked_by_me ? 'text-red-500' : 'text-gray-500 hover:text-red-400'}"
|
||||
>
|
||||
<svg
|
||||
class="h-5 w-5 {upload.liked_by_me ? 'fill-red-500' : ''}"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" 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
|
||||
onclick={() => oncomment(upload.id)}
|
||||
class="flex items-center gap-1.5 text-sm font-medium text-gray-500 transition-colors hover:text-blue-500"
|
||||
>
|
||||
<svg class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" 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>
|
||||
|
||||
<!-- Caption -->
|
||||
{#if upload.caption}
|
||||
<p class="px-4 pb-3 text-sm text-gray-800 [overflow-wrap:anywhere]">
|
||||
{upload.caption}
|
||||
</p>
|
||||
{/if}
|
||||
|
||||
<div class="border-b border-gray-100"></div>
|
||||
</article>
|
||||
@@ -1,85 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { browser } from '$app/environment';
|
||||
|
||||
const GUIDE_SEEN_KEY = 'eventsnap_guide_seen';
|
||||
|
||||
let visible = $state(false);
|
||||
let step = $state(0);
|
||||
|
||||
const steps = [
|
||||
{
|
||||
icon: '📸',
|
||||
title: 'Willkommen bei EventSnap!',
|
||||
body: 'Hier kannst du Fotos und Videos mit allen Gästen teilen — in Echtzeit, ganz ohne App-Store.'
|
||||
},
|
||||
{
|
||||
icon: '⬆️',
|
||||
title: 'Fotos & Videos hochladen',
|
||||
body: 'Tippe auf den Plus-Button unten in der Mitte, um Fotos aus deiner Galerie zu wählen oder direkt mit der Kamera aufzunehmen. Mehrere Dateien auf einmal sind kein Problem!'
|
||||
},
|
||||
{
|
||||
icon: '#️⃣',
|
||||
title: 'Hashtags nutzen',
|
||||
body: 'Füge in deiner Bildunterschrift #hashtags ein, um Fotos zu gruppieren — z.B. #tanz, #buffet oder #reden. Du kannst danach filtern.'
|
||||
},
|
||||
{
|
||||
icon: '🔑',
|
||||
title: 'Deinen PIN merken!',
|
||||
body: 'Du hast beim Registrieren einen 4-stelligen PIN erhalten. Speichere ihn — du brauchst ihn, um dein Konto auf einem anderen Gerät wiederherzustellen. Er ist immer unter „Mein Konto" zu finden.'
|
||||
}
|
||||
];
|
||||
|
||||
if (browser && !localStorage.getItem(GUIDE_SEEN_KEY)) {
|
||||
visible = true;
|
||||
}
|
||||
|
||||
function next() {
|
||||
if (step < steps.length - 1) {
|
||||
step++;
|
||||
} else {
|
||||
dismiss();
|
||||
}
|
||||
}
|
||||
|
||||
function dismiss() {
|
||||
if (browser) localStorage.setItem(GUIDE_SEEN_KEY, '1');
|
||||
visible = false;
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if visible}
|
||||
<!-- Backdrop -->
|
||||
<div class="fixed inset-0 z-50 flex items-end justify-center bg-black/60 sm:items-center">
|
||||
<div class="w-full max-w-sm rounded-t-3xl bg-white p-6 shadow-2xl sm:rounded-2xl">
|
||||
<!-- Step indicator -->
|
||||
<div class="mb-5 flex justify-center gap-1.5">
|
||||
{#each steps as _, i}
|
||||
<div class="h-1.5 rounded-full transition-all {i === step ? 'w-6 bg-blue-600' : 'w-1.5 bg-gray-200'}"></div>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<!-- Content -->
|
||||
<div class="mb-6 text-center">
|
||||
<div class="mb-3 text-5xl">{steps[step].icon}</div>
|
||||
<h2 class="mb-2 text-xl font-bold text-gray-900">{steps[step].title}</h2>
|
||||
<p class="text-sm leading-relaxed text-gray-600">{steps[step].body}</p>
|
||||
</div>
|
||||
|
||||
<!-- Buttons -->
|
||||
<div class="flex gap-2">
|
||||
<button
|
||||
onclick={dismiss}
|
||||
class="flex-1 rounded-xl border border-gray-200 py-3 text-sm text-gray-500 hover:bg-gray-50"
|
||||
>
|
||||
Überspringen
|
||||
</button>
|
||||
<button
|
||||
onclick={next}
|
||||
class="flex-1 rounded-xl bg-blue-600 py-3 text-sm font-semibold text-white hover:bg-blue-700"
|
||||
>
|
||||
{step < steps.length - 1 ? 'Weiter' : 'Los geht\'s!'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
@@ -1,5 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { queueItems, isProcessing, retryItem, removeItem, clearCompleted, rateLimitRetryAt } from '$lib/upload-queue';
|
||||
import { queueItems, isProcessing, retryItem, removeItem, clearCompleted } from '$lib/upload-queue';
|
||||
import type { QueueItem } from '$lib/upload-queue';
|
||||
|
||||
function formatSize(bytes: number): string {
|
||||
@@ -28,25 +28,6 @@
|
||||
|
||||
let items = $derived($queueItems);
|
||||
let hasCompleted = $derived(items.some((i) => i.status === 'done'));
|
||||
|
||||
// Countdown for rate-limit banner
|
||||
let countdown = $state(0);
|
||||
|
||||
$effect(() => {
|
||||
const retryAt = $rateLimitRetryAt;
|
||||
if (!retryAt) {
|
||||
countdown = 0;
|
||||
return;
|
||||
}
|
||||
|
||||
countdown = Math.ceil((retryAt - Date.now()) / 1000);
|
||||
const interval = setInterval(() => {
|
||||
countdown = Math.ceil((retryAt - Date.now()) / 1000);
|
||||
if (countdown <= 0) clearInterval(interval);
|
||||
}, 1000);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
});
|
||||
</script>
|
||||
|
||||
{#if items.length > 0}
|
||||
@@ -68,12 +49,6 @@
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if $rateLimitRetryAt && countdown > 0}
|
||||
<div class="border-b border-amber-100 bg-amber-50 px-4 py-2 text-sm text-amber-800">
|
||||
Upload-Limit erreicht. Wird in {countdown} Sek. automatisch fortgesetzt.
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<ul class="divide-y divide-gray-100">
|
||||
{#each items as item (item.id)}
|
||||
<li class="px-4 py-3">
|
||||
|
||||
@@ -1,135 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { uploadSheetOpen } from '$lib/ui-store';
|
||||
import { pendingFiles } from '$lib/pending-upload-store';
|
||||
import CameraCapture from '$lib/components/CameraCapture.svelte';
|
||||
import type { PendingFile } from '$lib/pending-upload-store';
|
||||
|
||||
let showCamera = $state(false);
|
||||
let fileInput: HTMLInputElement;
|
||||
|
||||
// Keep the sheet and backdrop always in the DOM for smooth CSS transitions.
|
||||
let open = $derived($uploadSheetOpen);
|
||||
|
||||
function close() {
|
||||
uploadSheetOpen.set(false);
|
||||
}
|
||||
|
||||
function openGallery() {
|
||||
fileInput?.click();
|
||||
}
|
||||
|
||||
function openCamera() {
|
||||
showCamera = true;
|
||||
}
|
||||
|
||||
async function handleFiles() {
|
||||
const files = fileInput?.files;
|
||||
if (!files || files.length === 0) return;
|
||||
|
||||
const staged: PendingFile[] = [];
|
||||
for (const file of files) {
|
||||
staged.push({ file, previewUrl: URL.createObjectURL(file) });
|
||||
}
|
||||
pendingFiles.set(staged);
|
||||
fileInput.value = '';
|
||||
close();
|
||||
await goto('/upload');
|
||||
}
|
||||
|
||||
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 });
|
||||
pendingFiles.set([{ file, previewUrl: URL.createObjectURL(file) }]);
|
||||
showCamera = false;
|
||||
close();
|
||||
await goto('/upload');
|
||||
}
|
||||
|
||||
function handleCameraClose() {
|
||||
showCamera = false;
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- Camera (rendered outside sheet so it gets full viewport) -->
|
||||
{#if showCamera}
|
||||
<CameraCapture oncapture={handleCapture} onclose={handleCameraClose} />
|
||||
{/if}
|
||||
|
||||
<!-- Hidden file input -->
|
||||
<input
|
||||
bind:this={fileInput}
|
||||
type="file"
|
||||
accept="image/*,video/*"
|
||||
multiple
|
||||
class="hidden"
|
||||
onchange={handleFiles}
|
||||
/>
|
||||
|
||||
<!-- Backdrop -->
|
||||
<div
|
||||
class="fixed inset-0 z-40 bg-black/50 transition-opacity duration-300"
|
||||
class:opacity-0={!open}
|
||||
class:pointer-events-none={!open}
|
||||
class:opacity-100={open}
|
||||
onclick={close}
|
||||
aria-hidden="true"
|
||||
></div>
|
||||
|
||||
<!-- Sheet -->
|
||||
<div
|
||||
class="fixed inset-x-0 bottom-0 z-50 rounded-t-2xl bg-white transition-transform duration-300"
|
||||
class:translate-y-full={!open}
|
||||
class:translate-y-0={open}
|
||||
style="padding-bottom: env(safe-area-inset-bottom)"
|
||||
>
|
||||
<!-- Drag handle -->
|
||||
<div class="flex justify-center pt-3 pb-1">
|
||||
<div class="h-1 w-10 rounded-full bg-gray-300"></div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-3 px-4 pb-4 pt-2">
|
||||
<!-- Gallery option -->
|
||||
<button
|
||||
onclick={openGallery}
|
||||
class="flex w-full items-center gap-4 rounded-xl bg-gray-50 px-5 py-4 text-left transition hover:bg-gray-100 active:bg-gray-200"
|
||||
>
|
||||
<span class="flex h-11 w-11 items-center justify-center rounded-full bg-blue-100 text-blue-600">
|
||||
<svg class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="m2.25 15.75 5.159-5.159a2.25 2.25 0 0 1 3.182 0l5.159 5.159m-1.5-1.5 1.409-1.409a2.25 2.25 0 0 1 3.182 0l2.909 2.909m-18 3.75h16.5a1.5 1.5 0 0 0 1.5-1.5V6a1.5 1.5 0 0 0-1.5-1.5H3.75A1.5 1.5 0 0 0 2.25 6v12a1.5 1.5 0 0 0 1.5 1.5Zm10.5-11.25h.008v.008h-.008V8.25Zm.375 0a.375.375 0 1 1-.75 0 .375.375 0 0 1 .75 0Z" />
|
||||
</svg>
|
||||
</span>
|
||||
<div>
|
||||
<p class="font-semibold text-gray-900">Galerie</p>
|
||||
<p class="text-sm text-gray-500">Foto oder Video wählen</p>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<!-- Camera option -->
|
||||
<button
|
||||
onclick={openCamera}
|
||||
class="flex w-full items-center gap-4 rounded-xl bg-gray-50 px-5 py-4 text-left transition hover:bg-gray-100 active:bg-gray-200"
|
||||
>
|
||||
<span class="flex h-11 w-11 items-center justify-center rounded-full bg-purple-100 text-purple-600">
|
||||
<svg class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M6.827 6.175A2.31 2.31 0 0 1 5.186 7.23c-.38.054-.757.112-1.134.175C2.999 7.58 2.25 8.507 2.25 9.574V18a2.25 2.25 0 0 0 2.25 2.25h15A2.25 2.25 0 0 0 21.75 18V9.574c0-1.067-.75-1.994-1.802-2.169a47.865 47.865 0 0 0-1.134-.175 2.31 2.31 0 0 1-1.64-1.055l-.822-1.316a2.192 2.192 0 0 0-1.736-1.039 48.774 48.774 0 0 0-5.232 0 2.192 2.192 0 0 0-1.736 1.039l-.821 1.316Z" />
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M16.5 12.75a4.5 4.5 0 1 1-9 0 4.5 4.5 0 0 1 9 0ZM18.75 10.5h.008v.008h-.008V10.5Z" />
|
||||
</svg>
|
||||
</span>
|
||||
<div>
|
||||
<p class="font-semibold text-gray-900">Kamera</p>
|
||||
<p class="text-sm text-gray-500">Jetzt aufnehmen</p>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<!-- Cancel -->
|
||||
<button
|
||||
onclick={close}
|
||||
class="w-full rounded-xl border border-gray-200 py-3 text-sm font-medium text-gray-600 transition hover:bg-gray-50"
|
||||
>
|
||||
Abbrechen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1,17 +0,0 @@
|
||||
import { writable } from 'svelte/store';
|
||||
|
||||
export interface PendingFile {
|
||||
file: File;
|
||||
previewUrl: string; // URL.createObjectURL result — revoke after use
|
||||
}
|
||||
|
||||
export const pendingFiles = writable<PendingFile[]>([]);
|
||||
export const pendingCaption = writable('');
|
||||
|
||||
export function clearPending() {
|
||||
pendingFiles.update((files) => {
|
||||
for (const f of files) URL.revokeObjectURL(f.previewUrl);
|
||||
return [];
|
||||
});
|
||||
pendingCaption.set('');
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
import { writable, derived } from 'svelte/store';
|
||||
import { queueItems } from './upload-queue';
|
||||
|
||||
// Controls BottomNav visibility. Upload page sets this false on mount and restores on destroy.
|
||||
export const showBottomNav = writable(true);
|
||||
|
||||
// Controls the UploadSheet overlay. FAB sets true; sheet sets false.
|
||||
export const uploadSheetOpen = writable(false);
|
||||
|
||||
// Count of items currently pending or uploading — shown as FAB badge.
|
||||
export const uploadBadgeCount = derived(queueItems, ($items) =>
|
||||
$items.filter((i) => i.status === 'pending' || i.status === 'uploading').length
|
||||
);
|
||||
@@ -18,9 +18,6 @@ export interface QueueItem {
|
||||
export const queueItems = writable<QueueItem[]>([]);
|
||||
export const isProcessing = writable(false);
|
||||
|
||||
/** Set to the timestamp (ms) at which the rate-limit lifts, or null when clear. */
|
||||
export const rateLimitRetryAt = writable<number | null>(null);
|
||||
|
||||
const DB_NAME = 'eventsnap-uploads';
|
||||
const STORE_NAME = 'queue';
|
||||
|
||||
@@ -38,14 +35,6 @@ async function getDb(): Promise<IDBPDatabase> {
|
||||
return db;
|
||||
}
|
||||
|
||||
class RateLimitError extends Error {
|
||||
retryAfterSecs: number;
|
||||
constructor(secs: number) {
|
||||
super('rate_limited');
|
||||
this.retryAfterSecs = secs;
|
||||
}
|
||||
}
|
||||
|
||||
export async function loadQueue(): Promise<void> {
|
||||
const database = await getDb();
|
||||
const all = await database.getAll(STORE_NAME);
|
||||
@@ -147,21 +136,7 @@ async function processQueue(): Promise<void> {
|
||||
const next = items.find((item) => item.status === 'pending');
|
||||
if (!next) break;
|
||||
|
||||
try {
|
||||
await uploadItem(next.id);
|
||||
} catch (e) {
|
||||
if (e instanceof RateLimitError) {
|
||||
// Keep all pending items as-is; schedule queue resume when limit lifts
|
||||
const retryAt = Date.now() + e.retryAfterSecs * 1000;
|
||||
rateLimitRetryAt.set(retryAt);
|
||||
setTimeout(() => {
|
||||
rateLimitRetryAt.set(null);
|
||||
processQueue();
|
||||
}, e.retryAfterSecs * 1000);
|
||||
break;
|
||||
}
|
||||
// Other errors are already handled inside uploadItem (marked as 'error')
|
||||
}
|
||||
await uploadItem(next.id);
|
||||
}
|
||||
} finally {
|
||||
processing = false;
|
||||
@@ -173,6 +148,7 @@ async function uploadItem(id: string): Promise<void> {
|
||||
const database = await getDb();
|
||||
const entry = await database.get(STORE_NAME, id);
|
||||
if (!entry || !entry.blob) {
|
||||
// No blob — mark as error
|
||||
updateItemStatus(id, 'error', 'Datei nicht gefunden.');
|
||||
return;
|
||||
}
|
||||
@@ -208,14 +184,6 @@ async function uploadItem(id: string): Promise<void> {
|
||||
xhr.addEventListener('load', () => {
|
||||
if (xhr.status >= 200 && xhr.status < 300) {
|
||||
resolve();
|
||||
} else if (xhr.status === 429) {
|
||||
try {
|
||||
const body = JSON.parse(xhr.responseText);
|
||||
const secs = typeof body.retry_after_secs === 'number' ? body.retry_after_secs : 60;
|
||||
reject(new RateLimitError(secs));
|
||||
} catch {
|
||||
reject(new RateLimitError(60));
|
||||
}
|
||||
} else {
|
||||
try {
|
||||
const body = JSON.parse(xhr.responseText);
|
||||
@@ -237,13 +205,6 @@ async function uploadItem(id: string): Promise<void> {
|
||||
await database.put(STORE_NAME, entry);
|
||||
updateItemStatus(id, 'done');
|
||||
} catch (e) {
|
||||
if (e instanceof RateLimitError) {
|
||||
// Reset to pending so it will be retried when the queue resumes
|
||||
entry.status = 'pending';
|
||||
await database.put(STORE_NAME, entry);
|
||||
updateItemStatus(id, 'pending');
|
||||
throw e; // Propagate to processQueue for scheduling
|
||||
}
|
||||
const msg = e instanceof Error ? e.message : 'Upload fehlgeschlagen.';
|
||||
entry.status = 'error';
|
||||
entry.error = msg;
|
||||
@@ -263,7 +224,7 @@ function updateItemStatus(
|
||||
? {
|
||||
...item,
|
||||
status,
|
||||
progress: status === 'done' ? 100 : status === 'pending' ? 0 : item.progress,
|
||||
progress: status === 'done' ? 100 : status === 'error' ? item.progress : item.progress,
|
||||
error
|
||||
}
|
||||
: item
|
||||
|
||||
@@ -3,22 +3,9 @@
|
||||
import '../app.css';
|
||||
import { initAuth } from '$lib/auth';
|
||||
import { onMount } from 'svelte';
|
||||
import BottomNav from '$lib/components/BottomNav.svelte';
|
||||
import UploadSheet from '$lib/components/UploadSheet.svelte';
|
||||
import { showBottomNav } from '$lib/ui-store';
|
||||
import { isAuthenticated } from '$lib/auth';
|
||||
import { queueItems, isProcessing } from '$lib/upload-queue';
|
||||
|
||||
let { children } = $props();
|
||||
|
||||
// Slim progress bar: ratio of completed items to total, shown while processing.
|
||||
let progressPct = $derived.by(() => {
|
||||
const total = $queueItems.length;
|
||||
if (total === 0) return 0;
|
||||
const done = $queueItems.filter((i) => i.status === 'done').length;
|
||||
return Math.round((done / total) * 100);
|
||||
});
|
||||
|
||||
onMount(() => {
|
||||
initAuth();
|
||||
});
|
||||
@@ -29,23 +16,3 @@
|
||||
</svelte:head>
|
||||
|
||||
{@render children()}
|
||||
|
||||
<!-- Slim upload progress bar — sits just above the bottom nav -->
|
||||
{#if $isProcessing && $isAuthenticated && $showBottomNav}
|
||||
<div
|
||||
class="fixed z-30 h-0.5 bg-gray-200 transition-all"
|
||||
style="bottom: calc(3.5rem + env(safe-area-inset-bottom)); left: 0; right: 0"
|
||||
>
|
||||
<div
|
||||
class="h-full bg-blue-500 transition-all duration-500"
|
||||
style="width: {progressPct}%"
|
||||
></div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- UploadSheet is always mounted for smooth enter/exit animation -->
|
||||
<UploadSheet />
|
||||
|
||||
{#if $showBottomNav && $isAuthenticated}
|
||||
<BottomNav />
|
||||
{/if}
|
||||
|
||||
@@ -1,223 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { getToken, getPin, getDisplayName, getExpiry, getRole, clearAuth } from '$lib/auth';
|
||||
import { api } from '$lib/api';
|
||||
import { onMount } from 'svelte';
|
||||
|
||||
let pin = $state<string | null>(null);
|
||||
let displayName = $state<string | null>(null);
|
||||
let role = $state<'guest' | 'host' | 'admin' | null>(null);
|
||||
let expiry = $state<Date | null>(null);
|
||||
let pinCopied = $state(false);
|
||||
let leaveConfirmOpen = $state(false);
|
||||
|
||||
onMount(() => {
|
||||
if (!getToken()) {
|
||||
goto('/join');
|
||||
return;
|
||||
}
|
||||
pin = getPin();
|
||||
displayName = getDisplayName();
|
||||
role = getRole();
|
||||
expiry = getExpiry();
|
||||
});
|
||||
|
||||
function copyPin() {
|
||||
if (!pin) return;
|
||||
navigator.clipboard.writeText(pin);
|
||||
pinCopied = true;
|
||||
setTimeout(() => (pinCopied = false), 2000);
|
||||
}
|
||||
|
||||
async function handleLogout() {
|
||||
try { await api.delete('/session'); } catch { /* ignore */ }
|
||||
clearAuth();
|
||||
goto('/join');
|
||||
}
|
||||
|
||||
function formatDate(d: Date): string {
|
||||
return d.toLocaleDateString('de-DE', { day: '2-digit', month: 'long', year: 'numeric' });
|
||||
}
|
||||
|
||||
function roleLabel(r: string | null): string {
|
||||
switch (r) {
|
||||
case 'admin': return 'Admin';
|
||||
case 'host': return 'Gastgeber';
|
||||
default: return 'Gast';
|
||||
}
|
||||
}
|
||||
|
||||
function roleColor(r: string | null): string {
|
||||
switch (r) {
|
||||
case 'admin': return 'bg-red-100 text-red-700';
|
||||
case 'host': return 'bg-purple-100 text-purple-700';
|
||||
default: return 'bg-blue-100 text-blue-700';
|
||||
}
|
||||
}
|
||||
|
||||
function avatarColor(name: string | null): string {
|
||||
if (!name) return 'bg-gray-100 text-gray-500';
|
||||
const COLORS = ['bg-blue-100 text-blue-700','bg-purple-100 text-purple-700','bg-green-100 text-green-700','bg-amber-100 text-amber-700','bg-rose-100 text-rose-700'];
|
||||
let hash = 0;
|
||||
for (const ch of name) hash = (hash * 31 + ch.charCodeAt(0)) & 0xff;
|
||||
return COLORS[hash % COLORS.length];
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="min-h-screen bg-gray-50 pb-24">
|
||||
<!-- Header -->
|
||||
<div class="border-b border-gray-200 bg-white">
|
||||
<div class="mx-auto flex max-w-lg items-center px-4 py-4">
|
||||
<h1 class="text-xl font-bold text-gray-900">Mein Konto</h1>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mx-auto max-w-lg space-y-3 p-4">
|
||||
<!-- Profile card -->
|
||||
<div class="rounded-xl border border-gray-200 bg-white p-5">
|
||||
<div class="flex items-center gap-4">
|
||||
<div
|
||||
class="flex h-14 w-14 shrink-0 items-center justify-center rounded-full text-xl font-bold
|
||||
{avatarColor(displayName)}"
|
||||
>
|
||||
{displayName ? displayName[0].toUpperCase() : '?'}
|
||||
</div>
|
||||
<div class="min-w-0">
|
||||
<p class="truncate text-lg font-bold text-gray-900">{displayName ?? 'Unbekannt'}</p>
|
||||
<span class="mt-0.5 inline-block rounded-full px-2.5 py-0.5 text-xs font-semibold {roleColor(role)}">
|
||||
{roleLabel(role)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{#if expiry}
|
||||
<p class="mt-3 text-xs text-gray-400">Sitzung gültig bis {formatDate(expiry)}</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Dashboards section (host + admin only) -->
|
||||
{#if role === 'host' || role === 'admin'}
|
||||
<div class="overflow-hidden rounded-xl border border-gray-200 bg-white">
|
||||
<div class="border-b border-gray-100 px-5 py-3">
|
||||
<h2 class="text-xs font-semibold uppercase tracking-wide text-gray-500">Dashboards</h2>
|
||||
</div>
|
||||
<a
|
||||
href="/host"
|
||||
class="flex items-center gap-3 px-5 py-4 transition hover:bg-gray-50"
|
||||
>
|
||||
<!-- Star icon -->
|
||||
<svg class="h-5 w-5 text-amber-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M11.48 3.499a.562.562 0 011.04 0l2.125 5.111a.563.563 0 00.475.345l5.518.442c.499.04.701.663.321.988l-4.204 3.602a.563.563 0 00-.182.557l1.285 5.385a.562.562 0 01-.84.61l-4.725-2.885a.562.562 0 00-.586 0L6.982 20.54a.562.562 0 01-.84-.61l1.285-5.386a.562.562 0 00-.182-.557l-4.204-3.602a.562.562 0 01.321-.988l5.518-.442a.563.563 0 00.475-.345L11.48 3.5z" />
|
||||
</svg>
|
||||
<span class="flex-1 font-medium text-gray-900">Host-Dashboard</span>
|
||||
<svg class="h-4 w-4 text-gray-300" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M8.25 4.5l7.5 7.5-7.5 7.5" />
|
||||
</svg>
|
||||
</a>
|
||||
{#if role === 'admin'}
|
||||
<a
|
||||
href="/admin"
|
||||
class="flex items-center gap-3 border-t border-gray-100 px-5 py-4 transition hover:bg-gray-50"
|
||||
>
|
||||
<!-- Shield icon -->
|
||||
<svg class="h-5 w-5 text-blue-600" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M9 12.75L11.25 15 15 9.75m-3-7.036A11.959 11.959 0 013.598 6 11.99 11.99 0 003 9.749c0 5.592 3.824 10.29 9 11.623 5.176-1.332 9-6.03 9-11.622 0-1.31-.21-2.571-.598-3.751h-.152c-3.196 0-6.1-1.248-8.25-3.285z" />
|
||||
</svg>
|
||||
<span class="flex-1 font-medium text-gray-900">Admin-Dashboard</span>
|
||||
<svg class="h-4 w-4 text-gray-300" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M8.25 4.5l7.5 7.5-7.5 7.5" />
|
||||
</svg>
|
||||
</a>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- PIN card -->
|
||||
<div class="rounded-xl border border-amber-200 bg-amber-50 p-5">
|
||||
<h2 class="mb-1 font-semibold text-amber-900">Wiederherstellungs-PIN</h2>
|
||||
<p class="mb-3 text-sm text-amber-700">
|
||||
Du brauchst diesen PIN, um dein Konto auf einem anderen Gerät wiederherzustellen. Schreib ihn auf!
|
||||
</p>
|
||||
{#if pin}
|
||||
<div class="flex items-center justify-between rounded-lg bg-white px-4 py-3 shadow-sm">
|
||||
<span class="font-mono text-4xl font-bold tracking-widest text-gray-900">{pin}</span>
|
||||
<button
|
||||
onclick={copyPin}
|
||||
class="rounded-md bg-amber-100 px-3 py-1.5 text-sm font-medium text-amber-800 transition hover:bg-amber-200"
|
||||
>
|
||||
{pinCopied ? 'Kopiert!' : 'Kopieren'}
|
||||
</button>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="rounded-lg bg-white px-4 py-3 text-sm text-gray-400 shadow-sm">
|
||||
PIN nicht gespeichert. Nutze die Wiederherstellungs-Seite, um dich mit deinem PIN anzumelden.
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Konto section -->
|
||||
<div class="overflow-hidden rounded-xl border border-gray-200 bg-white">
|
||||
<div class="border-b border-gray-100 px-5 py-3">
|
||||
<h2 class="text-xs font-semibold uppercase tracking-wide text-gray-500">Konto</h2>
|
||||
</div>
|
||||
|
||||
<!-- Recover / device switch -->
|
||||
<a
|
||||
href="/recover"
|
||||
class="flex items-center gap-3 px-5 py-4 transition hover:bg-gray-50"
|
||||
>
|
||||
<svg class="h-5 w-5 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M10.5 1.5H8.25A2.25 2.25 0 006 3.75v16.5a2.25 2.25 0 002.25 2.25h7.5A2.25 2.25 0 0018 20.25V3.75a2.25 2.25 0 00-2.25-2.25H13.5m-3 0V3h3V1.5m-3 0h3m-3 8.25h3m-3 3h3m-3 3h3" />
|
||||
</svg>
|
||||
<span class="flex-1 text-sm font-medium text-gray-700">Gerät wechseln / PIN nutzen</span>
|
||||
<svg class="h-4 w-4 text-gray-300" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M8.25 4.5l7.5 7.5-7.5 7.5" />
|
||||
</svg>
|
||||
</a>
|
||||
|
||||
<!-- Leave / logout -->
|
||||
<button
|
||||
onclick={() => (leaveConfirmOpen = true)}
|
||||
class="flex w-full items-center gap-3 border-t border-gray-100 px-5 py-4 text-left transition hover:bg-red-50"
|
||||
>
|
||||
<svg class="h-5 w-5 text-red-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M15.75 9V5.25A2.25 2.25 0 0013.5 3h-6a2.25 2.25 0 00-2.25 2.25v13.5A2.25 2.25 0 007.5 21h6a2.25 2.25 0 002.25-2.25V15m3 0l3-3m0 0l-3-3m3 3H9" />
|
||||
</svg>
|
||||
<span class="flex-1 text-sm font-medium text-red-600">Event verlassen</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Leave-confirm bottom sheet -->
|
||||
{#if leaveConfirmOpen}
|
||||
<div class="fixed inset-0 z-50 flex items-end bg-black/40" onclick={() => (leaveConfirmOpen = false)} aria-hidden="true">
|
||||
<div
|
||||
class="w-full rounded-t-2xl bg-white px-5 pb-10 pt-6"
|
||||
onclick={(e) => e.stopPropagation()}
|
||||
onkeydown={(e) => e.stopPropagation()}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
tabindex="-1"
|
||||
>
|
||||
<div class="mb-4 flex justify-center">
|
||||
<div class="h-1 w-10 rounded-full bg-gray-300"></div>
|
||||
</div>
|
||||
<h3 class="mb-1 text-center text-lg font-bold text-gray-900">Event verlassen?</h3>
|
||||
<p class="mb-6 text-center text-sm text-gray-500">
|
||||
Du wirst abgemeldet. Mit deinem PIN kannst du jederzeit zurückkehren.
|
||||
</p>
|
||||
<button
|
||||
onclick={handleLogout}
|
||||
class="mb-3 w-full rounded-xl bg-red-600 py-3 text-sm font-semibold text-white transition hover:bg-red-700"
|
||||
>
|
||||
Abmelden
|
||||
</button>
|
||||
<button
|
||||
onclick={() => (leaveConfirmOpen = false)}
|
||||
class="w-full rounded-xl border border-gray-200 py-3 text-sm font-medium text-gray-700 transition hover:bg-gray-50"
|
||||
>
|
||||
Abbrechen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
@@ -1,502 +0,0 @@
|
||||
<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;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
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 (0–1)',
|
||||
estimated_guest_count: 'Geschätzte Gästezahl',
|
||||
compression_concurrency: 'Kompressions-Worker'
|
||||
};
|
||||
|
||||
type AdminTab = 'stats' | 'config' | 'export' | 'users';
|
||||
const TAB_LABELS: Record<AdminTab, string> = { stats: 'Stats', config: 'Config', export: 'Export', users: 'Nutzer' };
|
||||
|
||||
let activeTab = $state<AdminTab>('stats');
|
||||
|
||||
let stats = $state<StatsDto | null>(null);
|
||||
let config = $state<Record<string, string>>({});
|
||||
let configDraft = $state<Record<string, string>>({});
|
||||
let exportJobs = $state<ExportJob[]>([]);
|
||||
let users = $state<UserSummary[]>([]);
|
||||
let loading = $state(true);
|
||||
let saving = $state(false);
|
||||
let error = $state<string | null>(null);
|
||||
let toast = $state<string | null>(null);
|
||||
let exportJobsRefreshing = $state(false);
|
||||
|
||||
// Nutzer tab state
|
||||
let userSearch = $state('');
|
||||
let filteredUsers = $derived(
|
||||
userSearch.trim()
|
||||
? users.filter((u) => u.display_name.toLowerCase().includes(userSearch.toLowerCase()))
|
||||
: users
|
||||
);
|
||||
|
||||
// Ban modal state
|
||||
let banTarget = $state<UserSummary | null>(null);
|
||||
let banHideUploads = $state(false);
|
||||
let banSubmitting = $state(false);
|
||||
|
||||
const myRole = getRole();
|
||||
|
||||
onMount(async () => {
|
||||
const token = getToken();
|
||||
const role = getRole();
|
||||
if (!token || role !== 'admin') {
|
||||
goto('/admin/login');
|
||||
return;
|
||||
}
|
||||
await reload();
|
||||
});
|
||||
|
||||
async function reload() {
|
||||
loading = true;
|
||||
error = null;
|
||||
try {
|
||||
[stats, config, exportJobs, users] = await Promise.all([
|
||||
api.get<StatsDto>('/admin/stats'),
|
||||
api.get<Record<string, string>>('/admin/config'),
|
||||
api.get<ExportJob[]>('/admin/export/jobs'),
|
||||
api.get<UserSummary[]>('/host/users')
|
||||
]);
|
||||
configDraft = { ...config };
|
||||
} catch (e: unknown) {
|
||||
error = e instanceof Error ? e.message : 'Fehler beim Laden.';
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshExportJobs() {
|
||||
exportJobsRefreshing = true;
|
||||
try {
|
||||
exportJobs = await api.get<ExportJob[]>('/admin/export/jobs');
|
||||
} finally {
|
||||
exportJobsRefreshing = false;
|
||||
}
|
||||
}
|
||||
|
||||
function showToast(msg: string) {
|
||||
toast = msg;
|
||||
setTimeout(() => (toast = null), 3000);
|
||||
}
|
||||
|
||||
async function saveConfig() {
|
||||
saving = true;
|
||||
try {
|
||||
const changes: Record<string, string> = {};
|
||||
for (const key of Object.keys(configDraft)) {
|
||||
if (configDraft[key] !== config[key]) {
|
||||
changes[key] = String(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;
|
||||
}
|
||||
}
|
||||
|
||||
async function releaseGallery() {
|
||||
try {
|
||||
await api.post('/host/gallery/release');
|
||||
showToast('Galerie wurde freigegeben. Export wird vorbereitet…');
|
||||
} 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;
|
||||
users = await api.get<UserSummary[]>('/host/users');
|
||||
} 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.`);
|
||||
users = await api.get<UserSummary[]>('/host/users');
|
||||
} 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.`);
|
||||
users = await api.get<UserSummary[]>('/host/users');
|
||||
} 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.`);
|
||||
users = await api.get<UserSummary[]>('/host/users');
|
||||
} catch (e: unknown) {
|
||||
showToast(e instanceof Error ? e.message : 'Fehler.');
|
||||
}
|
||||
}
|
||||
|
||||
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(s: StatsDto): number {
|
||||
if (s.disk_total_bytes === 0) return 0;
|
||||
return Math.round((s.disk_used_bytes / s.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>
|
||||
|
||||
<!-- 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-24 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 pb-24">
|
||||
<!-- Header -->
|
||||
<div class="border-b border-gray-200 bg-white">
|
||||
<div class="mx-auto flex max-w-3xl items-center gap-3 px-4 py-4">
|
||||
<button
|
||||
onclick={() => goto('/account')}
|
||||
class="flex h-9 w-9 shrink-0 items-center justify-center rounded-full text-gray-500 transition hover:bg-gray-100"
|
||||
aria-label="Zurück"
|
||||
>
|
||||
<svg class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M10.5 19.5L3 12m0 0l7.5-7.5M3 12h18" />
|
||||
</svg>
|
||||
</button>
|
||||
<h1 class="text-xl font-bold text-gray-900">Admin-Dashboard</h1>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Inner tab bar -->
|
||||
<div class="sticky top-0 z-20 overflow-x-auto border-b border-gray-200 bg-white">
|
||||
<div class="mx-auto flex max-w-3xl min-w-max">
|
||||
{#each Object.entries(TAB_LABELS) as [tab, label]}
|
||||
<button
|
||||
onclick={() => (activeTab = tab as AdminTab)}
|
||||
class="px-5 py-3 text-sm font-medium whitespace-nowrap border-b-2 transition-colors
|
||||
{activeTab === tab ? 'border-blue-600 text-blue-600' : 'border-transparent text-gray-500 hover:text-gray-700'}"
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mx-auto max-w-3xl 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 tab ────────────────────────────────────────────────── -->
|
||||
{#if activeTab === 'stats'}
|
||||
<div class="space-y-3">
|
||||
{#if stats}
|
||||
<div class="grid grid-cols-2 gap-3 sm:grid-cols-4">
|
||||
<div class="rounded-xl bg-white border border-gray-200 p-4 text-center">
|
||||
<p class="text-3xl font-bold text-gray-900">{stats.user_count}</p>
|
||||
<p class="mt-0.5 text-xs text-gray-500">Gäste</p>
|
||||
</div>
|
||||
<div class="rounded-xl bg-white border border-gray-200 p-4 text-center">
|
||||
<p class="text-3xl font-bold text-gray-900">{stats.upload_count}</p>
|
||||
<p class="mt-0.5 text-xs text-gray-500">Uploads</p>
|
||||
</div>
|
||||
<div class="rounded-xl bg-white border border-gray-200 p-4 text-center">
|
||||
<p class="text-3xl font-bold text-gray-900">{stats.comment_count}</p>
|
||||
<p class="mt-0.5 text-xs text-gray-500">Kommentare</p>
|
||||
</div>
|
||||
<div class="rounded-xl bg-white border border-gray-200 p-4 text-center">
|
||||
<p class="text-3xl font-bold text-gray-900">{diskPct(stats)} %</p>
|
||||
<p class="mt-0.5 text-xs text-gray-500">Speicher</p>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Disk bar -->
|
||||
<div class="rounded-xl border border-gray-200 bg-white p-5">
|
||||
<div class="mb-1 flex items-center justify-between text-xs text-gray-500">
|
||||
<span>Speicherauslastung</span>
|
||||
<span>{formatBytes(stats.disk_used_bytes)} / {formatBytes(stats.disk_total_bytes)}</span>
|
||||
</div>
|
||||
<div class="h-2.5 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.5 text-xs text-gray-400">{formatBytes(stats.disk_free_bytes)} frei</p>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- ── Config tab ───────────────────────────────────────────────── -->
|
||||
{:else if activeTab === 'config'}
|
||||
<div class="relative">
|
||||
<div class="space-y-3 rounded-xl border border-gray-200 bg-white p-5 pb-20">
|
||||
{#each Object.entries(CONFIG_LABELS) as [key, label]}
|
||||
<div>
|
||||
<label for={key} class="mb-1 block text-sm font-medium text-gray-700">{label}</label>
|
||||
<input
|
||||
id={key}
|
||||
type="number"
|
||||
step="any"
|
||||
bind:value={configDraft[key]}
|
||||
class="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-200"
|
||||
/>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
<!-- Sticky save button -->
|
||||
<div class="sticky bottom-0 border-t border-gray-100 bg-white px-5 py-3">
|
||||
<button
|
||||
onclick={saveConfig}
|
||||
disabled={saving}
|
||||
class="w-full rounded-xl bg-blue-600 py-3 text-sm font-semibold text-white transition hover:bg-blue-700 disabled:opacity-50"
|
||||
>
|
||||
{saving ? 'Wird gespeichert…' : 'Speichern'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── Export tab ───────────────────────────────────────────────── -->
|
||||
{:else if activeTab === 'export'}
|
||||
<div class="space-y-3">
|
||||
<!-- Gallery release -->
|
||||
<div class="rounded-xl border border-gray-200 bg-white p-5">
|
||||
<h3 class="mb-3 font-semibold text-gray-900">Galerie</h3>
|
||||
<button
|
||||
onclick={releaseGallery}
|
||||
class="rounded-lg bg-blue-600 px-4 py-2 text-sm font-medium text-white transition hover:bg-blue-700"
|
||||
>
|
||||
Galerie freigeben
|
||||
</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">
|
||||
<h3 class="font-semibold text-gray-900">Export-Jobs</h3>
|
||||
<button
|
||||
onclick={refreshExportJobs}
|
||||
disabled={exportJobsRefreshing}
|
||||
class="text-xs text-blue-600 hover:underline disabled:opacity-50"
|
||||
>
|
||||
{exportJobsRefreshing ? 'Lädt…' : '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>
|
||||
</div>
|
||||
|
||||
<!-- ── Nutzer tab ───────────────────────────────────────────────── -->
|
||||
{:else if activeTab === 'users'}
|
||||
<div class="overflow-hidden rounded-xl border border-gray-200 bg-white">
|
||||
<!-- Search -->
|
||||
<div class="p-4">
|
||||
<div class="flex items-center gap-2 rounded-lg border border-gray-200 bg-gray-50 px-3 py-2">
|
||||
<svg class="h-4 w-4 shrink-0 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M21 21l-5.197-5.197m0 0A7.5 7.5 0 105.196 5.196a7.5 7.5 0 0010.607 10.607z" />
|
||||
</svg>
|
||||
<input
|
||||
type="search"
|
||||
placeholder="Nutzer suchen…"
|
||||
bind:value={userSearch}
|
||||
class="min-w-0 flex-1 bg-transparent text-sm text-gray-900 placeholder-gray-400 outline-none"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{#if filteredUsers.length === 0}
|
||||
<p class="px-5 py-8 text-center text-sm text-gray-400">Keine Treffer.</p>
|
||||
{:else}
|
||||
<div class="divide-y divide-gray-100">
|
||||
{#each filteredUsers as user}
|
||||
<div class="flex items-center gap-3 px-5 py-3">
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="flex flex-wrap items-center gap-1.5">
|
||||
<span class="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'}
|
||||
<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}
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
@@ -1,68 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { api, ApiError } from '$lib/api';
|
||||
import { setAuth, getRole } from '$lib/auth';
|
||||
import { browser } from '$app/environment';
|
||||
|
||||
// Already logged in as admin → go straight to dashboard
|
||||
if (browser && getRole() === 'admin') {
|
||||
goto('/admin');
|
||||
}
|
||||
|
||||
let password = $state('');
|
||||
let error = $state('');
|
||||
let loading = $state(false);
|
||||
|
||||
async function handleLogin() {
|
||||
if (!password) return;
|
||||
loading = true;
|
||||
error = '';
|
||||
try {
|
||||
const res = await api.post<{ jwt: string }>('/admin/login', { password });
|
||||
// Admin sessions have no PIN; pass null so setAuth doesn't overwrite a guest PIN
|
||||
setAuth(res.jwt, null, '');
|
||||
goto('/admin');
|
||||
} catch (e) {
|
||||
if (e instanceof ApiError) {
|
||||
error = e.message;
|
||||
} else {
|
||||
error = 'Ein Fehler ist aufgetreten.';
|
||||
}
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="flex min-h-screen items-center justify-center bg-gray-50 px-4">
|
||||
<div class="w-full max-w-sm">
|
||||
<h1 class="mb-2 text-center text-2xl font-bold text-gray-900">Admin-Login</h1>
|
||||
<p class="mb-6 text-center text-gray-500 text-sm">Nur für Veranstalter</p>
|
||||
|
||||
<form onsubmit={(e) => { e.preventDefault(); handleLogin(); }}>
|
||||
<input
|
||||
type="password"
|
||||
bind:value={password}
|
||||
placeholder="Passwort"
|
||||
autocomplete="current-password"
|
||||
class="mb-3 w-full rounded-lg border border-gray-300 px-4 py-3 text-lg focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-200"
|
||||
/>
|
||||
|
||||
{#if error}
|
||||
<p class="mb-3 text-sm text-red-600">{error}</p>
|
||||
{/if}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading || !password}
|
||||
class="w-full rounded-lg bg-blue-600 px-4 py-3 text-lg font-medium text-white transition hover:bg-blue-700 disabled:opacity-50"
|
||||
>
|
||||
{loading ? 'Wird angemeldet…' : 'Anmelden'}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<p class="mt-4 text-center text-sm text-gray-500">
|
||||
<a href="/join" class="text-blue-600 hover:underline">Zurück zum Event</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1,216 +0,0 @@
|
||||
<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;
|
||||
}
|
||||
|
||||
const HTML_GUIDE_KEY = 'eventsnap_html_guide_seen';
|
||||
|
||||
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';
|
||||
}
|
||||
}
|
||||
|
||||
async function downloadFile(endpoint: string, filename: string) {
|
||||
const token = getToken();
|
||||
const res = await fetch(endpoint, {
|
||||
headers: token ? { Authorization: `Bearer ${token}` } : {}
|
||||
});
|
||||
if (!res.ok) return;
|
||||
const blob = await res.blob();
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = filename;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
function downloadZip() {
|
||||
downloadFile('/api/v1/export/zip', 'Gallery.zip');
|
||||
}
|
||||
|
||||
function downloadHtml() {
|
||||
if (localStorage.getItem(HTML_GUIDE_KEY)) {
|
||||
downloadFile('/api/v1/export/html', 'Memories.zip');
|
||||
} else {
|
||||
showHtmlGuide = true;
|
||||
}
|
||||
}
|
||||
|
||||
function confirmHtmlDownload() {
|
||||
localStorage.setItem(HTML_GUIDE_KEY, '1');
|
||||
showHtmlGuide = false;
|
||||
downloadFile('/api/v1/export/html', 'Memories.zip');
|
||||
}
|
||||
</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>index.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 pb-24">
|
||||
<div class="border-b border-gray-200 bg-white">
|
||||
<div class="mx-auto flex max-w-lg items-center px-4 py-4">
|
||||
<h1 class="text-xl font-bold text-gray-900">Export</h1>
|
||||
</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>
|
||||
@@ -1,14 +1,12 @@
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { getToken } from '$lib/auth';
|
||||
import { getToken, clearAuth } from '$lib/auth';
|
||||
import { api } from '$lib/api';
|
||||
import { connectSse, disconnectSse, onSseEvent } from '$lib/sse';
|
||||
import { onMount, onDestroy } from 'svelte';
|
||||
import FeedGrid from '$lib/components/FeedGrid.svelte';
|
||||
import FeedListCard from '$lib/components/FeedListCard.svelte';
|
||||
import HashtagChips from '$lib/components/HashtagChips.svelte';
|
||||
import LightboxModal from '$lib/components/LightboxModal.svelte';
|
||||
import OnboardingGuide from '$lib/components/OnboardingGuide.svelte';
|
||||
import type { FeedUpload, FeedResponse, HashtagCount } from '$lib/types';
|
||||
|
||||
let uploads = $state<FeedUpload[]>([]);
|
||||
@@ -19,75 +17,8 @@
|
||||
let selectedUpload = $state<FeedUpload | null>(null);
|
||||
let sentinel: HTMLDivElement;
|
||||
|
||||
// View mode
|
||||
let viewMode = $state<'list' | 'grid'>('list');
|
||||
|
||||
// Grid search / filter state
|
||||
let searchQuery = $state('');
|
||||
let showAutocomplete = $state(false);
|
||||
|
||||
interface Filter { type: 'tag' | 'user'; value: string }
|
||||
let activeFilters = $state<Filter[]>([]);
|
||||
|
||||
let unsubscribers: (() => void)[] = [];
|
||||
|
||||
// ── Autocomplete derived from loaded uploads (no extra API calls) ────────
|
||||
let allTags = $derived.by(() => {
|
||||
const freq = new Map<string, number>();
|
||||
for (const u of uploads) {
|
||||
for (const m of (u.caption ?? '').matchAll(/#(\w+)/g)) {
|
||||
const t = m[1].toLowerCase();
|
||||
freq.set(t, (freq.get(t) ?? 0) + 1);
|
||||
}
|
||||
}
|
||||
return [...freq.entries()].sort((a, b) => b[1] - a[1]).map(([t]) => t);
|
||||
});
|
||||
|
||||
let allUploaders = $derived([...new Set(uploads.map((u) => u.uploader_name))].sort());
|
||||
|
||||
let suggestions = $derived.by((): Filter[] => {
|
||||
const q = searchQuery.trim();
|
||||
if (!q) {
|
||||
// Show top suggestions on focus
|
||||
if (!showAutocomplete) return [];
|
||||
return [
|
||||
...allUploaders.slice(0, 3).map((u) => ({ type: 'user' as const, value: u })),
|
||||
...allTags.slice(0, 3).map((t) => ({ type: 'tag' as const, value: t })),
|
||||
];
|
||||
}
|
||||
if (q.startsWith('#')) {
|
||||
const prefix = q.slice(1).toLowerCase();
|
||||
return allTags
|
||||
.filter((t) => t.startsWith(prefix))
|
||||
.slice(0, 8)
|
||||
.map((t) => ({ type: 'tag' as const, value: t }));
|
||||
}
|
||||
const lower = q.toLowerCase();
|
||||
return [
|
||||
...allUploaders
|
||||
.filter((u) => u.toLowerCase().includes(lower))
|
||||
.slice(0, 4)
|
||||
.map((u) => ({ type: 'user' as const, value: u })),
|
||||
...allTags
|
||||
.filter((t) => t.includes(lower))
|
||||
.slice(0, 4)
|
||||
.map((t) => ({ type: 'tag' as const, value: t })),
|
||||
];
|
||||
});
|
||||
|
||||
// ── Filtered uploads for grid view ───────────────────────────────────────
|
||||
let displayUploads = $derived.by(() => {
|
||||
if (viewMode === 'list' || activeFilters.length === 0) return uploads;
|
||||
const tags = activeFilters.filter((f) => f.type === 'tag').map((f) => f.value);
|
||||
const users = activeFilters.filter((f) => f.type === 'user').map((f) => f.value);
|
||||
return uploads.filter((u) => {
|
||||
const cap = (u.caption ?? '').toLowerCase();
|
||||
const passTag = !tags.length || tags.some((t) => cap.includes('#' + t));
|
||||
const passUser = !users.length || users.includes(u.uploader_name);
|
||||
return passTag && passUser;
|
||||
});
|
||||
});
|
||||
|
||||
onMount(async () => {
|
||||
if (!getToken()) {
|
||||
goto('/join');
|
||||
@@ -104,15 +35,25 @@
|
||||
uploads = [upload, ...uploads];
|
||||
} catch { /* ignore */ }
|
||||
}),
|
||||
onSseEvent('upload-processed', () => loadFeed(true)),
|
||||
onSseEvent('like-update', () => loadFeed(true)),
|
||||
onSseEvent('new-comment', () => loadFeed(true))
|
||||
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();
|
||||
if (entries[0].isIntersecting && nextCursor && !loadingMore) {
|
||||
loadMore();
|
||||
}
|
||||
},
|
||||
{ rootMargin: '200px' }
|
||||
);
|
||||
@@ -131,10 +72,18 @@
|
||||
if (!refresh && nextCursor) params.set('cursor', nextCursor);
|
||||
if (selectedHashtag) params.set('hashtag', selectedHashtag);
|
||||
params.set('limit', '20');
|
||||
|
||||
const res = await api.get<FeedResponse>(`/feed?${params}`);
|
||||
uploads = res.uploads;
|
||||
|
||||
if (refresh) {
|
||||
uploads = res.uploads;
|
||||
} else {
|
||||
uploads = res.uploads;
|
||||
}
|
||||
nextCursor = res.next_cursor;
|
||||
} catch { /* ignore */ }
|
||||
} catch {
|
||||
// Ignore
|
||||
}
|
||||
}
|
||||
|
||||
async function loadMore() {
|
||||
@@ -145,10 +94,13 @@
|
||||
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 {
|
||||
} catch {
|
||||
// Ignore
|
||||
} finally {
|
||||
loadingMore = false;
|
||||
}
|
||||
}
|
||||
@@ -156,7 +108,9 @@
|
||||
async function loadHashtags() {
|
||||
try {
|
||||
hashtags = await api.get<HashtagCount[]>('/hashtags');
|
||||
} catch { /* ignore */ }
|
||||
} catch {
|
||||
// Ignore
|
||||
}
|
||||
}
|
||||
|
||||
function selectHashtag(tag: string | null) {
|
||||
@@ -168,19 +122,29 @@
|
||||
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,
|
||||
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,
|
||||
like_count: selectedUpload.liked_by_me
|
||||
? selectedUpload.like_count - 1
|
||||
: selectedUpload.like_count + 1
|
||||
};
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
} catch {
|
||||
// Ignore
|
||||
}
|
||||
}
|
||||
|
||||
function openComments(id: string) {
|
||||
@@ -188,187 +152,62 @@
|
||||
if (u) selectedUpload = u;
|
||||
}
|
||||
|
||||
function selectSuggestion(item: Filter) {
|
||||
if (!activeFilters.some((f) => f.type === item.type && f.value === item.value)) {
|
||||
activeFilters = [...activeFilters, item];
|
||||
}
|
||||
searchQuery = '';
|
||||
showAutocomplete = false;
|
||||
}
|
||||
|
||||
function removeFilter(item: Filter) {
|
||||
activeFilters = activeFilters.filter((f) => !(f.type === item.type && f.value === item.value));
|
||||
}
|
||||
|
||||
function clearFilters() {
|
||||
activeFilters = [];
|
||||
searchQuery = '';
|
||||
}
|
||||
|
||||
function switchView(mode: 'list' | 'grid') {
|
||||
viewMode = mode;
|
||||
if (mode === 'list') {
|
||||
searchQuery = '';
|
||||
showAutocomplete = false;
|
||||
}
|
||||
async function handleLogout() {
|
||||
try { await api.delete('/session'); } catch { /* ignore */ }
|
||||
clearAuth();
|
||||
goto('/join');
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="min-h-screen bg-gray-50 pb-24">
|
||||
<!-- Sticky header -->
|
||||
<div class="sticky top-0 z-30 border-b border-gray-200 bg-white/95 backdrop-blur">
|
||||
<div class="min-h-screen bg-gray-50">
|
||||
<!-- Header -->
|
||||
<div class="sticky top-0 z-40 border-b border-gray-200 bg-white/95 backdrop-blur">
|
||||
<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>
|
||||
|
||||
<!-- List / Grid toggle -->
|
||||
<div class="flex items-center gap-1 rounded-lg bg-gray-100 p-1">
|
||||
<button
|
||||
onclick={() => switchView('list')}
|
||||
class="rounded-md p-1.5 transition-colors {viewMode === 'list' ? 'bg-white text-gray-900 shadow-sm' : 'text-gray-400 hover:text-gray-600'}"
|
||||
aria-label="Listenansicht"
|
||||
<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"
|
||||
>
|
||||
<!-- bars-3 -->
|
||||
<svg class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M3.75 6.75h16.5M3.75 12h16.5m-16.5 5.25h16.5" />
|
||||
</svg>
|
||||
</button>
|
||||
Hochladen
|
||||
</a>
|
||||
<button
|
||||
onclick={() => switchView('grid')}
|
||||
class="rounded-md p-1.5 transition-colors {viewMode === 'grid' ? 'bg-white text-gray-900 shadow-sm' : 'text-gray-400 hover:text-gray-600'}"
|
||||
aria-label="Rasteransicht"
|
||||
onclick={handleLogout}
|
||||
class="text-sm text-gray-500 hover:text-gray-700"
|
||||
>
|
||||
<!-- squares-2x2 -->
|
||||
<svg class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M3.75 6A2.25 2.25 0 0 1 6 3.75h2.25A2.25 2.25 0 0 1 10.5 6v2.25a2.25 2.25 0 0 1-2.25 2.25H6a2.25 2.25 0 0 1-2.25-2.25V6ZM3.75 15.75A2.25 2.25 0 0 1 6 13.5h2.25a2.25 2.25 0 0 1 2.25 2.25V18a2.25 2.25 0 0 1-2.25 2.25H6A2.25 2.25 0 0 1 3.75 18v-2.25ZM13.5 6a2.25 2.25 0 0 1 2.25-2.25H18A2.25 2.25 0 0 1 20.25 6v2.25A2.25 2.25 0 0 1 18 10.5h-2.25a2.25 2.25 0 0 1-2.25-2.25V6ZM13.5 15.75a2.25 2.25 0 0 1 2.25-2.25H18a2.25 2.25 0 0 1 2.25 2.25V18A2.25 2.25 0 0 1 18 20.25h-2.25A2.25 2.25 0 0 1 13.5 18v-2.25Z" />
|
||||
</svg>
|
||||
Abmelden
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- List view: hashtag chips -->
|
||||
{#if viewMode === 'list'}
|
||||
<div class="mx-auto max-w-2xl px-4 pb-2">
|
||||
<HashtagChips {hashtags} selected={selectedHashtag} onselect={selectHashtag} />
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Grid view: search bar + autocomplete -->
|
||||
{#if viewMode === 'grid'}
|
||||
<div class="mx-auto max-w-2xl px-4 pb-3">
|
||||
<div class="relative">
|
||||
<div class="flex items-center gap-2 rounded-xl border border-gray-200 bg-gray-50 px-3 py-2 focus-within:border-blue-400 focus-within:bg-white focus-within:ring-1 focus-within:ring-blue-200">
|
||||
<svg class="h-4 w-4 shrink-0 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M21 21l-5.197-5.197m0 0A7.5 7.5 0 1 0 5.196 5.196a7.5 7.5 0 0 0 10.607 10.607Z" />
|
||||
</svg>
|
||||
<input
|
||||
type="search"
|
||||
placeholder="Nutzer oder #Tag suchen…"
|
||||
bind:value={searchQuery}
|
||||
onfocus={() => (showAutocomplete = true)}
|
||||
onblur={() => setTimeout(() => (showAutocomplete = false), 150)}
|
||||
class="min-w-0 flex-1 bg-transparent text-sm text-gray-900 placeholder-gray-400 outline-none"
|
||||
/>
|
||||
{#if searchQuery}
|
||||
<button
|
||||
onclick={() => { searchQuery = ''; }}
|
||||
class="shrink-0 text-gray-400 hover:text-gray-600"
|
||||
aria-label="Suche löschen"
|
||||
>
|
||||
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18 18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Autocomplete dropdown -->
|
||||
{#if showAutocomplete && suggestions.length > 0}
|
||||
<div class="absolute left-0 right-0 top-full z-50 mt-1 overflow-hidden rounded-xl border border-gray-200 bg-white shadow-lg">
|
||||
{#each suggestions as item}
|
||||
<button
|
||||
class="flex w-full items-center gap-3 px-4 py-2.5 text-left text-sm hover:bg-gray-50"
|
||||
onmousedown={() => selectSuggestion(item)}
|
||||
>
|
||||
{#if item.type === 'user'}
|
||||
<svg class="h-4 w-4 shrink-0 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M15.75 6a3.75 3.75 0 1 1-7.5 0 3.75 3.75 0 0 1 7.5 0ZM4.501 20.118a7.5 7.5 0 0 1 14.998 0A17.933 17.933 0 0 1 12 21.75c-2.676 0-5.216-.584-7.499-1.632Z" />
|
||||
</svg>
|
||||
<span class="font-medium text-gray-900">{item.value}</span>
|
||||
{:else}
|
||||
<span class="text-blue-500 font-medium">#</span>
|
||||
<span class="font-medium text-gray-900">{item.value}</span>
|
||||
{/if}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Active filter chips -->
|
||||
{#if activeFilters.length > 0}
|
||||
<div class="mt-2 flex flex-wrap items-center gap-1.5">
|
||||
{#each activeFilters as filter}
|
||||
<span class="flex items-center gap-1 rounded-full bg-blue-100 px-2.5 py-0.5 text-xs font-medium text-blue-700">
|
||||
{filter.type === 'tag' ? '#' : ''}{filter.value}
|
||||
<button onclick={() => removeFilter(filter)} class="ml-0.5 hover:text-blue-900" aria-label="Filter entfernen">
|
||||
<svg class="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2.5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18 18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</span>
|
||||
{/each}
|
||||
{#if activeFilters.length >= 2}
|
||||
<button onclick={clearFilters} class="text-xs text-gray-400 hover:text-gray-600">
|
||||
Alle löschen
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
<!-- Hashtag filter chips -->
|
||||
<div class="mx-auto max-w-2xl px-4 pb-2">
|
||||
<HashtagChips {hashtags} selected={selectedHashtag} onselect={selectHashtag} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Content -->
|
||||
{#if uploads.length === 0}
|
||||
<div class="py-20 text-center">
|
||||
<p class="text-lg text-gray-400">Noch keine Fotos.</p>
|
||||
<p class="mt-1 text-sm text-gray-400">Tippe auf den Plus-Button unten!</p>
|
||||
</div>
|
||||
{:else if viewMode === 'list'}
|
||||
<!-- List view: chronological full-width cards -->
|
||||
<div class="mx-auto max-w-2xl">
|
||||
{#each uploads as upload (upload.id)}
|
||||
<FeedListCard
|
||||
{upload}
|
||||
onlike={handleLike}
|
||||
oncomment={openComments}
|
||||
onselect={(u) => (selectedUpload = u)}
|
||||
/>
|
||||
{/each}
|
||||
</div>
|
||||
{:else}
|
||||
<!-- Grid view: 3-col, filters applied -->
|
||||
<div class="mx-auto max-w-2xl">
|
||||
{#if displayUploads.length === 0}
|
||||
<div class="py-16 text-center">
|
||||
<p class="text-sm text-gray-400">Keine Treffer für die gewählten Filter.</p>
|
||||
<button onclick={clearFilters} class="mt-2 text-sm text-blue-600 hover:underline">Filter zurücksetzen</button>
|
||||
</div>
|
||||
{:else}
|
||||
<FeedGrid
|
||||
uploads={displayUploads}
|
||||
onlike={handleLike}
|
||||
oncomment={openComments}
|
||||
onselect={(u) => (selectedUpload = u)}
|
||||
threeCol={true}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
<!-- 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 class="mx-auto max-w-2xl">
|
||||
<!-- 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>
|
||||
@@ -385,6 +224,3 @@
|
||||
onlike={handleLike}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
<!-- First-visit onboarding guide -->
|
||||
<OnboardingGuide />
|
||||
|
||||
@@ -1,407 +0,0 @@
|
||||
<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);
|
||||
|
||||
// Collapsible section state
|
||||
let statsOpen = $state(true);
|
||||
let settingsOpen = $state(true);
|
||||
let usersOpen = $state(true);
|
||||
|
||||
// User search
|
||||
let userSearch = $state('');
|
||||
let filteredUsers = $derived(
|
||||
userSearch.trim()
|
||||
? users.filter((u) => u.display_name.toLowerCase().includes(userSearch.toLowerCase()))
|
||||
: users
|
||||
);
|
||||
|
||||
// Ban modal state
|
||||
let banTarget = $state<UserSummary | null>(null);
|
||||
let banHideUploads = $state(false);
|
||||
let banSubmitting = $state(false);
|
||||
|
||||
let toast = $state<string | null>(null);
|
||||
|
||||
const myRole = getRole();
|
||||
|
||||
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`;
|
||||
}
|
||||
</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-24 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 pb-24">
|
||||
<!-- Header -->
|
||||
<div class="border-b border-gray-200 bg-white">
|
||||
<div class="mx-auto flex max-w-3xl items-center gap-3 px-4 py-4">
|
||||
<button
|
||||
onclick={() => goto('/account')}
|
||||
class="flex h-9 w-9 shrink-0 items-center justify-center rounded-full text-gray-500 transition hover:bg-gray-100"
|
||||
aria-label="Zurück"
|
||||
>
|
||||
<svg class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M10.5 19.5L3 12m0 0l7.5-7.5M3 12h18" />
|
||||
</svg>
|
||||
</button>
|
||||
<div class="min-w-0">
|
||||
<h1 class="text-xl font-bold text-gray-900">Host-Dashboard</h1>
|
||||
{#if event}
|
||||
<p class="truncate text-sm text-gray-500">{event.name}</p>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mx-auto max-w-3xl space-y-3 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}
|
||||
|
||||
<!-- ── Statistiken ─────────────────────────────────────────────── -->
|
||||
<div class="overflow-hidden rounded-xl border border-gray-200 bg-white">
|
||||
<button
|
||||
onclick={() => (statsOpen = !statsOpen)}
|
||||
class="flex w-full items-center justify-between px-5 py-4"
|
||||
>
|
||||
<h2 class="font-semibold text-gray-900">Statistiken</h2>
|
||||
<svg
|
||||
class="h-5 w-5 text-gray-400 transition-transform duration-200 {statsOpen ? 'rotate-180' : ''}"
|
||||
fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"
|
||||
>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M19.5 8.25l-7.5 7.5-7.5-7.5" />
|
||||
</svg>
|
||||
</button>
|
||||
<div class="overflow-hidden transition-[max-height] duration-200 {statsOpen ? 'max-h-[500px]' : 'max-h-0'}">
|
||||
<div class="grid grid-cols-2 gap-3 border-t border-gray-100 p-4 sm:grid-cols-4">
|
||||
<div class="rounded-xl bg-gray-50 p-4 text-center">
|
||||
<p class="text-2xl font-bold text-gray-900">{users.length}</p>
|
||||
<p class="mt-0.5 text-xs text-gray-500">Gäste</p>
|
||||
</div>
|
||||
<div class="rounded-xl bg-gray-50 p-4 text-center">
|
||||
<p class="text-2xl font-bold text-gray-900">{users.reduce((s, u) => s + u.upload_count, 0)}</p>
|
||||
<p class="mt-0.5 text-xs text-gray-500">Uploads</p>
|
||||
</div>
|
||||
<div class="rounded-xl bg-gray-50 p-4 text-center">
|
||||
<p class="text-2xl font-bold {event.uploads_locked ? 'text-red-600' : 'text-green-600'}">
|
||||
{event.uploads_locked ? 'Gesperrt' : 'Offen'}
|
||||
</p>
|
||||
<p class="mt-0.5 text-xs text-gray-500">Uploads</p>
|
||||
</div>
|
||||
<div class="rounded-xl bg-gray-50 p-4 text-center">
|
||||
<p class="text-2xl font-bold {event.export_released ? 'text-blue-600' : 'text-gray-400'}">
|
||||
{event.export_released ? 'Ja' : 'Nein'}
|
||||
</p>
|
||||
<p class="mt-0.5 text-xs text-gray-500">Freigegeben</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── Event-Einstellungen ─────────────────────────────────────── -->
|
||||
<div class="overflow-hidden rounded-xl border border-gray-200 bg-white">
|
||||
<button
|
||||
onclick={() => (settingsOpen = !settingsOpen)}
|
||||
class="flex w-full items-center justify-between px-5 py-4"
|
||||
>
|
||||
<h2 class="font-semibold text-gray-900">Event-Einstellungen</h2>
|
||||
<svg
|
||||
class="h-5 w-5 text-gray-400 transition-transform duration-200 {settingsOpen ? 'rotate-180' : ''}"
|
||||
fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"
|
||||
>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M19.5 8.25l-7.5 7.5-7.5-7.5" />
|
||||
</svg>
|
||||
</button>
|
||||
<div class="overflow-hidden transition-[max-height] duration-200 {settingsOpen ? 'max-h-[500px]' : 'max-h-0'}">
|
||||
<div class="flex flex-wrap gap-3 border-t border-gray-100 p-5">
|
||||
<button
|
||||
onclick={toggleEventLock}
|
||||
class="rounded-lg px-4 py-2 text-sm font-medium transition
|
||||
{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 transition
|
||||
{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>
|
||||
</div>
|
||||
|
||||
<!-- ── Nutzerverwaltung ───────────────────────────────────────── -->
|
||||
<div class="overflow-hidden rounded-xl border border-gray-200 bg-white">
|
||||
<button
|
||||
onclick={() => (usersOpen = !usersOpen)}
|
||||
class="flex w-full items-center justify-between px-5 py-4"
|
||||
>
|
||||
<h2 class="font-semibold text-gray-900">Nutzerverwaltung</h2>
|
||||
<svg
|
||||
class="h-5 w-5 text-gray-400 transition-transform duration-200 {usersOpen ? 'rotate-180' : ''}"
|
||||
fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"
|
||||
>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M19.5 8.25l-7.5 7.5-7.5-7.5" />
|
||||
</svg>
|
||||
</button>
|
||||
<div class="overflow-hidden transition-[max-height] duration-300 {usersOpen ? 'max-h-[9999px]' : 'max-h-0'}">
|
||||
<div class="border-t border-gray-100">
|
||||
<!-- Search -->
|
||||
<div class="px-4 py-3">
|
||||
<div class="flex items-center gap-2 rounded-lg border border-gray-200 bg-gray-50 px-3 py-2">
|
||||
<svg class="h-4 w-4 shrink-0 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M21 21l-5.197-5.197m0 0A7.5 7.5 0 105.196 5.196a7.5 7.5 0 0010.607 10.607z" />
|
||||
</svg>
|
||||
<input
|
||||
type="search"
|
||||
placeholder="Nutzer suchen…"
|
||||
bind:value={userSearch}
|
||||
class="min-w-0 flex-1 bg-transparent text-sm text-gray-900 placeholder-gray-400 outline-none"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{#if filteredUsers.length === 0}
|
||||
<p class="px-5 py-8 text-center text-sm text-gray-400">Keine Treffer.</p>
|
||||
{:else}
|
||||
<div class="divide-y divide-gray-100">
|
||||
{#each filteredUsers as user}
|
||||
<div class="flex items-center gap-3 px-5 py-3">
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="flex flex-wrap items-center gap-1.5">
|
||||
<span class="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>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
@@ -10,13 +10,6 @@
|
||||
let pin = $state('');
|
||||
let copied = $state(false);
|
||||
|
||||
// Name-taken state — shown instead of the normal form
|
||||
let nameTaken = $state(false);
|
||||
let takenName = $state('');
|
||||
let recoveryPin = $state('');
|
||||
let recoveryError = $state('');
|
||||
let recoveryLoading = $state(false);
|
||||
|
||||
async function handleJoin() {
|
||||
if (!displayName.trim()) return;
|
||||
loading = true;
|
||||
@@ -29,14 +22,11 @@
|
||||
is_new: boolean;
|
||||
}>('/join', { display_name: displayName.trim() });
|
||||
|
||||
setAuth(res.jwt, res.pin, res.user_id, displayName.trim());
|
||||
setAuth(res.jwt, res.pin, res.user_id);
|
||||
pin = res.pin;
|
||||
showPinModal = true;
|
||||
} catch (e) {
|
||||
if (e instanceof ApiError && e.code === 'conflict') {
|
||||
takenName = displayName.trim();
|
||||
nameTaken = true;
|
||||
} else if (e instanceof ApiError) {
|
||||
if (e instanceof ApiError) {
|
||||
error = e.message;
|
||||
} else {
|
||||
error = 'Ein Fehler ist aufgetreten.';
|
||||
@@ -46,35 +36,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
async function handleInlineRecover() {
|
||||
if (recoveryPin.length < 4) return;
|
||||
recoveryLoading = true;
|
||||
recoveryError = '';
|
||||
try {
|
||||
const res = await api.post<{ jwt: string; user_id: string }>(
|
||||
'/recover',
|
||||
{ display_name: takenName, pin: recoveryPin.trim() }
|
||||
);
|
||||
setAuth(res.jwt, recoveryPin.trim(), res.user_id, takenName);
|
||||
goto('/feed');
|
||||
} catch (e) {
|
||||
if (e instanceof ApiError) {
|
||||
recoveryError = e.message;
|
||||
} else {
|
||||
recoveryError = 'Ein Fehler ist aufgetreten.';
|
||||
}
|
||||
} finally {
|
||||
recoveryLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
function tryDifferentName() {
|
||||
nameTaken = false;
|
||||
recoveryPin = '';
|
||||
recoveryError = '';
|
||||
// Keep displayName so the user can edit it slightly
|
||||
}
|
||||
|
||||
function copyPin() {
|
||||
navigator.clipboard.writeText(pin);
|
||||
copied = true;
|
||||
@@ -88,85 +49,35 @@
|
||||
|
||||
<div class="flex min-h-screen items-center justify-center bg-gray-50 px-4">
|
||||
<div class="w-full max-w-sm">
|
||||
<h1 class="mb-2 text-center text-2xl font-bold text-gray-900">Willkommen!</h1>
|
||||
<p class="mb-6 text-center text-gray-600">Gib deinen Namen ein, um dem Event beizutreten.</p>
|
||||
|
||||
{#if nameTaken}
|
||||
<!-- Name-taken state: sign in with PIN or choose a different name -->
|
||||
<div class="mb-5 rounded-lg border border-amber-200 bg-amber-50 p-4">
|
||||
<p class="font-semibold text-amber-900">„{takenName}" ist bereits vergeben.</p>
|
||||
<p class="mt-1 text-sm text-amber-800">
|
||||
Wähle einen anderen Namen, z. B. einen Spitznamen oder füge deinen Nachnamen hinzu
|
||||
(„{takenName} M." oder „{takenName} aus Berlin").
|
||||
</p>
|
||||
</div>
|
||||
<form onsubmit={(e) => { e.preventDefault(); handleJoin(); }}>
|
||||
<input
|
||||
type="text"
|
||||
bind:value={displayName}
|
||||
placeholder="Dein Name"
|
||||
maxlength={50}
|
||||
class="mb-3 w-full rounded-lg border border-gray-300 px-4 py-3 text-lg focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-200"
|
||||
/>
|
||||
|
||||
<p class="mb-3 text-sm font-medium text-gray-700">
|
||||
Falls du das bist, melde dich mit deinem PIN an:
|
||||
</p>
|
||||
|
||||
<form onsubmit={(e) => { e.preventDefault(); handleInlineRecover(); }}>
|
||||
<input
|
||||
type="text"
|
||||
bind:value={recoveryPin}
|
||||
placeholder="4-stelliger PIN"
|
||||
maxlength={4}
|
||||
inputmode="numeric"
|
||||
pattern="[0-9]*"
|
||||
class="mb-3 w-full rounded-lg border border-gray-300 px-4 py-3 text-center text-2xl font-mono tracking-widest focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-200"
|
||||
/>
|
||||
|
||||
{#if recoveryError}
|
||||
<p class="mb-3 text-sm text-red-600">{recoveryError}</p>
|
||||
{/if}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={recoveryLoading || recoveryPin.length < 4}
|
||||
class="mb-3 w-full rounded-lg bg-blue-600 px-4 py-3 font-medium text-white transition hover:bg-blue-700 disabled:opacity-50"
|
||||
>
|
||||
{recoveryLoading ? 'Wird angemeldet...' : 'Anmelden'}
|
||||
</button>
|
||||
</form>
|
||||
{#if error}
|
||||
<p class="mb-3 text-sm text-red-600">{error}</p>
|
||||
{/if}
|
||||
|
||||
<button
|
||||
onclick={tryDifferentName}
|
||||
class="w-full rounded-lg border border-gray-300 px-4 py-3 font-medium text-gray-700 transition hover:bg-gray-50"
|
||||
type="submit"
|
||||
disabled={loading || !displayName.trim()}
|
||||
class="w-full rounded-lg bg-blue-600 px-4 py-3 text-lg font-medium text-white transition hover:bg-blue-700 disabled:opacity-50"
|
||||
>
|
||||
Anderen Namen wählen
|
||||
{loading ? 'Wird geladen...' : 'Beitreten'}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{:else}
|
||||
<!-- Normal join form -->
|
||||
<h1 class="mb-2 text-center text-2xl font-bold text-gray-900">Willkommen!</h1>
|
||||
<p class="mb-6 text-center text-gray-600">Gib deinen Namen ein, um dem Event beizutreten.</p>
|
||||
|
||||
<form onsubmit={(e) => { e.preventDefault(); handleJoin(); }}>
|
||||
<input
|
||||
type="text"
|
||||
bind:value={displayName}
|
||||
placeholder="Dein Name"
|
||||
maxlength={50}
|
||||
class="mb-3 w-full rounded-lg border border-gray-300 px-4 py-3 text-lg focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-200"
|
||||
/>
|
||||
|
||||
{#if error}
|
||||
<p class="mb-3 text-sm text-red-600">{error}</p>
|
||||
{/if}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading || !displayName.trim()}
|
||||
class="w-full rounded-lg bg-blue-600 px-4 py-3 text-lg font-medium text-white transition hover:bg-blue-700 disabled:opacity-50"
|
||||
>
|
||||
{loading ? 'Wird geladen...' : 'Beitreten'}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<p class="mt-4 text-center text-sm text-gray-500">
|
||||
Schon dabei?
|
||||
<a href="/recover" class="text-blue-600 hover:underline">Mit PIN wiederherstellen</a>
|
||||
</p>
|
||||
{/if}
|
||||
|
||||
<p class="mt-4 text-center text-sm text-gray-500">
|
||||
Schon dabei?
|
||||
<a href="/recover" class="text-blue-600 hover:underline">Mit PIN wiederherstellen</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -25,7 +25,7 @@
|
||||
user_id: string;
|
||||
}>('/recover', { display_name: displayName.trim(), pin: pin.trim() });
|
||||
|
||||
setAuth(res.jwt, pin.trim(), res.user_id, displayName.trim());
|
||||
setAuth(res.jwt, pin.trim(), res.user_id);
|
||||
goto('/feed');
|
||||
} catch (e) {
|
||||
if (e instanceof ApiError) {
|
||||
|
||||
@@ -2,193 +2,78 @@
|
||||
import { goto } from '$app/navigation';
|
||||
import { getToken } from '$lib/auth';
|
||||
import { addToQueue, loadQueue } from '$lib/upload-queue';
|
||||
import { showBottomNav } from '$lib/ui-store';
|
||||
import { pendingFiles, pendingCaption, clearPending } from '$lib/pending-upload-store';
|
||||
import { get } from 'svelte/store';
|
||||
import { onMount, onDestroy } from 'svelte';
|
||||
import type { PendingFile } from '$lib/pending-upload-store';
|
||||
import UploadQueue from '$lib/components/UploadQueue.svelte';
|
||||
import { onMount } from 'svelte';
|
||||
|
||||
interface StagedFile extends PendingFile {
|
||||
// previewUrl and file inherited from PendingFile
|
||||
}
|
||||
|
||||
let stagedFiles = $state<StagedFile[]>([]);
|
||||
let caption = $state('');
|
||||
let submitting = $state(false);
|
||||
let captionEl: HTMLTextAreaElement;
|
||||
|
||||
// Quick-tag chips derived from caption as the user types
|
||||
let captionTags = $derived.by(() => {
|
||||
const matches = [...caption.matchAll(/#(\w+)/g)];
|
||||
return [...new Set(matches.map((m) => m[1].toLowerCase()))];
|
||||
});
|
||||
let hashtags = $state('');
|
||||
let fileInput: HTMLInputElement;
|
||||
|
||||
onMount(() => {
|
||||
showBottomNav.set(false);
|
||||
if (!getToken()) {
|
||||
goto('/join');
|
||||
return;
|
||||
}
|
||||
loadQueue();
|
||||
|
||||
// Pull staged files from the pending store (written by UploadSheet)
|
||||
const pf = get(pendingFiles);
|
||||
const pc = get(pendingCaption);
|
||||
stagedFiles = pf;
|
||||
caption = pc;
|
||||
|
||||
// Auto-focus caption textarea after a short delay (let layout settle)
|
||||
setTimeout(() => captionEl?.focus(), 80);
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
showBottomNav.set(true);
|
||||
});
|
||||
async function handleFiles() {
|
||||
const files = fileInput?.files;
|
||||
if (!files || files.length === 0) return;
|
||||
|
||||
function removeFile(idx: number) {
|
||||
const removed = stagedFiles[idx];
|
||||
URL.revokeObjectURL(removed.previewUrl);
|
||||
stagedFiles = stagedFiles.filter((_, i) => i !== idx);
|
||||
}
|
||||
|
||||
function cancel() {
|
||||
clearPending();
|
||||
goto('/feed');
|
||||
}
|
||||
|
||||
async function handleSubmit() {
|
||||
if (stagedFiles.length === 0 || submitting) return;
|
||||
submitting = true;
|
||||
for (const sf of stagedFiles) {
|
||||
await addToQueue(sf.file, caption, '');
|
||||
for (const file of files) {
|
||||
await addToQueue(file, caption, hashtags);
|
||||
}
|
||||
clearPending();
|
||||
goto('/feed');
|
||||
}
|
||||
|
||||
function isVideo(file: File): boolean {
|
||||
return file.type.startsWith('video/');
|
||||
// Reset form
|
||||
caption = '';
|
||||
hashtags = '';
|
||||
if (fileInput) fileInput.value = '';
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- Full-screen composer — bottom nav is suppressed -->
|
||||
<div class="flex min-h-screen flex-col bg-white">
|
||||
<!-- Header -->
|
||||
<div class="flex items-center justify-between border-b border-gray-100 px-4 py-3">
|
||||
<button
|
||||
onclick={cancel}
|
||||
class="flex h-9 w-9 items-center justify-center rounded-full text-gray-500 transition hover:bg-gray-100"
|
||||
aria-label="Abbrechen"
|
||||
>
|
||||
<svg class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18 18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
<h1 class="text-base font-semibold text-gray-900">Neuer Beitrag</h1>
|
||||
<!-- Submit button in header for desktop convenience -->
|
||||
<button
|
||||
onclick={handleSubmit}
|
||||
disabled={stagedFiles.length === 0 || submitting}
|
||||
class="rounded-lg bg-blue-600 px-4 py-1.5 text-sm font-semibold text-white transition
|
||||
hover:bg-blue-700 disabled:opacity-40"
|
||||
>
|
||||
{submitting ? 'Wird hochgeladen…' : 'Hochladen'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-1 flex-col overflow-y-auto">
|
||||
<!-- Thumbnail strip -->
|
||||
{#if stagedFiles.length > 0}
|
||||
<div class="flex gap-2 overflow-x-auto px-4 py-3 scrollbar-none">
|
||||
{#each stagedFiles as sf, i}
|
||||
<div class="relative h-20 w-20 shrink-0 overflow-hidden rounded-xl bg-gray-100">
|
||||
{#if isVideo(sf.file)}
|
||||
<div class="flex h-full w-full items-center justify-center bg-gray-800">
|
||||
<svg class="h-7 w-7 text-white/70" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M8 5v14l11-7z" />
|
||||
</svg>
|
||||
</div>
|
||||
{:else}
|
||||
<img src={sf.previewUrl} alt="" class="h-full w-full object-cover" />
|
||||
{/if}
|
||||
<button
|
||||
onclick={() => removeFile(i)}
|
||||
class="absolute right-1 top-1 flex h-5 w-5 items-center justify-center rounded-full bg-black/60 text-white"
|
||||
aria-label="Entfernen"
|
||||
>
|
||||
<svg class="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="3">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18 18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
<div class="border-b border-gray-100"></div>
|
||||
{:else}
|
||||
<!-- No files: prompt to go back and pick some -->
|
||||
<div class="flex flex-1 flex-col items-center justify-center gap-4 p-8 text-center">
|
||||
<svg class="h-16 w-16 text-gray-200" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M2.25 15.75l5.159-5.159a2.25 2.25 0 013.182 0l5.159 5.159m-1.5-1.5l1.409-1.409a2.25 2.25 0 013.182 0l2.909 2.909M9 9.75h.008v.008H9V9.75zm.375 0a.375.375 0 11-.75 0 .375.375 0 01.75 0zM6 20.25h12A2.25 2.25 0 0020.25 18V6A2.25 2.25 0 0018 3.75H6A2.25 2.25 0 003.75 6v12A2.25 2.25 0 006 20.25z" />
|
||||
</svg>
|
||||
<div>
|
||||
<p class="font-medium text-gray-500">Keine Dateien ausgewählt</p>
|
||||
<p class="mt-1 text-sm text-gray-400">Geh zurück und tippe auf den Plus-Button.</p>
|
||||
</div>
|
||||
<button
|
||||
onclick={cancel}
|
||||
class="rounded-lg border border-gray-300 px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50"
|
||||
>
|
||||
Zurück
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Caption textarea -->
|
||||
<div class="px-4 pt-4">
|
||||
<textarea
|
||||
bind:this={captionEl}
|
||||
bind:value={caption}
|
||||
placeholder="Beschreibung hinzufügen… (#hashtags möglich)"
|
||||
rows="4"
|
||||
class="w-full resize-none rounded-xl border border-gray-200 bg-gray-50 px-4 py-3 text-sm text-gray-900
|
||||
placeholder-gray-400 focus:border-blue-400 focus:bg-white focus:outline-none focus:ring-1 focus:ring-blue-200"
|
||||
></textarea>
|
||||
<div class="min-h-screen bg-gray-50 p-4">
|
||||
<div class="mx-auto max-w-lg">
|
||||
<div class="mb-6 flex items-center justify-between">
|
||||
<h1 class="text-xl font-bold text-gray-900">Hochladen</h1>
|
||||
<a href="/feed" class="text-sm text-blue-600 hover:underline">Zur Galerie</a>
|
||||
</div>
|
||||
|
||||
<!-- Quick-tag chips (derived from typed caption) -->
|
||||
{#if captionTags.length > 0}
|
||||
<div class="flex flex-wrap gap-1.5 px-4 pt-2">
|
||||
{#each captionTags as tag}
|
||||
<span class="rounded-full bg-blue-50 px-2.5 py-0.5 text-xs font-medium text-blue-600">
|
||||
#{tag}
|
||||
</span>
|
||||
{/each}
|
||||
<div class="rounded-lg border border-gray-200 bg-white p-4">
|
||||
<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"
|
||||
>
|
||||
<svg class="mb-2 h-10 w-10 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" />
|
||||
</svg>
|
||||
<span class="text-sm font-medium text-gray-600">Fotos oder Videos auswählen</span>
|
||||
<span class="mt-1 text-xs text-gray-400">Mehrere Dateien möglich</span>
|
||||
<input
|
||||
bind:this={fileInput}
|
||||
type="file"
|
||||
accept="image/*,video/*"
|
||||
multiple
|
||||
class="hidden"
|
||||
onchange={handleFiles}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<div class="mt-4 space-y-3">
|
||||
<input
|
||||
type="text"
|
||||
bind:value={caption}
|
||||
placeholder="Beschreibung (optional, #hashtags möglich)"
|
||||
class="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-200"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
bind:value={hashtags}
|
||||
placeholder="Hashtags (kommagetrennt, z.B. hochzeit, party)"
|
||||
class="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-200"
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="h-8"></div>
|
||||
</div>
|
||||
|
||||
<!-- Sticky submit button at bottom (mobile-primary) -->
|
||||
<div class="border-t border-gray-100 px-4 py-3">
|
||||
<button
|
||||
onclick={handleSubmit}
|
||||
disabled={stagedFiles.length === 0 || submitting}
|
||||
class="flex w-full items-center justify-center gap-2 rounded-xl bg-blue-600 py-3.5 text-sm font-semibold
|
||||
text-white transition hover:bg-blue-700 active:scale-[0.98] disabled:opacity-40"
|
||||
>
|
||||
{#if submitting}
|
||||
<svg class="h-4 w-4 animate-spin" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path>
|
||||
</svg>
|
||||
Wird hochgeladen…
|
||||
{:else}
|
||||
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M3 16.5v2.25A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V16.5m-13.5-9L12 3m0 0l4.5 4.5M12 3v13.5" />
|
||||
</svg>
|
||||
{stagedFiles.length > 0 ? `${stagedFiles.length} Datei${stagedFiles.length > 1 ? 'en' : ''} hochladen` : 'Hochladen'}
|
||||
{/if}
|
||||
</button>
|
||||
<UploadQueue />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -3,11 +3,5 @@ import tailwindcss from '@tailwindcss/vite';
|
||||
import { defineConfig } from 'vite';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [tailwindcss(), sveltekit()],
|
||||
server: {
|
||||
proxy: {
|
||||
'/api': 'http://localhost:3000',
|
||||
'/media': 'http://localhost:3000'
|
||||
}
|
||||
}
|
||||
plugins: [tailwindcss(), sveltekit()]
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user