Merge branch 'feature/diashow-theming-config'
Runtime colour theme + COMMENTS_ENABLED switch, diashow transition/fullscreen/wake-lock work, quota disk-leak fix, and docker-compose.dev.yml container-env corrections.
This commit is contained in:
@@ -63,8 +63,25 @@ pub struct AppConfig {
|
|||||||
pub app_port: u16,
|
pub app_port: u16,
|
||||||
/// Number of concurrent media compression workers (read once at boot).
|
/// Number of concurrent media compression workers (read once at boot).
|
||||||
pub compression_concurrency: usize,
|
pub compression_concurrency: usize,
|
||||||
|
/// Master switch for the comment feature (env `COMMENTS_ENABLED`, default true).
|
||||||
|
/// When false the backend rejects new comments and the frontend hides the whole
|
||||||
|
/// comment UI. Existing comments stay in the DB (hidden), so flipping it back
|
||||||
|
/// restores them. Boot-time immutable, like `compression_concurrency`.
|
||||||
|
pub comments_enabled: bool,
|
||||||
|
/// Default colour theme, used as the fallback when the DB config keys are unset.
|
||||||
|
/// Runtime overrides live in the `config` table (admin UI); these env vars only
|
||||||
|
/// seed the initial default. `preset` is an id the frontend knows (e.g.
|
||||||
|
/// "champagne-gold", "rose", … or "custom"); the two seeds are `#rrggbb` brand +
|
||||||
|
/// accent colours the whole palette is derived from.
|
||||||
|
pub default_theme_preset: String,
|
||||||
|
pub default_theme_primary: String,
|
||||||
|
pub default_theme_accent: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The shipped default brand/accent seed (champagne gold — matches the hand-tuned
|
||||||
|
/// ramp in tailwind-theme.css). Kept here so an unset env still yields the current look.
|
||||||
|
const DEFAULT_THEME_SEED: &str = "#8a6a2b";
|
||||||
|
|
||||||
impl AppConfig {
|
impl AppConfig {
|
||||||
pub fn from_env() -> Result<Self> {
|
pub fn from_env() -> Result<Self> {
|
||||||
let app_env = std::env::var("APP_ENV").unwrap_or_else(|_| "development".to_string());
|
let app_env = std::env::var("APP_ENV").unwrap_or_else(|_| "development".to_string());
|
||||||
@@ -100,6 +117,20 @@ impl AppConfig {
|
|||||||
.and_then(|v| v.parse().ok())
|
.and_then(|v| v.parse().ok())
|
||||||
.filter(|&n| n >= 1)
|
.filter(|&n| n >= 1)
|
||||||
.unwrap_or(2),
|
.unwrap_or(2),
|
||||||
|
comments_enabled: std::env::var("COMMENTS_ENABLED")
|
||||||
|
.map(|v| {
|
||||||
|
!matches!(
|
||||||
|
v.trim().to_ascii_lowercase().as_str(),
|
||||||
|
"false" | "0" | "no" | "off"
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.unwrap_or(true),
|
||||||
|
default_theme_preset: std::env::var("THEME_PRESET")
|
||||||
|
.unwrap_or_else(|_| "champagne-gold".to_string()),
|
||||||
|
default_theme_primary: std::env::var("THEME_PRIMARY")
|
||||||
|
.unwrap_or_else(|_| DEFAULT_THEME_SEED.to_string()),
|
||||||
|
default_theme_accent: std::env::var("THEME_ACCENT")
|
||||||
|
.unwrap_or_else(|_| DEFAULT_THEME_SEED.to_string()),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -138,10 +138,27 @@ pub async fn patch_config(
|
|||||||
"storage_quota_enabled",
|
"storage_quota_enabled",
|
||||||
"upload_count_quota_enabled",
|
"upload_count_quota_enabled",
|
||||||
];
|
];
|
||||||
const TEXT_KEYS: &[&str] = &["privacy_note"];
|
const TEXT_KEYS: &[&str] = &[
|
||||||
|
"privacy_note",
|
||||||
|
"theme_preset",
|
||||||
|
"theme_primary",
|
||||||
|
"theme_accent",
|
||||||
|
];
|
||||||
const PRIVACY_NOTE_MAX_LEN: usize = 16 * 1024; // 16 KiB free text is plenty
|
const PRIVACY_NOTE_MAX_LEN: usize = 16 * 1024; // 16 KiB free text is plenty
|
||||||
|
// Preset ids the frontend knows how to render (mirror of PRESETS in
|
||||||
|
// frontend/src/lib/theme/palette.ts). "custom" means "use the theme_primary/accent
|
||||||
|
// seeds verbatim". Kept in sync by hand — a new preset must be added in both places.
|
||||||
|
const THEME_PRESETS: &[&str] = &[
|
||||||
|
"champagne-gold",
|
||||||
|
"rose",
|
||||||
|
"sage",
|
||||||
|
"dusk-blue",
|
||||||
|
"classic-silver",
|
||||||
|
"custom",
|
||||||
|
];
|
||||||
|
|
||||||
let mut privacy_note_changed = false;
|
let mut privacy_note_changed = false;
|
||||||
|
let mut theme_changed = false;
|
||||||
|
|
||||||
// Validate every key first so a bad value in the batch can't leave a partial
|
// Validate every key first so a bad value in the batch can't leave a partial
|
||||||
// update behind — validation must fully precede any write.
|
// update behind — validation must fully precede any write.
|
||||||
@@ -186,8 +203,23 @@ pub async fn patch_config(
|
|||||||
"Wert für {key} ist zu lang (max. {PRIVACY_NOTE_MAX_LEN} Zeichen)."
|
"Wert für {key} ist zu lang (max. {PRIVACY_NOTE_MAX_LEN} Zeichen)."
|
||||||
)));
|
)));
|
||||||
}
|
}
|
||||||
if key_str == "privacy_note" {
|
match key_str {
|
||||||
privacy_note_changed = true;
|
"privacy_note" => privacy_note_changed = true,
|
||||||
|
"theme_preset" => {
|
||||||
|
if !THEME_PRESETS.contains(&value.trim()) {
|
||||||
|
return Err(AppError::BadRequest(format!("Ungültiges Theme: {value}.")));
|
||||||
|
}
|
||||||
|
theme_changed = true;
|
||||||
|
}
|
||||||
|
"theme_primary" | "theme_accent" => {
|
||||||
|
if !is_hex_color(value.trim()) {
|
||||||
|
return Err(AppError::BadRequest(format!(
|
||||||
|
"Ungültige Farbe für {key}: muss #rrggbb sein."
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
theme_changed = true;
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
return Err(AppError::BadRequest(format!(
|
return Err(AppError::BadRequest(format!(
|
||||||
@@ -217,16 +249,30 @@ pub async fn patch_config(
|
|||||||
|
|
||||||
// Notify all clients that a publicly-readable config value changed so their stores
|
// Notify all clients that a publicly-readable config value changed so their stores
|
||||||
// (e.g. the privacy note in My Account) refresh without a manual reload.
|
// (e.g. the privacy note in My Account) refresh without a manual reload.
|
||||||
if privacy_note_changed {
|
if privacy_note_changed || theme_changed {
|
||||||
|
let mut keys: Vec<&str> = Vec::new();
|
||||||
|
if privacy_note_changed {
|
||||||
|
keys.push("privacy_note");
|
||||||
|
}
|
||||||
|
if theme_changed {
|
||||||
|
keys.push("theme");
|
||||||
|
}
|
||||||
let _ = state.sse_tx.send(crate::state::SseEvent::new(
|
let _ = state.sse_tx.send(crate::state::SseEvent::new(
|
||||||
"event-updated",
|
"event-updated",
|
||||||
serde_json::json!({ "keys": ["privacy_note"] }).to_string(),
|
serde_json::json!({ "keys": keys }).to_string(),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(StatusCode::NO_CONTENT)
|
Ok(StatusCode::NO_CONTENT)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A strict `#rrggbb` hex-colour check (6 hex digits, leading `#`). Deliberately not
|
||||||
|
/// accepting shorthand/`#rgba` so the value is safe to drop straight into CSS.
|
||||||
|
fn is_hex_color(s: &str) -> bool {
|
||||||
|
let bytes = s.as_bytes();
|
||||||
|
bytes.len() == 7 && bytes[0] == b'#' && bytes[1..].iter().all(|b| b.is_ascii_hexdigit())
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn get_export_jobs(
|
pub async fn get_export_jobs(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
RequireAdmin(_auth): RequireAdmin,
|
RequireAdmin(_auth): RequireAdmin,
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ use serde::Serialize;
|
|||||||
use crate::auth::middleware::AuthUser;
|
use crate::auth::middleware::AuthUser;
|
||||||
use crate::error::AppError;
|
use crate::error::AppError;
|
||||||
use crate::handlers::upload::compute_storage_quota;
|
use crate::handlers::upload::compute_storage_quota;
|
||||||
use crate::models::user::User;
|
use crate::models::user::{User, UserRole};
|
||||||
use crate::services::config;
|
use crate::services::config;
|
||||||
use crate::state::AppState;
|
use crate::state::AppState;
|
||||||
|
|
||||||
@@ -37,12 +37,26 @@ pub async fn get_quota(
|
|||||||
|
|
||||||
let estimate = compute_storage_quota(&state).await;
|
let estimate = compute_storage_quota(&state).await;
|
||||||
|
|
||||||
|
// Raw server telemetry (free disk, concurrent uploader count) is staff-only — it
|
||||||
|
// must never reach a guest, even though the guest upload UI no longer renders it.
|
||||||
|
// A guest still gets their own `used`/`limit` so enforcement stays transparent to
|
||||||
|
// the code paths that consume it; only the server-wide fields are zeroed.
|
||||||
|
let is_staff = matches!(auth.role, UserRole::Host | UserRole::Admin);
|
||||||
|
|
||||||
Ok(Json(QuotaDto {
|
Ok(Json(QuotaDto {
|
||||||
enabled: estimate.limit_bytes.is_some(),
|
enabled: estimate.limit_bytes.is_some(),
|
||||||
used_bytes: user.total_upload_bytes,
|
used_bytes: user.total_upload_bytes,
|
||||||
limit_bytes: estimate.limit_bytes,
|
limit_bytes: estimate.limit_bytes,
|
||||||
active_uploaders: estimate.active_uploaders,
|
active_uploaders: if is_staff {
|
||||||
free_disk_bytes: estimate.free_disk_bytes,
|
estimate.active_uploaders
|
||||||
|
} else {
|
||||||
|
0
|
||||||
|
},
|
||||||
|
free_disk_bytes: if is_staff {
|
||||||
|
estimate.free_disk_bytes
|
||||||
|
} else {
|
||||||
|
0
|
||||||
|
},
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,21 +4,41 @@ use axum::Json;
|
|||||||
use axum::extract::State;
|
use axum::extract::State;
|
||||||
use serde::Serialize;
|
use serde::Serialize;
|
||||||
|
|
||||||
|
use crate::services::config;
|
||||||
use crate::state::AppState;
|
use crate::state::AppState;
|
||||||
|
|
||||||
#[derive(Serialize)]
|
#[derive(Serialize)]
|
||||||
pub struct PublicEventDto {
|
pub struct PublicEventDto {
|
||||||
pub name: String,
|
pub name: String,
|
||||||
pub slug: String,
|
pub slug: String,
|
||||||
|
/// Whether the comment feature is on (env `COMMENTS_ENABLED`). The frontend hides
|
||||||
|
/// the whole comment UI when false; exposed here so even the pre-auth shell knows.
|
||||||
|
pub comments_enabled: bool,
|
||||||
|
/// Active colour theme. `preset` is an id the frontend maps to a palette (or
|
||||||
|
/// "custom"); `primary`/`accent` are the `#rrggbb` seeds the ramps derive from.
|
||||||
|
/// Resolved as DB-config override → env default. Public so the theme applies on
|
||||||
|
/// the very first (pre-auth) paint without a flash.
|
||||||
|
pub theme_preset: String,
|
||||||
|
pub theme_primary: String,
|
||||||
|
pub theme_accent: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Public event identity, used by the pre-auth join/recover screens so a guest can
|
/// Public event identity + presentation config, used by the pre-auth join/recover
|
||||||
/// see *which* event they're joining. Only the display name and slug are exposed —
|
/// screens (which event am I joining, what does it look like). Only non-user-scoped
|
||||||
/// nothing user-scoped — so this is safe without a token. Served straight from the
|
/// fields are exposed, so this is safe without a token. Identity comes straight from
|
||||||
/// instance config (no DB round-trip needed).
|
/// instance config; the theme is resolved from the runtime `config` table (admin UI)
|
||||||
|
/// falling back to the env-seeded default.
|
||||||
pub async fn get_public_event(State(state): State<AppState>) -> Json<PublicEventDto> {
|
pub async fn get_public_event(State(state): State<AppState>) -> Json<PublicEventDto> {
|
||||||
|
let cache = &state.config_cache;
|
||||||
Json(PublicEventDto {
|
Json(PublicEventDto {
|
||||||
name: state.config.event_name.clone(),
|
name: state.config.event_name.clone(),
|
||||||
slug: state.config.event_slug.clone(),
|
slug: state.config.event_slug.clone(),
|
||||||
|
comments_enabled: state.config.comments_enabled,
|
||||||
|
theme_preset: config::get_str(cache, "theme_preset", &state.config.default_theme_preset)
|
||||||
|
.await,
|
||||||
|
theme_primary: config::get_str(cache, "theme_primary", &state.config.default_theme_primary)
|
||||||
|
.await,
|
||||||
|
theme_accent: config::get_str(cache, "theme_accent", &state.config.default_theme_accent)
|
||||||
|
.await,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -129,6 +129,12 @@ pub async fn add_comment(
|
|||||||
Path(upload_id): Path<Uuid>,
|
Path(upload_id): Path<Uuid>,
|
||||||
Json(body): Json<AddCommentRequest>,
|
Json(body): Json<AddCommentRequest>,
|
||||||
) -> Result<(StatusCode, Json<CommentDto>), AppError> {
|
) -> Result<(StatusCode, Json<CommentDto>), AppError> {
|
||||||
|
// Comments can be disabled instance-wide (env COMMENTS_ENABLED). The frontend hides
|
||||||
|
// the UI, but gate the API too so a stale client or direct call can't slip one in.
|
||||||
|
if !state.config.comments_enabled {
|
||||||
|
return Err(AppError::Forbidden("Kommentare sind deaktiviert.".into()));
|
||||||
|
}
|
||||||
|
|
||||||
let user = crate::models::user::User::find_by_id(&state.pool, auth.user_id)
|
let user = crate::models::user::User::find_by_id(&state.pool, auth.user_id)
|
||||||
.await?
|
.await?
|
||||||
.ok_or_else(|| AppError::NotFound("Benutzer nicht gefunden.".into()))?;
|
.ok_or_else(|| AppError::NotFound("Benutzer nicht gefunden.".into()))?;
|
||||||
|
|||||||
@@ -888,6 +888,10 @@ async fn run_html_export_inner(
|
|||||||
let data_json =
|
let data_json =
|
||||||
serde_json::to_string_pretty(&viewer_data).context("failed to serialize data.json")?;
|
serde_json::to_string_pretty(&viewer_data).context("failed to serialize data.json")?;
|
||||||
|
|
||||||
|
// Match the live app's colour theme in the offline keepsake.
|
||||||
|
let (theme_primary, theme_accent) = resolve_theme_seeds(pool).await;
|
||||||
|
let theme_css = theme_override_css(&theme_primary, &theme_accent);
|
||||||
|
|
||||||
let _ = update_progress(pool, event_id, "html", epoch, 72).await;
|
let _ = update_progress(pool, event_id, "html", epoch, 72).await;
|
||||||
|
|
||||||
// 5. Create ZIP (per-generation paths — see run_zip_export)
|
// 5. Create ZIP (per-generation paths — see run_zip_export)
|
||||||
@@ -903,7 +907,7 @@ async fn run_html_export_inner(
|
|||||||
// `window.__EXPORT_DATA__` global into index.html. Guests double-click
|
// `window.__EXPORT_DATA__` global into index.html. Guests double-click
|
||||||
// index.html (file://), where a cross-origin fetch() of a sibling file is
|
// index.html (file://), where a cross-origin fetch() of a sibling file is
|
||||||
// blocked — so the data must be inlined rather than fetched from data.json.
|
// blocked — so the data must be inlined rather than fetched from data.json.
|
||||||
write_viewer_with_data(&VIEWER_DIR, &mut zip, &data_json).await?;
|
write_viewer_with_data(&VIEWER_DIR, &mut zip, &data_json, theme_css.as_deref()).await?;
|
||||||
|
|
||||||
let _ = update_progress(pool, event_id, "html", epoch, 75).await;
|
let _ = update_progress(pool, event_id, "html", epoch, 75).await;
|
||||||
|
|
||||||
@@ -1313,6 +1317,7 @@ async fn write_viewer_with_data(
|
|||||||
dir: &include_dir::Dir<'_>,
|
dir: &include_dir::Dir<'_>,
|
||||||
zip: &mut ZipFileWriter<tokio::fs::File>,
|
zip: &mut ZipFileWriter<tokio::fs::File>,
|
||||||
data_json: &str,
|
data_json: &str,
|
||||||
|
theme_css: Option<&str>,
|
||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
for file in dir.files() {
|
for file in dir.files() {
|
||||||
let path = file.path().to_string_lossy().to_string();
|
let path = file.path().to_string_lossy().to_string();
|
||||||
@@ -1321,10 +1326,18 @@ async fn write_viewer_with_data(
|
|||||||
.context("export-viewer index.html is not valid UTF-8")?;
|
.context("export-viewer index.html is not valid UTF-8")?;
|
||||||
// Escape `</` so a caption containing `</script>` can't break out of the tag.
|
// Escape `</` so a caption containing `</script>` can't break out of the tag.
|
||||||
let safe = data_json.replace("</", "<\\/");
|
let safe = data_json.replace("</", "<\\/");
|
||||||
let script = format!("<script>window.__EXPORT_DATA__={safe};</script>");
|
// Match the live app's colour theme: inject the same `:root:root{…}` override
|
||||||
|
// the app builds at runtime so a rose/sage/custom event exports a rose/sage
|
||||||
|
// keepsake (not the embedded default gold). The CSS is generated purely from
|
||||||
|
// hex seeds (theme_override_css), so there's nothing to escape.
|
||||||
|
let mut head_inject = String::new();
|
||||||
|
if let Some(css) = theme_css {
|
||||||
|
head_inject.push_str(&format!("<style id=\"es-theme\">{css}</style>"));
|
||||||
|
}
|
||||||
|
head_inject.push_str(&format!("<script>window.__EXPORT_DATA__={safe};</script>"));
|
||||||
let injected = match html.find("</head>") {
|
let injected = match html.find("</head>") {
|
||||||
Some(idx) => format!("{}{}{}", &html[..idx], script, &html[idx..]),
|
Some(idx) => format!("{}{}{}", &html[..idx], head_inject, &html[idx..]),
|
||||||
None => format!("{script}{html}"),
|
None => format!("{head_inject}{html}"),
|
||||||
};
|
};
|
||||||
let builder = ZipEntryBuilder::new(path.into(), Compression::Deflate);
|
let builder = ZipEntryBuilder::new(path.into(), Compression::Deflate);
|
||||||
let mut entry = zip.write_entry_stream(builder).await?;
|
let mut entry = zip.write_entry_stream(builder).await?;
|
||||||
@@ -1340,11 +1353,101 @@ async fn write_viewer_with_data(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
for sub_dir in dir.dirs() {
|
for sub_dir in dir.dirs() {
|
||||||
Box::pin(write_viewer_with_data(sub_dir, zip, data_json)).await?;
|
Box::pin(write_viewer_with_data(sub_dir, zip, data_json, theme_css)).await?;
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Default champagne-gold seed — matches the hand-tuned ramp already embedded in the
|
||||||
|
/// viewer, so a default-themed event injects no override.
|
||||||
|
const KEEPSAKE_DEFAULT_SEED: &str = "#8a6a2b";
|
||||||
|
|
||||||
|
// stop → color-mix instruction. MIRRORS `LADDER` in frontend/src/lib/theme/palette.ts —
|
||||||
|
// keep the two in sync so an exported keepsake matches the live app pixel-for-pixel.
|
||||||
|
const THEME_LADDER: &[(u16, Option<&str>)] = &[
|
||||||
|
(50, Some("white 90%")),
|
||||||
|
(100, Some("white 80%")),
|
||||||
|
(200, Some("white 62%")),
|
||||||
|
(300, Some("white 42%")),
|
||||||
|
(400, Some("white 22%")),
|
||||||
|
(500, Some("white 9%")),
|
||||||
|
(600, None),
|
||||||
|
(700, Some("black 15%")),
|
||||||
|
(800, Some("black 30%")),
|
||||||
|
(900, Some("black 45%")),
|
||||||
|
(950, Some("black 63%")),
|
||||||
|
];
|
||||||
|
|
||||||
|
fn theme_stop_value(seed: &str, mix: Option<&str>) -> String {
|
||||||
|
match mix {
|
||||||
|
Some(m) => format!("color-mix(in oklab, {seed}, {m})"),
|
||||||
|
None => seed.to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn theme_ramp(families: &[&str], seed: &str) -> String {
|
||||||
|
let mut out = String::new();
|
||||||
|
for (stop, mix) in THEME_LADDER {
|
||||||
|
let val = theme_stop_value(seed, *mix);
|
||||||
|
for fam in families {
|
||||||
|
out.push_str(&format!("--color-{fam}-{stop}:{val};"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_hex_color(s: &str) -> bool {
|
||||||
|
let b = s.as_bytes();
|
||||||
|
b.len() == 7 && b[0] == b'#' && b[1..].iter().all(|c| c.is_ascii_hexdigit())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build the keepsake's `:root:root{…}` colour override from two seed colours, or None
|
||||||
|
/// for the default gold (viewer already carries that ramp) or an invalid seed (fall back
|
||||||
|
/// to the embedded default rather than emit unsafe CSS). MIRRORS `buildPaletteCss` in
|
||||||
|
/// frontend/src/lib/theme/palette.ts.
|
||||||
|
fn theme_override_css(primary: &str, accent: &str) -> Option<String> {
|
||||||
|
if !is_hex_color(primary) || !is_hex_color(accent) {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
if primary.eq_ignore_ascii_case(KEEPSAKE_DEFAULT_SEED)
|
||||||
|
&& accent.eq_ignore_ascii_case(KEEPSAKE_DEFAULT_SEED)
|
||||||
|
{
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let accent500 = theme_stop_value(accent, Some("white 9%"));
|
||||||
|
let mut css = String::from(":root:root{");
|
||||||
|
css.push_str(&theme_ramp(&["blue", "primary"], primary));
|
||||||
|
css.push_str(&theme_ramp(&["purple"], accent));
|
||||||
|
css.push_str(&format!(
|
||||||
|
"--color-violet-500:{accent500};--color-violet-600:{accent};"
|
||||||
|
));
|
||||||
|
css.push_str(&format!(
|
||||||
|
"--color-accent-500:{accent500};--color-accent-600:{accent};"
|
||||||
|
));
|
||||||
|
css.push('}');
|
||||||
|
Some(css)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Read the active theme seeds from the runtime `config` table (set by the admin UI),
|
||||||
|
/// falling back to the default gold. NOTE: an env-only default (THEME_PRIMARY set but
|
||||||
|
/// never saved via the admin UI) isn't stored in this table, so such a keepsake would
|
||||||
|
/// use gold; admin-set themes — the normal path — match the app exactly.
|
||||||
|
async fn resolve_theme_seeds(pool: &PgPool) -> (String, String) {
|
||||||
|
async fn read(pool: &PgPool, key: &str) -> String {
|
||||||
|
sqlx::query_scalar::<_, String>("SELECT value FROM config WHERE key = $1")
|
||||||
|
.bind(key)
|
||||||
|
.fetch_optional(pool)
|
||||||
|
.await
|
||||||
|
.ok()
|
||||||
|
.flatten()
|
||||||
|
.unwrap_or_else(|| KEEPSAKE_DEFAULT_SEED.to_string())
|
||||||
|
}
|
||||||
|
(
|
||||||
|
read(pool, "theme_primary").await,
|
||||||
|
read(pool, "theme_accent").await,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
fn ext_from_path(path: &str) -> &str {
|
fn ext_from_path(path: &str) -> &str {
|
||||||
path.rsplit('.').next().unwrap_or("bin")
|
path.rsplit('.').next().unwrap_or("bin")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,3 +11,31 @@ services:
|
|||||||
# is tolerated (warned) rather than rejected.
|
# is tolerated (warned) rather than rejected.
|
||||||
environment:
|
environment:
|
||||||
APP_ENV: development
|
APP_ENV: development
|
||||||
|
# `.env` sets DATABASE_URL to @localhost for the run-backend-natively workflow
|
||||||
|
# (the db port is published above for that). When the app runs IN a container,
|
||||||
|
# localhost is the app itself — point it at the `db` service instead. Creds are
|
||||||
|
# interpolated from .env so nothing is hardcoded.
|
||||||
|
DATABASE_URL: postgres://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB}
|
||||||
|
# `.env` sets MEDIA_PATH to a HOST path (/home/fabi/EventSnap/media) for the
|
||||||
|
# run-backend-natively workflow. In a container that path doesn't exist and the
|
||||||
|
# app user can't create it from `/`, so every upload 500s with EACCES. The media
|
||||||
|
# volume is mounted at /media (see docker-compose.yml) — point the app there.
|
||||||
|
MEDIA_PATH: /media
|
||||||
|
# Recent Docker Compose interpolates env_file values, so the `$` segments of the
|
||||||
|
# bcrypt ADMIN_PASSWORD_HASH in .env get eaten (the salt reads as an unset var) —
|
||||||
|
# every admin login then 401s. Re-supply it here with `$` doubled to `$$` so Compose
|
||||||
|
# passes the literal hash. NOTE: production (docker-compose.yml + .env) has the SAME
|
||||||
|
# bug — escape the hash as `$$` in .env, or set it via `environment:` there too.
|
||||||
|
ADMIN_PASSWORD_HASH: "$$2b$$12$$PAteqCNpsbm6d0HTJcywfOaUovjAU.iNVlsL7EDYaRC/z4P/xv7ye"
|
||||||
|
# Smoke-testing the comment kill-switch: boot-time flag, so it needs a restart
|
||||||
|
# (not an admin-UI toggle). Backend rejects new comments (403) and the frontend
|
||||||
|
# hides the whole comment UI. Flip back to true (or drop this line) to restore.
|
||||||
|
COMMENTS_ENABLED: "false"
|
||||||
|
|
||||||
|
caddy:
|
||||||
|
# The prod caddy service has no env_file, so the Caddyfile's `{$DOMAIN}` expands
|
||||||
|
# to empty and the site block collapses into a malformed global block. Supply it
|
||||||
|
# for local dev (from .env → localhost, which Caddy serves with a local self-signed
|
||||||
|
# cert). NOTE: the prod compose likely needs DOMAIN wired to caddy too.
|
||||||
|
environment:
|
||||||
|
DOMAIN: ${DOMAIN}
|
||||||
|
|||||||
@@ -33,6 +33,19 @@
|
|||||||
(pref === 'system' && window.matchMedia('(prefers-color-scheme: dark)').matches);
|
(pref === 'system' && window.matchMedia('(prefers-color-scheme: dark)').matches);
|
||||||
if (dark) document.documentElement.classList.add('dark');
|
if (dark) document.documentElement.classList.add('dark');
|
||||||
} catch (_) {}
|
} catch (_) {}
|
||||||
|
// Colour-theme FOUC guard: re-apply the cached palette override before paint so
|
||||||
|
// a custom/preset theme doesn't flash the default gold. The value is a ready-to-
|
||||||
|
// inject `:root:root{…}` string from event-config-store (empty for the default
|
||||||
|
// theme); refreshed from /event once the app mounts.
|
||||||
|
try {
|
||||||
|
var css = localStorage.getItem('eventsnap_palette_css');
|
||||||
|
if (css) {
|
||||||
|
var s = document.createElement('style');
|
||||||
|
s.id = 'es-theme';
|
||||||
|
s.textContent = css;
|
||||||
|
document.head.appendChild(s);
|
||||||
|
}
|
||||||
|
} catch (_) {}
|
||||||
})();
|
})();
|
||||||
</script>
|
</script>
|
||||||
%sveltekit.head%
|
%sveltekit.head%
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
import { avatarPalette, initials } from '$lib/avatar';
|
import { avatarPalette, initials } from '$lib/avatar';
|
||||||
import { vibrate } from '$lib/haptics';
|
import { vibrate } from '$lib/haptics';
|
||||||
import { now } from '$lib/now';
|
import { now } from '$lib/now';
|
||||||
|
import { commentsEnabled } from '$lib/event-config-store';
|
||||||
import HeartBurst from './HeartBurst.svelte';
|
import HeartBurst from './HeartBurst.svelte';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -205,19 +206,27 @@
|
|||||||
</svg>
|
</svg>
|
||||||
{upload.like_count}
|
{upload.like_count}
|
||||||
</button>
|
</button>
|
||||||
<button
|
{#if $commentsEnabled}
|
||||||
onclick={() => oncomment(upload.id)}
|
<button
|
||||||
class="flex items-center gap-1.5 text-sm font-medium text-gray-500 transition-colors hover:text-blue-500 active:text-blue-500 dark:text-gray-400 dark:hover:text-blue-400 dark:active:text-blue-400"
|
onclick={() => oncomment(upload.id)}
|
||||||
>
|
class="flex items-center gap-1.5 text-sm font-medium text-gray-500 transition-colors hover:text-blue-500 active:text-blue-500 dark:text-gray-400 dark:hover:text-blue-400 dark:active:text-blue-400"
|
||||||
<svg class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
>
|
||||||
<path
|
<svg
|
||||||
stroke-linecap="round"
|
class="h-5 w-5"
|
||||||
stroke-linejoin="round"
|
fill="none"
|
||||||
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"
|
viewBox="0 0 24 24"
|
||||||
/>
|
stroke="currentColor"
|
||||||
</svg>
|
stroke-width="2"
|
||||||
{upload.comment_count}
|
>
|
||||||
</button>
|
<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>
|
||||||
|
{/if}
|
||||||
{#if isOwn}
|
{#if isOwn}
|
||||||
<span class="ml-auto text-xs text-gray-400 dark:text-gray-500">Eigener Beitrag</span>
|
<span class="ml-auto text-xs text-gray-400 dark:text-gray-500">Eigener Beitrag</span>
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
@@ -11,6 +11,7 @@
|
|||||||
import { modalInert } from '$lib/actions/modal-inert';
|
import { modalInert } from '$lib/actions/modal-inert';
|
||||||
import { toastError } from '$lib/toast-store';
|
import { toastError } from '$lib/toast-store';
|
||||||
import { vibrate } from '$lib/haptics';
|
import { vibrate } from '$lib/haptics';
|
||||||
|
import { commentsEnabled } from '$lib/event-config-store';
|
||||||
import HeartBurst from './HeartBurst.svelte';
|
import HeartBurst from './HeartBurst.svelte';
|
||||||
|
|
||||||
const COMMENT_MAX = 500;
|
const COMMENT_MAX = 500;
|
||||||
@@ -224,81 +225,91 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Comments list -->
|
<!-- Comments list + composer — hidden entirely when comments are disabled instance-wide -->
|
||||||
<div class="flex-1 overflow-y-auto p-3">
|
{#if $commentsEnabled}
|
||||||
{#if comments.length === 0}
|
<div class="flex-1 overflow-y-auto p-3">
|
||||||
<p class="text-center text-sm text-gray-400 dark:text-gray-500">Noch keine Kommentare.</p>
|
{#if comments.length === 0}
|
||||||
{:else}
|
<p class="text-center text-sm text-gray-400 dark:text-gray-500">
|
||||||
<div class="space-y-3">
|
Noch keine Kommentare.
|
||||||
{#each comments as comment (comment.id)}
|
</p>
|
||||||
<div class="flex items-start gap-2">
|
{:else}
|
||||||
<div class="flex-1">
|
<div class="space-y-3">
|
||||||
<span class="text-sm font-medium text-gray-900 dark:text-gray-100"
|
{#each comments as comment (comment.id)}
|
||||||
>{comment.uploader_name}</span
|
<div class="flex items-start gap-2">
|
||||||
>
|
<div class="flex-1">
|
||||||
<span class="ml-1 text-sm text-gray-700 dark:text-gray-300">{comment.body}</span>
|
<span class="text-sm font-medium text-gray-900 dark:text-gray-100"
|
||||||
<div class="mt-0.5 text-xs text-gray-400 dark:text-gray-500">
|
>{comment.uploader_name}</span
|
||||||
{formatTime(comment.created_at)}
|
>
|
||||||
|
<span class="ml-1 text-sm text-gray-700 dark:text-gray-300">{comment.body}</span
|
||||||
|
>
|
||||||
|
<div class="mt-0.5 text-xs text-gray-400 dark:text-gray-500">
|
||||||
|
{formatTime(comment.created_at)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{#if comment.user_id === userId}
|
||||||
|
<button
|
||||||
|
onclick={() => deleteComment(comment.id)}
|
||||||
|
class="shrink-0 text-gray-400 hover:text-red-500 dark:text-gray-500 dark:hover:text-red-400"
|
||||||
|
aria-label="Löschen"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
class="h-3.5 w-3.5"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="2"
|
||||||
|
d="M6 18L18 6M6 6l12 12"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{#if comment.user_id === userId}
|
{/each}
|
||||||
<button
|
</div>
|
||||||
onclick={() => deleteComment(comment.id)}
|
{/if}
|
||||||
class="shrink-0 text-gray-400 hover:text-red-500 dark:text-gray-500 dark:hover:text-red-400"
|
</div>
|
||||||
aria-label="Löschen"
|
|
||||||
>
|
|
||||||
<svg class="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
||||||
<path
|
|
||||||
stroke-linecap="round"
|
|
||||||
stroke-linejoin="round"
|
|
||||||
stroke-width="2"
|
|
||||||
d="M6 18L18 6M6 6l12 12"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Comment input -->
|
<!-- Comment input -->
|
||||||
<form
|
<form
|
||||||
onsubmit={(e) => {
|
onsubmit={(e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
submitComment();
|
submitComment();
|
||||||
}}
|
}}
|
||||||
class="border-t border-gray-100 p-3 dark:border-gray-800"
|
class="border-t border-gray-100 p-3 dark:border-gray-800"
|
||||||
>
|
|
||||||
<div class="flex gap-2">
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
bind:value={newComment}
|
|
||||||
placeholder="Kommentar schreiben..."
|
|
||||||
maxlength={COMMENT_MAX}
|
|
||||||
class="input flex-1"
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
disabled={loading || !newComment.trim()}
|
|
||||||
class="btn btn-primary btn-sm"
|
|
||||||
>
|
|
||||||
Senden
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
class="mt-1 text-right text-xs"
|
|
||||||
class:text-gray-400={newComment.length < 450}
|
|
||||||
class:dark:text-gray-500={newComment.length < 450}
|
|
||||||
class:text-amber-600={newComment.length >= 450 && newComment.length < COMMENT_MAX}
|
|
||||||
class:dark:text-amber-400={newComment.length >= 450 && newComment.length < COMMENT_MAX}
|
|
||||||
class:text-red-600={newComment.length >= COMMENT_MAX}
|
|
||||||
class:dark:text-red-400={newComment.length >= COMMENT_MAX}
|
|
||||||
>
|
>
|
||||||
{newComment.length}/{COMMENT_MAX}
|
<div class="flex gap-2">
|
||||||
</div>
|
<input
|
||||||
</form>
|
type="text"
|
||||||
|
bind:value={newComment}
|
||||||
|
placeholder="Kommentar schreiben..."
|
||||||
|
maxlength={COMMENT_MAX}
|
||||||
|
class="input flex-1"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={loading || !newComment.trim()}
|
||||||
|
class="btn btn-primary btn-sm"
|
||||||
|
>
|
||||||
|
Senden
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="mt-1 text-right text-xs"
|
||||||
|
class:text-gray-400={newComment.length < 450}
|
||||||
|
class:dark:text-gray-500={newComment.length < 450}
|
||||||
|
class:text-amber-600={newComment.length >= 450 && newComment.length < COMMENT_MAX}
|
||||||
|
class:dark:text-amber-400={newComment.length >= 450 && newComment.length < COMMENT_MAX}
|
||||||
|
class:text-red-600={newComment.length >= COMMENT_MAX}
|
||||||
|
class:dark:text-red-400={newComment.length >= COMMENT_MAX}
|
||||||
|
>
|
||||||
|
{newComment.length}/{COMMENT_MAX}
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -14,9 +14,13 @@
|
|||||||
let { src, isVideo, durationMs, onended }: Props = $props();
|
let { src, isVideo, durationMs, onended }: Props = $props();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<!-- The fade lives in a scoped class (not an inline `style="animation:…"`): Svelte scopes
|
||||||
|
`@keyframes` names and only rewrites `animation` references inside this <style> block —
|
||||||
|
an inline animation name stays unscoped and silently never matches, so the fade would
|
||||||
|
never run. The dynamic duration is passed through as a CSS custom property instead. -->
|
||||||
<div
|
<div
|
||||||
class="absolute inset-0 flex items-center justify-center bg-black"
|
class="slide absolute inset-0 flex items-center justify-center bg-black"
|
||||||
style="animation: crossfade-in {durationMs}ms ease-out forwards;"
|
style="--fade-dur: {durationMs}ms"
|
||||||
>
|
>
|
||||||
{#if isVideo}
|
{#if isVideo}
|
||||||
<!-- svelte-ignore a11y_media_has_caption -->
|
<!-- svelte-ignore a11y_media_has_caption -->
|
||||||
@@ -27,6 +31,9 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
|
.slide {
|
||||||
|
animation: crossfade-in var(--fade-dur, 400ms) ease-out forwards;
|
||||||
|
}
|
||||||
@keyframes crossfade-in {
|
@keyframes crossfade-in {
|
||||||
from {
|
from {
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
|
|||||||
@@ -3,7 +3,8 @@
|
|||||||
//
|
//
|
||||||
// Adding a new animation:
|
// Adding a new animation:
|
||||||
// 1. Drop a Svelte file alongside crossfade.svelte / kenburns.svelte.
|
// 1. Drop a Svelte file alongside crossfade.svelte / kenburns.svelte.
|
||||||
// 2. Add one entry to `transitions` below.
|
// 2. Add one entry to `transitions` below (reuse a component with different `props`
|
||||||
|
// to expose variants — see the slide directions).
|
||||||
// 3. Rebuild — the popover updates automatically.
|
// 3. Rebuild — the popover updates automatically.
|
||||||
//
|
//
|
||||||
// This is the extensibility principle from docs/FEATURES.md §2.9 made concrete:
|
// This is the extensibility principle from docs/FEATURES.md §2.9 made concrete:
|
||||||
@@ -12,12 +13,16 @@
|
|||||||
import type { Component } from 'svelte';
|
import type { Component } from 'svelte';
|
||||||
import Crossfade from './crossfade.svelte';
|
import Crossfade from './crossfade.svelte';
|
||||||
import KenBurns from './kenburns.svelte';
|
import KenBurns from './kenburns.svelte';
|
||||||
|
import Slide from './slide.svelte';
|
||||||
|
|
||||||
export interface SlideTransition {
|
export interface SlideTransition {
|
||||||
id: string;
|
id: string;
|
||||||
label: string;
|
label: string;
|
||||||
defaultDurationMs: number;
|
defaultDurationMs: number;
|
||||||
component: Component<TransitionProps>;
|
component: Component<TransitionProps>;
|
||||||
|
/** Extra static props merged into the component — lets one component back several
|
||||||
|
* registry entries (e.g. the slide directions). */
|
||||||
|
props?: Partial<TransitionProps>;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Props every transition Svelte component receives. */
|
/** Props every transition Svelte component receives. */
|
||||||
@@ -27,6 +32,12 @@ export interface TransitionProps {
|
|||||||
durationMs: number;
|
durationMs: number;
|
||||||
/** Fired when a video slide finishes so the parent can advance early. */
|
/** Fired when a video slide finishes so the parent can advance early. */
|
||||||
onended?: () => void;
|
onended?: () => void;
|
||||||
|
/** Edge a slide transition enters from (ignored by non-slide components). */
|
||||||
|
direction?: 'up' | 'down' | 'left' | 'right';
|
||||||
|
/** Which half of a two-layer transition this instance plays: the incoming slide
|
||||||
|
* ('enter') or the outgoing one being pushed off ('exit'). Ignored by transitions
|
||||||
|
* that keep the previous frame static (crossfade, Ken Burns). */
|
||||||
|
phase?: 'enter' | 'exit';
|
||||||
}
|
}
|
||||||
|
|
||||||
export const transitions: SlideTransition[] = [
|
export const transitions: SlideTransition[] = [
|
||||||
@@ -41,6 +52,27 @@ export const transitions: SlideTransition[] = [
|
|||||||
label: 'Ken Burns',
|
label: 'Ken Burns',
|
||||||
defaultDurationMs: 600,
|
defaultDurationMs: 600,
|
||||||
component: KenBurns as unknown as Component<TransitionProps>
|
component: KenBurns as unknown as Component<TransitionProps>
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'slide-up',
|
||||||
|
label: 'Von unten',
|
||||||
|
defaultDurationMs: 1000,
|
||||||
|
component: Slide as unknown as Component<TransitionProps>,
|
||||||
|
props: { direction: 'up' }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'slide-left',
|
||||||
|
label: 'Von rechts',
|
||||||
|
defaultDurationMs: 1000,
|
||||||
|
component: Slide as unknown as Component<TransitionProps>,
|
||||||
|
props: { direction: 'left' }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'slide-right',
|
||||||
|
label: 'Von links',
|
||||||
|
defaultDurationMs: 1000,
|
||||||
|
component: Slide as unknown as Component<TransitionProps>,
|
||||||
|
props: { direction: 'right' }
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -18,9 +18,14 @@
|
|||||||
const panY = Math.round((Math.random() - 0.5) * 6);
|
const panY = Math.round((Math.random() - 0.5) * 6);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<!-- Both animations live in scoped classes rather than inline `style="animation:…"`.
|
||||||
|
Svelte scopes `@keyframes` names and only rewrites `animation` references inside this
|
||||||
|
<style> block, so an inline animation name never matches the scoped keyframe and the
|
||||||
|
effect silently dies. Dynamic values (fade duration, pan origin) pass through as inline
|
||||||
|
custom properties / properties that Svelte leaves untouched. -->
|
||||||
<div
|
<div
|
||||||
class="absolute inset-0 flex items-center justify-center overflow-hidden bg-black"
|
class="kb absolute inset-0 flex items-center justify-center overflow-hidden bg-black"
|
||||||
style="animation: kb-fade {durationMs}ms ease-out forwards;"
|
style="--fade-dur: {durationMs}ms"
|
||||||
>
|
>
|
||||||
{#if isVideo}
|
{#if isVideo}
|
||||||
<!-- svelte-ignore a11y_media_has_caption -->
|
<!-- svelte-ignore a11y_media_has_caption -->
|
||||||
@@ -29,13 +34,19 @@
|
|||||||
<img
|
<img
|
||||||
{src}
|
{src}
|
||||||
alt=""
|
alt=""
|
||||||
class="h-full w-full object-cover"
|
class="kb-img h-full w-full object-cover"
|
||||||
style="animation: kb-zoom 10s ease-out forwards; transform-origin: {50 + panX}% {50 + panY}%;"
|
style="transform-origin: {50 + panX}% {50 + panY}%;"
|
||||||
/>
|
/>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
|
.kb {
|
||||||
|
animation: kb-fade var(--fade-dur, 600ms) ease-out forwards;
|
||||||
|
}
|
||||||
|
.kb-img {
|
||||||
|
animation: kb-zoom 10s ease-out forwards;
|
||||||
|
}
|
||||||
@keyframes kb-fade {
|
@keyframes kb-fade {
|
||||||
from {
|
from {
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
|
|||||||
115
frontend/src/lib/diashow/transitions/slide.svelte
Normal file
115
frontend/src/lib/diashow/transitions/slide.svelte
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
// Slide transition: the incoming slide glides in from one edge while the outgoing slide
|
||||||
|
// is pushed off the opposite edge on the same axis — the pair travels together, edge to
|
||||||
|
// edge, like a filmstrip / conveyor. `phase` tells this instance which half it is:
|
||||||
|
// enter → starts offscreen, settles at centre (the new photo)
|
||||||
|
// exit → starts at centre, leaves offscreen the same way (the previous photo)
|
||||||
|
// The diashow's two-layer stack renders both at once, so the push reads as one motion.
|
||||||
|
//
|
||||||
|
// The animation lives in scoped classes, not an inline animation declaration: Svelte
|
||||||
|
// scopes keyframe names and only rewrites animation references inside the scoped style
|
||||||
|
// block, so an inline animation name silently never matches its keyframe. Dynamic values
|
||||||
|
// (duration, offsets) ride in as inline CSS custom properties, which pass through
|
||||||
|
// untouched. Switching the is-exit class swaps animation-name, which restarts the
|
||||||
|
// animation — the mechanism that lets the previous frame begin its exit.
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
src: string;
|
||||||
|
isVideo: boolean;
|
||||||
|
durationMs: number;
|
||||||
|
/** Fired when a video slide finishes so the parent can advance early. */
|
||||||
|
onended?: () => void;
|
||||||
|
/** Edge the slide enters from. */
|
||||||
|
direction?: 'up' | 'down' | 'left' | 'right';
|
||||||
|
/** Which half of the push this instance plays. */
|
||||||
|
phase?: 'enter' | 'exit';
|
||||||
|
}
|
||||||
|
|
||||||
|
let { src, isVideo, durationMs, onended, direction = 'up', phase = 'enter' }: Props = $props();
|
||||||
|
|
||||||
|
// Offscreen offsets. The incoming slide starts at `enterFrom` and ends centred; the
|
||||||
|
// outgoing slide ends at `exitTo` — the opposite edge on the same axis — so both travel
|
||||||
|
// the same direction and stay adjacent (no overlap, no gap).
|
||||||
|
const enterFrom = $derived(
|
||||||
|
{
|
||||||
|
up: 'translateY(100%)',
|
||||||
|
down: 'translateY(-100%)',
|
||||||
|
left: 'translateX(100%)',
|
||||||
|
right: 'translateX(-100%)'
|
||||||
|
}[direction]
|
||||||
|
);
|
||||||
|
const exitTo = $derived(
|
||||||
|
{
|
||||||
|
up: 'translateY(-100%)',
|
||||||
|
down: 'translateY(100%)',
|
||||||
|
left: 'translateX(-100%)',
|
||||||
|
right: 'translateX(100%)'
|
||||||
|
}[direction]
|
||||||
|
);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="slide-layer absolute inset-0 flex items-center justify-center bg-black"
|
||||||
|
class:is-exit={phase === 'exit'}
|
||||||
|
style="--slide-dur: {durationMs}ms; --enter-from: {enterFrom}; --exit-to: {exitTo};"
|
||||||
|
>
|
||||||
|
{#if isVideo}
|
||||||
|
<!-- svelte-ignore a11y_media_has_caption -->
|
||||||
|
<video {src} autoplay muted playsinline {onended} class="h-full w-full object-contain"></video>
|
||||||
|
{:else}
|
||||||
|
<img {src} alt="" class="h-full w-full object-contain" />
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.slide-layer {
|
||||||
|
/* easeInOutSine — a gentle slow → fast → slow curve so the push eases in and settles
|
||||||
|
softly rather than lurching from a fast start. */
|
||||||
|
animation: slide-enter var(--slide-dur, 1000ms) cubic-bezier(0.37, 0, 0.63, 1) forwards;
|
||||||
|
will-change: transform;
|
||||||
|
}
|
||||||
|
.slide-layer.is-exit {
|
||||||
|
animation-name: slide-exit;
|
||||||
|
}
|
||||||
|
@keyframes slide-enter {
|
||||||
|
from {
|
||||||
|
transform: var(--enter-from, translateY(100%));
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
transform: translate(0, 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@keyframes slide-exit {
|
||||||
|
from {
|
||||||
|
transform: translate(0, 0);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
transform: var(--exit-to, translateY(-100%));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@keyframes slide-fade {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@keyframes slide-fade-out {
|
||||||
|
from {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/* Respect users who ask for less motion — cross-dissolve instead of travelling. */
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.slide-layer {
|
||||||
|
animation: slide-fade var(--slide-dur, 1000ms) ease-out forwards;
|
||||||
|
}
|
||||||
|
.slide-layer.is-exit {
|
||||||
|
animation: slide-fade-out var(--slide-dur, 1000ms) ease-out forwards;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -7,33 +7,43 @@
|
|||||||
|
|
||||||
interface SentinelLike {
|
interface SentinelLike {
|
||||||
release: () => Promise<void>;
|
release: () => Promise<void>;
|
||||||
|
// WakeLockSentinel is an EventTarget; it fires `release` when the lock ends — including
|
||||||
|
// when the OS drops it because the tab was hidden.
|
||||||
|
addEventListener?: (type: 'release', listener: () => void) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type WakeLock = { request: (t: string) => Promise<SentinelLike> };
|
||||||
|
|
||||||
let sentinel: SentinelLike | null = null;
|
let sentinel: SentinelLike | null = null;
|
||||||
let visibilityHandler: (() => void) | null = null;
|
let visibilityHandler: (() => void) | null = null;
|
||||||
|
|
||||||
export async function acquireWakeLock(): Promise<void> {
|
async function request(wakeLock: WakeLock): Promise<void> {
|
||||||
const wakeLock = (
|
|
||||||
navigator as Navigator & { wakeLock?: { request: (t: string) => Promise<SentinelLike> } }
|
|
||||||
).wakeLock;
|
|
||||||
if (!wakeLock) return;
|
|
||||||
try {
|
try {
|
||||||
sentinel = await wakeLock.request('screen');
|
sentinel = await wakeLock.request('screen');
|
||||||
|
// The OS auto-releases the lock when the tab is hidden. Without clearing our handle
|
||||||
|
// on that event, the visibility handler's `sentinel === null` guard would never be
|
||||||
|
// true and the lock would never be re-acquired — the screen could then sleep after
|
||||||
|
// the first time the tab lost focus. Null it on release so re-acquire can fire.
|
||||||
|
sentinel.addEventListener?.('release', () => {
|
||||||
|
sentinel = null;
|
||||||
|
});
|
||||||
} catch {
|
} catch {
|
||||||
// User denied, or already released — nothing useful to do.
|
// User denied, or already released — nothing useful to do.
|
||||||
sentinel = null;
|
sentinel = null;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function acquireWakeLock(): Promise<void> {
|
||||||
|
const wakeLock = (navigator as Navigator & { wakeLock?: WakeLock }).wakeLock;
|
||||||
|
if (!wakeLock) return;
|
||||||
|
await request(wakeLock);
|
||||||
|
|
||||||
// Re-acquire when the page becomes visible again (the OS releases the lock
|
// Re-acquire when the page becomes visible again (the OS releases the lock
|
||||||
// while the tab is hidden).
|
// while the tab is hidden).
|
||||||
if (!visibilityHandler) {
|
if (!visibilityHandler) {
|
||||||
visibilityHandler = async () => {
|
visibilityHandler = () => {
|
||||||
if (document.visibilityState === 'visible' && sentinel === null) {
|
if (document.visibilityState === 'visible' && sentinel === null) {
|
||||||
try {
|
void request(wakeLock);
|
||||||
sentinel = await wakeLock.request('screen');
|
|
||||||
} catch {
|
|
||||||
sentinel = null;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
document.addEventListener('visibilitychange', visibilityHandler);
|
document.addEventListener('visibilitychange', visibilityHandler);
|
||||||
|
|||||||
60
frontend/src/lib/event-config-store.ts
Normal file
60
frontend/src/lib/event-config-store.ts
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
/**
|
||||||
|
* Public event presentation config: the colour theme and the comments feature flag,
|
||||||
|
* served unauthenticated from `GET /api/v1/event`. Loaded once on boot (and refreshed
|
||||||
|
* on the `event-updated` SSE) so the theme applies before the user has even joined and
|
||||||
|
* the comment UI reflects the instance setting.
|
||||||
|
*
|
||||||
|
* The resolved theme CSS is cached in localStorage so the boot script in app.html can
|
||||||
|
* paint the right colours before the JS bundle loads (no flash of the default palette).
|
||||||
|
*/
|
||||||
|
import { writable } from 'svelte/store';
|
||||||
|
import { buildPaletteCss, applyPaletteCss, type ThemeConfig } from './theme/palette';
|
||||||
|
|
||||||
|
export const PALETTE_CACHE_KEY = 'eventsnap_palette_css';
|
||||||
|
|
||||||
|
export type EventConfig = {
|
||||||
|
name: string;
|
||||||
|
slug: string;
|
||||||
|
commentsEnabled: boolean;
|
||||||
|
theme: ThemeConfig;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const eventConfig = writable<EventConfig | null>(null);
|
||||||
|
/** Convenience flag for the comment UI. Optimistic `true` until /event resolves. */
|
||||||
|
export const commentsEnabled = writable<boolean>(true);
|
||||||
|
|
||||||
|
/** Fetch /event, apply the theme, and cache the resolved CSS for the next boot. */
|
||||||
|
export async function loadEventConfig(): Promise<void> {
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/v1/event');
|
||||||
|
if (!res.ok) return;
|
||||||
|
const b = await res.json();
|
||||||
|
const theme: ThemeConfig = {
|
||||||
|
preset: b.theme_preset ?? 'champagne-gold',
|
||||||
|
primary: b.theme_primary ?? '#8a6a2b',
|
||||||
|
accent: b.theme_accent ?? '#8a6a2b'
|
||||||
|
};
|
||||||
|
eventConfig.set({
|
||||||
|
name: b.name,
|
||||||
|
slug: b.slug,
|
||||||
|
commentsEnabled: b.comments_enabled ?? true,
|
||||||
|
theme
|
||||||
|
});
|
||||||
|
commentsEnabled.set(b.comments_enabled ?? true);
|
||||||
|
|
||||||
|
const css = buildPaletteCss(theme);
|
||||||
|
applyPaletteCss(css);
|
||||||
|
try {
|
||||||
|
localStorage.setItem(PALETTE_CACHE_KEY, css);
|
||||||
|
} catch {
|
||||||
|
/* private mode / quota — the theme still applied for this session */
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
/* offline / server down — keep whatever the boot script already painted */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Apply a theme immediately without persisting — used by the admin live preview. */
|
||||||
|
export function previewTheme(theme: ThemeConfig): void {
|
||||||
|
applyPaletteCss(buildPaletteCss(theme));
|
||||||
|
}
|
||||||
120
frontend/src/lib/theme/palette.ts
Normal file
120
frontend/src/lib/theme/palette.ts
Normal file
@@ -0,0 +1,120 @@
|
|||||||
|
/**
|
||||||
|
* Runtime colour theming.
|
||||||
|
*
|
||||||
|
* The app's colours are Tailwind v4 `@theme` tokens that compile to CSS custom
|
||||||
|
* properties (`--color-primary-600` …) which every utility references as
|
||||||
|
* `var(...)`. So we can recolour the whole app at runtime by overriding those
|
||||||
|
* variables — no rebuild. We derive a full 50→950 ramp for the brand ("primary",
|
||||||
|
* which the app authored as `blue-*`) and accent (`purple-*`/`violet-*`/`accent-*`)
|
||||||
|
* families from two seed colours, using CSS `color-mix()` so the browser does the
|
||||||
|
* tint/shade math.
|
||||||
|
*
|
||||||
|
* Neutrals (`gray-*`, the pearl→graphite system) are deliberately NOT themed —
|
||||||
|
* keeping them fixed guarantees text/background contrast never regresses, and the
|
||||||
|
* design language treats greys as the constant that "carries the system".
|
||||||
|
*
|
||||||
|
* The default (champagne gold) produces an EMPTY override so the hand-tuned ramp in
|
||||||
|
* tailwind-theme.css shows through verbatim — zero regression for existing installs.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type ThemeConfig = { preset: string; primary: string; accent: string };
|
||||||
|
|
||||||
|
/** Champagne-gold seed — matches `--color-primary-600` in tailwind-theme.css. */
|
||||||
|
export const DEFAULT_SEED = '#8a6a2b';
|
||||||
|
|
||||||
|
export type Preset = { id: string; label: string; primary: string; accent: string };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Curated palettes. The seed is the brand colour at ramp stop 600 (the primary
|
||||||
|
* button / FAB), chosen dark enough that white button text clears WCAG AA.
|
||||||
|
* MIRROR: the ids are validated server-side in backend/src/handlers/admin.rs
|
||||||
|
* (THEME_PRESETS) — add a preset in both places.
|
||||||
|
*/
|
||||||
|
export const PRESETS: Preset[] = [
|
||||||
|
{ id: 'champagne-gold', label: 'Champagner-Gold', primary: '#8a6a2b', accent: '#8a6a2b' },
|
||||||
|
{ id: 'rose', label: 'Rosé', primary: '#9e3f5c', accent: '#9e3f5c' },
|
||||||
|
{ id: 'sage', label: 'Salbeigrün', primary: '#4d6a4d', accent: '#4d6a4d' },
|
||||||
|
{ id: 'dusk-blue', label: 'Dämmerblau', primary: '#395880', accent: '#395880' },
|
||||||
|
{ id: 'classic-silver', label: 'Klassisch Silber', primary: '#5b5b61', accent: '#5b5b61' }
|
||||||
|
];
|
||||||
|
|
||||||
|
/** Resolve a stored theme config to its two effective seed colours. */
|
||||||
|
export function resolveSeeds(cfg: ThemeConfig): { primary: string; accent: string } {
|
||||||
|
if (cfg.preset === 'custom') return { primary: cfg.primary, accent: cfg.accent };
|
||||||
|
const p = PRESETS.find((x) => x.id === cfg.preset);
|
||||||
|
return p
|
||||||
|
? { primary: p.primary, accent: p.accent }
|
||||||
|
: { primary: DEFAULT_SEED, accent: DEFAULT_SEED };
|
||||||
|
}
|
||||||
|
|
||||||
|
// stop → color-mix instruction against the seed. 600 is the seed itself; lighter
|
||||||
|
// stops mix toward white, darker toward black — in oklab for a perceptually even ramp.
|
||||||
|
const LADDER: Array<[number, string | null]> = [
|
||||||
|
[50, 'white 90%'],
|
||||||
|
[100, 'white 80%'],
|
||||||
|
[200, 'white 62%'],
|
||||||
|
[300, 'white 42%'],
|
||||||
|
[400, 'white 22%'],
|
||||||
|
[500, 'white 9%'],
|
||||||
|
[600, null],
|
||||||
|
[700, 'black 15%'],
|
||||||
|
[800, 'black 30%'],
|
||||||
|
[900, 'black 45%'],
|
||||||
|
[950, 'black 63%']
|
||||||
|
];
|
||||||
|
|
||||||
|
function stopValue(seed: string, mix: string | null): string {
|
||||||
|
return mix ? `color-mix(in oklab, ${seed}, ${mix})` : seed;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ramp(families: string[], seed: string): string {
|
||||||
|
let out = '';
|
||||||
|
for (const [stop, mix] of LADDER) {
|
||||||
|
const val = stopValue(seed, mix);
|
||||||
|
for (const fam of families) out += `--color-${fam}-${stop}:${val};`;
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build the `:root:root { … }` override for a theme. `:root:root` (specificity 0,2,0)
|
||||||
|
* beats Tailwind's `:root` `@theme` defaults regardless of stylesheet order, so the
|
||||||
|
* override wins whether it's injected before (boot) or after the bundle.
|
||||||
|
*
|
||||||
|
* Returns '' for the default gold, meaning "no override" → the verbatim hand-tuned
|
||||||
|
* ramp. Callers treat '' as "remove any injected style".
|
||||||
|
*/
|
||||||
|
export function buildPaletteCss(cfg: ThemeConfig): string {
|
||||||
|
const { primary, accent } = resolveSeeds(cfg);
|
||||||
|
const isDefault = primary.toLowerCase() === DEFAULT_SEED && accent.toLowerCase() === DEFAULT_SEED;
|
||||||
|
if (isDefault) return '';
|
||||||
|
|
||||||
|
const accent500 = stopValue(accent, 'white 9%');
|
||||||
|
return (
|
||||||
|
':root:root{' +
|
||||||
|
ramp(['blue', 'primary'], primary) +
|
||||||
|
ramp(['purple'], accent) +
|
||||||
|
// violet + accent aliases are only used at 500/600 in the app
|
||||||
|
`--color-violet-500:${accent500};--color-violet-600:${accent};` +
|
||||||
|
`--color-accent-500:${accent500};--color-accent-600:${accent};` +
|
||||||
|
'}'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const STYLE_ID = 'es-theme';
|
||||||
|
|
||||||
|
/** Inject/replace/remove the theme override `<style>`. Empty css removes it. */
|
||||||
|
export function applyPaletteCss(css: string): void {
|
||||||
|
if (typeof document === 'undefined') return;
|
||||||
|
let el = document.getElementById(STYLE_ID) as HTMLStyleElement | null;
|
||||||
|
if (!css) {
|
||||||
|
el?.remove();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!el) {
|
||||||
|
el = document.createElement('style');
|
||||||
|
el.id = STYLE_ID;
|
||||||
|
document.head.appendChild(el);
|
||||||
|
}
|
||||||
|
el.textContent = css;
|
||||||
|
}
|
||||||
@@ -16,6 +16,7 @@
|
|||||||
import { api } from '$lib/api';
|
import { api } from '$lib/api';
|
||||||
import type { MeContextDto } from '$lib/types';
|
import type { MeContextDto } from '$lib/types';
|
||||||
import { eventState, markClosed, markOpened, refreshEventState } from '$lib/event-state-store';
|
import { eventState, markClosed, markOpened, refreshEventState } from '$lib/event-state-store';
|
||||||
|
import { loadEventConfig } from '$lib/event-config-store';
|
||||||
|
|
||||||
let { children } = $props();
|
let { children } = $props();
|
||||||
|
|
||||||
@@ -37,6 +38,10 @@
|
|||||||
// Hooks up the appliedTheme → <html class="dark"> sync. Must run early so the
|
// Hooks up the appliedTheme → <html class="dark"> sync. Must run early so the
|
||||||
// first paint after hydration matches the saved preference.
|
// first paint after hydration matches the saved preference.
|
||||||
initTheme();
|
initTheme();
|
||||||
|
// Load the public event config (colour theme + comments flag) and apply the
|
||||||
|
// palette. Applies for authed and pre-auth (/join) alike; the app.html boot script
|
||||||
|
// already painted the cached palette, this reconciles it with the server.
|
||||||
|
void loadEventConfig();
|
||||||
// Hydrate cross-cutting stores once on boot if the user is already authenticated.
|
// Hydrate cross-cutting stores once on boot if the user is already authenticated.
|
||||||
// Page-level mounts will refresh again as needed.
|
// Page-level mounts will refresh again as needed.
|
||||||
if (getToken()) {
|
if (getToken()) {
|
||||||
@@ -80,7 +85,10 @@
|
|||||||
markClosed();
|
markClosed();
|
||||||
void refreshEventState();
|
void refreshEventState();
|
||||||
}),
|
}),
|
||||||
onSseEvent('event-opened', () => markOpened())
|
onSseEvent('event-opened', () => markOpened()),
|
||||||
|
// A host changed the theme (or privacy note) — re-fetch the public config so the
|
||||||
|
// palette updates live on every open client, not just after a reload.
|
||||||
|
onSseEvent('event-updated', () => void loadEventConfig())
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -431,8 +431,9 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Per-user quota widget -->
|
<!-- Per-user quota widget — staff-only (host/admin); guests never see the
|
||||||
{#if $quotaStore.enabled && $quotaStore.limit_bytes != null}
|
server-derived storage figures. -->
|
||||||
|
{#if (role === 'host' || role === 'admin') && $quotaStore.enabled && $quotaStore.limit_bytes != null}
|
||||||
<div class="card p-5">
|
<div class="card p-5">
|
||||||
<h2
|
<h2
|
||||||
class="mb-2 text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400"
|
class="mb-2 text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400"
|
||||||
|
|||||||
@@ -7,6 +7,9 @@
|
|||||||
import ConfirmSheet from '$lib/components/ConfirmSheet.svelte';
|
import ConfirmSheet from '$lib/components/ConfirmSheet.svelte';
|
||||||
import Modal from '$lib/components/Modal.svelte';
|
import Modal from '$lib/components/Modal.svelte';
|
||||||
import IconButton from '$lib/components/IconButton.svelte';
|
import IconButton from '$lib/components/IconButton.svelte';
|
||||||
|
import { PRESETS, DEFAULT_SEED, buildPaletteCss, type ThemeConfig } from '$lib/theme/palette';
|
||||||
|
import { previewTheme, PALETTE_CACHE_KEY, loadEventConfig } from '$lib/event-config-store';
|
||||||
|
import { onDestroy } from 'svelte';
|
||||||
|
|
||||||
interface StatsDto {
|
interface StatsDto {
|
||||||
user_count: number;
|
user_count: number;
|
||||||
@@ -122,6 +125,100 @@
|
|||||||
configDraft = { ...configDraft, [key]: isTrue(configDraft[key]) ? 'false' : 'true' };
|
configDraft = { ...configDraft, [key]: isTrue(configDraft[key]) ? 'false' : 'true' };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Colour theme ─────────────────────────────────────────────────────────
|
||||||
|
// The theme lives in the same `config` table but needs a bespoke UI (swatches +
|
||||||
|
// pickers + live preview) rather than the generic field renderer. Selecting a
|
||||||
|
// preset or nudging a colour recolours the whole admin page instantly (preview);
|
||||||
|
// Save persists it and refreshes every open client via the event-updated SSE.
|
||||||
|
let themePreset = $state('champagne-gold');
|
||||||
|
let themePrimary = $state(DEFAULT_SEED);
|
||||||
|
let themeAccent = $state(DEFAULT_SEED);
|
||||||
|
let themeSaving = $state(false);
|
||||||
|
// The persisted theme, so leaving without saving reverts the live preview.
|
||||||
|
let savedTheme: ThemeConfig = {
|
||||||
|
preset: 'champagne-gold',
|
||||||
|
primary: DEFAULT_SEED,
|
||||||
|
accent: DEFAULT_SEED
|
||||||
|
};
|
||||||
|
|
||||||
|
function currentTheme(): ThemeConfig {
|
||||||
|
return { preset: themePreset, primary: themePrimary, accent: themeAccent };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadTheme() {
|
||||||
|
try {
|
||||||
|
const b = await api.get<{
|
||||||
|
theme_preset: string;
|
||||||
|
theme_primary: string;
|
||||||
|
theme_accent: string;
|
||||||
|
}>('/event');
|
||||||
|
themePreset = b.theme_preset ?? 'champagne-gold';
|
||||||
|
themePrimary = b.theme_primary ?? DEFAULT_SEED;
|
||||||
|
themeAccent = b.theme_accent ?? DEFAULT_SEED;
|
||||||
|
savedTheme = currentTheme();
|
||||||
|
} catch {
|
||||||
|
/* keep defaults — the theme card just shows champagne-gold */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectPreset(id: string) {
|
||||||
|
themePreset = id;
|
||||||
|
if (id !== 'custom') {
|
||||||
|
const p = PRESETS.find((x) => x.id === id);
|
||||||
|
if (p) {
|
||||||
|
themePrimary = p.primary;
|
||||||
|
themeAccent = p.accent;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
previewTheme(currentTheme());
|
||||||
|
}
|
||||||
|
|
||||||
|
// A colour picker moved → force preset to 'custom' and preview live.
|
||||||
|
function onCustomColor() {
|
||||||
|
themePreset = 'custom';
|
||||||
|
previewTheme(currentTheme());
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveTheme() {
|
||||||
|
themeSaving = true;
|
||||||
|
try {
|
||||||
|
const theme = currentTheme();
|
||||||
|
await api.patch('/admin/config', {
|
||||||
|
theme_preset: theme.preset,
|
||||||
|
theme_primary: theme.primary,
|
||||||
|
theme_accent: theme.accent
|
||||||
|
});
|
||||||
|
savedTheme = theme;
|
||||||
|
try {
|
||||||
|
localStorage.setItem(PALETTE_CACHE_KEY, buildPaletteCss(theme));
|
||||||
|
} catch {
|
||||||
|
/* non-fatal */
|
||||||
|
}
|
||||||
|
toast('Farbschema gespeichert.', 'success');
|
||||||
|
} catch (e: unknown) {
|
||||||
|
toastError(e);
|
||||||
|
} finally {
|
||||||
|
themeSaving = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetTheme() {
|
||||||
|
selectPreset('champagne-gold');
|
||||||
|
}
|
||||||
|
|
||||||
|
const themeDirty = $derived(
|
||||||
|
themePreset !== savedTheme.preset ||
|
||||||
|
themePrimary.toLowerCase() !== savedTheme.primary.toLowerCase() ||
|
||||||
|
themeAccent.toLowerCase() !== savedTheme.accent.toLowerCase()
|
||||||
|
);
|
||||||
|
|
||||||
|
// Leaving the admin page without saving must not strand an unsaved preview on the
|
||||||
|
// rest of the app — revert to the persisted theme. loadEventConfig re-applies the
|
||||||
|
// authoritative palette + cache.
|
||||||
|
onDestroy(() => {
|
||||||
|
void loadEventConfig();
|
||||||
|
});
|
||||||
|
|
||||||
type AdminTab = 'stats' | 'config' | 'export' | 'users';
|
type AdminTab = 'stats' | 'config' | 'export' | 'users';
|
||||||
const TAB_LABELS: Record<AdminTab, string> = {
|
const TAB_LABELS: Record<AdminTab, string> = {
|
||||||
stats: 'Stats',
|
stats: 'Stats',
|
||||||
@@ -215,6 +312,7 @@
|
|||||||
api.get<UserSummary[]>('/host/users')
|
api.get<UserSummary[]>('/host/users')
|
||||||
]);
|
]);
|
||||||
configDraft = { ...config };
|
configDraft = { ...config };
|
||||||
|
void loadTheme();
|
||||||
} catch (e: unknown) {
|
} catch (e: unknown) {
|
||||||
error = e instanceof Error ? e.message : 'Fehler beim Laden.';
|
error = e instanceof Error ? e.message : 'Fehler beim Laden.';
|
||||||
} finally {
|
} finally {
|
||||||
@@ -591,6 +689,147 @@
|
|||||||
<!-- ── Config tab ───────────────────────────────────────────────── -->
|
<!-- ── Config tab ───────────────────────────────────────────────── -->
|
||||||
{:else if activeTab === 'config'}
|
{:else if activeTab === 'config'}
|
||||||
<div class="relative space-y-3 pb-20">
|
<div class="relative space-y-3 pb-20">
|
||||||
|
<!-- ── Colour theme ─────────────────────────────────────────── -->
|
||||||
|
<div class="card">
|
||||||
|
<div class="border-b border-gray-100 px-5 py-3 dark:border-gray-700">
|
||||||
|
<h3
|
||||||
|
class="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400"
|
||||||
|
>
|
||||||
|
Farbschema
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
<div class="space-y-4 px-5 py-4">
|
||||||
|
<p class="text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
Wähle eine Vorlage oder eigene Farben. Die Vorschau wird sofort angewendet;
|
||||||
|
„Speichern“ übernimmt sie für alle Gäste. Neutrale Töne (Grau/Silber) bleiben
|
||||||
|
bewusst unverändert.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<!-- Preset swatches -->
|
||||||
|
<div class="grid grid-cols-2 gap-2 sm:grid-cols-3">
|
||||||
|
{#each PRESETS as preset (preset.id)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={() => selectPreset(preset.id)}
|
||||||
|
class="flex items-center gap-2 rounded-lg border px-3 py-2 text-left text-sm transition-colors {themePreset ===
|
||||||
|
preset.id
|
||||||
|
? 'border-primary-400 bg-primary-50 dark:bg-primary-950/40'
|
||||||
|
: 'border-gray-200 hover:border-gray-300 dark:border-gray-700 dark:hover:border-gray-600'}"
|
||||||
|
aria-pressed={themePreset === preset.id}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class="h-5 w-5 shrink-0 rounded-full ring-1 ring-black/10"
|
||||||
|
style="background-color: {preset.primary}"
|
||||||
|
></span>
|
||||||
|
<span class="min-w-0 truncate text-gray-800 dark:text-gray-200"
|
||||||
|
>{preset.label}</span
|
||||||
|
>
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={() => selectPreset('custom')}
|
||||||
|
class="flex items-center gap-2 rounded-lg border px-3 py-2 text-left text-sm transition-colors {themePreset ===
|
||||||
|
'custom'
|
||||||
|
? 'border-primary-400 bg-primary-50 dark:bg-primary-950/40'
|
||||||
|
: 'border-gray-200 hover:border-gray-300 dark:border-gray-700 dark:hover:border-gray-600'}"
|
||||||
|
aria-pressed={themePreset === 'custom'}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class="h-5 w-5 shrink-0 rounded-full ring-1 ring-black/10"
|
||||||
|
style="background: conic-gradient(from 0deg, #b0506a, #c6a24a, #5c7a5c, #3f5f8a, #b0506a)"
|
||||||
|
></span>
|
||||||
|
<span class="text-gray-800 dark:text-gray-200">Eigene</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Custom colour pickers -->
|
||||||
|
{#if themePreset === 'custom'}
|
||||||
|
<div class="grid grid-cols-2 gap-3">
|
||||||
|
<label class="text-sm">
|
||||||
|
<span class="mb-1 block font-medium text-gray-700 dark:text-gray-300"
|
||||||
|
>Primär</span
|
||||||
|
>
|
||||||
|
<span class="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
type="color"
|
||||||
|
bind:value={themePrimary}
|
||||||
|
oninput={onCustomColor}
|
||||||
|
class="h-9 w-9 cursor-pointer rounded border border-gray-200 bg-transparent p-0.5 dark:border-gray-700"
|
||||||
|
aria-label="Primärfarbe"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
bind:value={themePrimary}
|
||||||
|
oninput={onCustomColor}
|
||||||
|
class="input font-mono text-sm uppercase"
|
||||||
|
maxlength="7"
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
<label class="text-sm">
|
||||||
|
<span class="mb-1 block font-medium text-gray-700 dark:text-gray-300"
|
||||||
|
>Akzent</span
|
||||||
|
>
|
||||||
|
<span class="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
type="color"
|
||||||
|
bind:value={themeAccent}
|
||||||
|
oninput={onCustomColor}
|
||||||
|
class="h-9 w-9 cursor-pointer rounded border border-gray-200 bg-transparent p-0.5 dark:border-gray-700"
|
||||||
|
aria-label="Akzentfarbe"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
bind:value={themeAccent}
|
||||||
|
oninput={onCustomColor}
|
||||||
|
class="input font-mono text-sm uppercase"
|
||||||
|
maxlength="7"
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<p class="text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
Die gewählte Farbe ist der Button-/Signalton; hellere und dunklere Abstufungen
|
||||||
|
werden automatisch daraus abgeleitet. Für gute Lesbarkeit reicht ein kräftiger,
|
||||||
|
eher dunkler Ton (weiße Button-Schrift).
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Mini live preview -->
|
||||||
|
<div
|
||||||
|
class="flex flex-wrap items-center gap-2 rounded-lg bg-gray-50 p-3 dark:bg-gray-800/50"
|
||||||
|
>
|
||||||
|
<button type="button" class="btn btn-primary btn-sm" tabindex="-1"
|
||||||
|
>Beispiel-Button</button
|
||||||
|
>
|
||||||
|
<span class="chip chip-active" style="pointer-events: none">#hochzeit</span>
|
||||||
|
<span class="text-sm font-medium text-primary-600 dark:text-primary-400"
|
||||||
|
>Akzenttext</span
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={saveTheme}
|
||||||
|
disabled={themeSaving || !themeDirty}
|
||||||
|
class="btn btn-primary btn-sm"
|
||||||
|
>
|
||||||
|
{themeSaving ? 'Wird gespeichert…' : 'Farbschema speichern'}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={resetTheme}
|
||||||
|
class="btn btn-secondary btn-sm"
|
||||||
|
disabled={themePreset === 'champagne-gold'}
|
||||||
|
>
|
||||||
|
Zurücksetzen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{#each CONFIG_GROUPS as group (group.title)}
|
{#each CONFIG_GROUPS as group (group.title)}
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<div class="border-b border-gray-100 px-5 py-3 dark:border-gray-700">
|
<div class="border-b border-gray-100 px-5 py-3 dark:border-gray-700">
|
||||||
|
|||||||
@@ -15,17 +15,36 @@
|
|||||||
|
|
||||||
let queue = new SlideQueue();
|
let queue = new SlideQueue();
|
||||||
let current = $state<FeedUpload | null>(null);
|
let current = $state<FeedUpload | null>(null);
|
||||||
|
// Two-layer crossfade. Previously the diashow rendered only `current` inside a
|
||||||
|
// `{#key current.id}` block, so on every advance Svelte destroyed the old slide the
|
||||||
|
// instant the id changed and mounted the new one starting at opacity 0 over the black
|
||||||
|
// backdrop — the old image vanished to black, then the new one faded up (and only
|
||||||
|
// popped in once it decoded). That's the "black flash". Instead we keep the outgoing
|
||||||
|
// slide fully opaque *underneath* the incoming one while it fades in, then drop it —
|
||||||
|
// there's always an opaque frame on screen, so the black never shows through.
|
||||||
|
type SlideLayer = { key: string; src: string; isVideo: boolean; phase: 'enter' | 'exit' };
|
||||||
|
let layers = $state<SlideLayer[]>([]);
|
||||||
|
let dropPrevTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
// Guards the async (decode-gated) layer commit against a newer advance superseding it.
|
||||||
|
let slideToken = 0;
|
||||||
let dwellMs = $state(6000);
|
let dwellMs = $state(6000);
|
||||||
let transitionId = $state('crossfade');
|
let transitionId = $state('crossfade');
|
||||||
let paused = $state(false);
|
let paused = $state(false);
|
||||||
let showOverlay = $state(false);
|
let showOverlay = $state(false);
|
||||||
let overlayHideTimer: ReturnType<typeof setTimeout> | null = null;
|
|
||||||
let advanceTimer: ReturnType<typeof setTimeout> | null = null;
|
let advanceTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
const unsubs: Array<() => void> = [];
|
const unsubs: Array<() => void> = [];
|
||||||
|
|
||||||
|
// Controls auto-hide: the top-right cluster shows on pointer/keyboard activity and fades
|
||||||
|
// after a few seconds idle (the cursor hides with it) so a projector shows only photos.
|
||||||
|
let controlsVisible = $state(true);
|
||||||
|
let controlsHideTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
// Fullscreen toggle via the Fullscreen API — an in-app button, independent of the
|
||||||
|
// browser's own F11 chrome. `isFullscreen` mirrors the actual state via fullscreenchange.
|
||||||
|
let isFullscreen = $state(false);
|
||||||
|
let rootEl: HTMLDivElement | undefined = $state();
|
||||||
|
|
||||||
const transitionDef = $derived(findTransition(transitionId));
|
const transitionDef = $derived(findTransition(transitionId));
|
||||||
|
|
||||||
const mediaSrc = $derived(current ? pickMediaUrl($dataMode, current) : '');
|
|
||||||
const isVideo = $derived(current?.mime_type.startsWith('video/') ?? false);
|
const isVideo = $derived(current?.mime_type.startsWith('video/') ?? false);
|
||||||
|
|
||||||
function isEmpty(): boolean {
|
function isEmpty(): boolean {
|
||||||
@@ -49,10 +68,63 @@
|
|||||||
|
|
||||||
function advance() {
|
function advance() {
|
||||||
const next = queue.next();
|
const next = queue.next();
|
||||||
current = next;
|
showSlide(next);
|
||||||
if (current) scheduleNext();
|
if (current) scheduleNext();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Swap in the next slide as the top layer while holding the outgoing one beneath it
|
||||||
|
// for the length of the transition. For images we decode the incoming frame first so
|
||||||
|
// the fade reveals a painted picture rather than a blank one; the outgoing slide stays
|
||||||
|
// visible until then, so there's never a black gap. Videos can't be pre-decoded, so
|
||||||
|
// they swap in immediately.
|
||||||
|
function showSlide(next: FeedUpload | null) {
|
||||||
|
const token = ++slideToken;
|
||||||
|
if (dropPrevTimer) {
|
||||||
|
clearTimeout(dropPrevTimer);
|
||||||
|
dropPrevTimer = null;
|
||||||
|
}
|
||||||
|
current = next;
|
||||||
|
if (!next) {
|
||||||
|
layers = [];
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const slide: SlideLayer = {
|
||||||
|
key: next.id,
|
||||||
|
src: pickMediaUrl($dataMode, next),
|
||||||
|
isVideo: next.mime_type.startsWith('video/'),
|
||||||
|
phase: 'enter'
|
||||||
|
};
|
||||||
|
const prev = layers.length ? layers[layers.length - 1] : null;
|
||||||
|
// Re-showing the same slide (e.g. a feed re-fetch resolved to the current head) —
|
||||||
|
// just replace it; never stack a slide on top of itself.
|
||||||
|
if (prev && prev.key === slide.key) {
|
||||||
|
layers = [slide];
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const commit = () => {
|
||||||
|
if (token !== slideToken) return; // superseded by a newer advance — drop this frame
|
||||||
|
// Mark the outgoing frame as exiting so slide transitions push it off in step with
|
||||||
|
// the incoming one. Crossfade/Ken Burns ignore `phase` and just hold it opaque.
|
||||||
|
const exiting = prev ? { ...prev, phase: 'exit' as const } : null;
|
||||||
|
layers = exiting ? [exiting, slide] : [slide];
|
||||||
|
if (exiting) {
|
||||||
|
dropPrevTimer = setTimeout(() => {
|
||||||
|
dropPrevTimer = null;
|
||||||
|
layers = layers.filter((l) => l.key !== exiting.key);
|
||||||
|
}, transitionDef.defaultDurationMs + 80);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if (slide.isVideo) {
|
||||||
|
commit();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const pre = new Image();
|
||||||
|
pre.src = slide.src;
|
||||||
|
// decode() resolves once the bitmap is ready to paint; on failure just commit anyway
|
||||||
|
// (a broken image should still advance the show rather than stall it).
|
||||||
|
pre.decode().then(commit).catch(commit);
|
||||||
|
}
|
||||||
|
|
||||||
// A video finished before its dwell/12s cap — advance immediately (unless paused).
|
// A video finished before its dwell/12s cap — advance immediately (unless paused).
|
||||||
// The {#key current.id} block destroys the old <video> on advance, so a fallback
|
// The {#key current.id} block destroys the old <video> on advance, so a fallback
|
||||||
// timer that already fired can't trigger this handler for a stale slide.
|
// timer that already fired can't trigger this handler for a stale slide.
|
||||||
@@ -178,18 +250,37 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function revealOverlay() {
|
// Reveal the control cluster on any pointer/keyboard activity and re-arm the idle timer.
|
||||||
showOverlay = true;
|
// Controls stay up while the settings panel is open so it can't vanish mid-interaction.
|
||||||
if (overlayHideTimer) clearTimeout(overlayHideTimer);
|
function showControls() {
|
||||||
overlayHideTimer = setTimeout(() => (showOverlay = false), 4000);
|
controlsVisible = true;
|
||||||
|
if (controlsHideTimer) clearTimeout(controlsHideTimer);
|
||||||
|
controlsHideTimer = setTimeout(() => {
|
||||||
|
if (!showOverlay) controlsVisible = false;
|
||||||
|
}, 3000);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Manual toggle from the always-visible control button — stays open (no
|
// Manual toggle from the control button — stays open (no auto-hide) so keyboard users
|
||||||
// auto-hide) so keyboard users can tab through the controls without them
|
// can tab through the settings without them vanishing mid-interaction.
|
||||||
// vanishing mid-interaction.
|
|
||||||
function toggleOverlay() {
|
function toggleOverlay() {
|
||||||
if (overlayHideTimer) clearTimeout(overlayHideTimer);
|
|
||||||
showOverlay = !showOverlay;
|
showOverlay = !showOverlay;
|
||||||
|
showControls();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function toggleFullscreen() {
|
||||||
|
try {
|
||||||
|
if (document.fullscreenElement) {
|
||||||
|
await document.exitFullscreen();
|
||||||
|
} else {
|
||||||
|
await (rootEl ?? document.documentElement).requestFullscreen();
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Fullscreen can be refused (missing user gesture, unsupported) — non-fatal.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleFullscreenChange() {
|
||||||
|
isFullscreen = document.fullscreenElement !== null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function togglePause() {
|
function togglePause() {
|
||||||
@@ -206,7 +297,10 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function handleKeydown(e: KeyboardEvent) {
|
function handleKeydown(e: KeyboardEvent) {
|
||||||
|
showControls();
|
||||||
if (e.key === 'Escape') {
|
if (e.key === 'Escape') {
|
||||||
|
// In fullscreen the browser exits on Escape — don't ALSO navigate away.
|
||||||
|
if (isFullscreen) return;
|
||||||
if (showOverlay) {
|
if (showOverlay) {
|
||||||
showOverlay = false;
|
showOverlay = false;
|
||||||
} else {
|
} else {
|
||||||
@@ -217,6 +311,10 @@
|
|||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
togglePause();
|
togglePause();
|
||||||
}
|
}
|
||||||
|
if (e.key === 'f' || e.key === 'F') {
|
||||||
|
e.preventDefault();
|
||||||
|
void toggleFullscreen();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
@@ -229,6 +327,8 @@
|
|||||||
}
|
}
|
||||||
showBottomNav.set(false);
|
showBottomNav.set(false);
|
||||||
void acquireWakeLock();
|
void acquireWakeLock();
|
||||||
|
document.addEventListener('fullscreenchange', handleFullscreenChange);
|
||||||
|
showControls(); // show briefly on entry, then fade after the idle timeout
|
||||||
unsubs.push(onSseEvent('upload-processed', handleUploadProcessed));
|
unsubs.push(onSseEvent('upload-processed', handleUploadProcessed));
|
||||||
unsubs.push(onSseEvent('upload-deleted', handleUploadDeleted));
|
unsubs.push(onSseEvent('upload-deleted', handleUploadDeleted));
|
||||||
unsubs.push(onSseEvent('user-hidden', handleUserHidden));
|
unsubs.push(onSseEvent('user-hidden', handleUserHidden));
|
||||||
@@ -246,8 +346,12 @@
|
|||||||
onDestroy(() => {
|
onDestroy(() => {
|
||||||
showBottomNav.set(true);
|
showBottomNav.set(true);
|
||||||
clearTimer();
|
clearTimer();
|
||||||
if (overlayHideTimer) clearTimeout(overlayHideTimer);
|
if (controlsHideTimer) clearTimeout(controlsHideTimer);
|
||||||
if (processedDebounce) clearTimeout(processedDebounce);
|
if (processedDebounce) clearTimeout(processedDebounce);
|
||||||
|
if (dropPrevTimer) clearTimeout(dropPrevTimer);
|
||||||
|
document.removeEventListener('fullscreenchange', handleFullscreenChange);
|
||||||
|
// Leave fullscreen when exiting the show, so /feed isn't stuck fullscreen.
|
||||||
|
if (document.fullscreenElement) void document.exitFullscreen().catch(() => {});
|
||||||
void releaseWakeLock();
|
void releaseWakeLock();
|
||||||
for (const unsub of unsubs) unsub();
|
for (const unsub of unsubs) unsub();
|
||||||
disconnectSse();
|
disconnectSse();
|
||||||
@@ -257,19 +361,24 @@
|
|||||||
<svelte:window onkeydown={handleKeydown} />
|
<svelte:window onkeydown={handleKeydown} />
|
||||||
|
|
||||||
<div
|
<div
|
||||||
|
bind:this={rootEl}
|
||||||
class="fixed inset-0 z-[60] flex items-center justify-center bg-black text-white"
|
class="fixed inset-0 z-[60] flex items-center justify-center bg-black text-white"
|
||||||
|
class:cursor-none={!controlsVisible}
|
||||||
role="presentation"
|
role="presentation"
|
||||||
onclick={revealOverlay}
|
onpointermove={showControls}
|
||||||
|
onpointerdown={showControls}
|
||||||
>
|
>
|
||||||
{#if current}
|
{#if layers.length}
|
||||||
{#key current.id + '|' + transitionDef.id}
|
{#each layers as layer (layer.key)}
|
||||||
<transitionDef.component
|
<transitionDef.component
|
||||||
src={mediaSrc}
|
src={layer.src}
|
||||||
{isVideo}
|
isVideo={layer.isVideo}
|
||||||
durationMs={transitionDef.defaultDurationMs}
|
durationMs={transitionDef.defaultDurationMs}
|
||||||
onended={handleVideoEnded}
|
phase={layer.phase}
|
||||||
|
onended={layer.key === current?.id ? handleVideoEnded : undefined}
|
||||||
|
{...transitionDef.props ?? {}}
|
||||||
/>
|
/>
|
||||||
{/key}
|
{/each}
|
||||||
{:else if isEmpty()}
|
{:else if isEmpty()}
|
||||||
<div class="text-center">
|
<div class="text-center">
|
||||||
<p class="text-2xl font-semibold">Noch keine Beiträge</p>
|
<p class="text-2xl font-semibold">Noch keine Beiträge</p>
|
||||||
@@ -279,10 +388,12 @@
|
|||||||
<div class="text-white/60">Lade…</div>
|
<div class="text-white/60">Lade…</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<!-- Always-visible controls: keyboard-reachable, so the show is never a trap and
|
<!-- Controls appear on pointer/keyboard activity and fade when idle. Still fully
|
||||||
the controls are reachable without a pointer. Honours the notch. -->
|
keyboard-reachable (any key reveals them), so the show is never a trap. Notch-safe. -->
|
||||||
<div
|
<div
|
||||||
class="absolute right-0 top-0 z-10 flex gap-2 p-3"
|
class="absolute right-0 top-0 z-10 flex gap-2 p-3 transition-opacity duration-300"
|
||||||
|
class:opacity-0={!controlsVisible}
|
||||||
|
class:pointer-events-none={!controlsVisible}
|
||||||
style="padding-top: calc(env(safe-area-inset-top) + 0.75rem)"
|
style="padding-top: calc(env(safe-area-inset-top) + 0.75rem)"
|
||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
@@ -303,6 +414,36 @@
|
|||||||
/>
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
void toggleFullscreen();
|
||||||
|
}}
|
||||||
|
aria-label={isFullscreen ? 'Vollbild beenden' : 'Vollbild'}
|
||||||
|
aria-pressed={isFullscreen}
|
||||||
|
class="inline-flex h-11 w-11 items-center justify-center rounded-full bg-white/10 text-white hover:bg-white/20"
|
||||||
|
>
|
||||||
|
{#if isFullscreen}
|
||||||
|
<!-- arrows pointing in -->
|
||||||
|
<svg class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.8">
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
d="M9 9V4.5M9 9H4.5M9 9 3.75 3.75M9 15v4.5M9 15H4.5M9 15l-5.25 5.25M15 9h4.5M15 9V4.5M15 9l5.25-5.25M15 15h4.5M15 15v4.5m0-4.5 5.25 5.25"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
{:else}
|
||||||
|
<!-- arrows pointing out -->
|
||||||
|
<svg class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.8">
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
d="M3.75 3.75v4.5m0-4.5h4.5m-4.5 0L9 9M3.75 20.25v-4.5m0 4.5h4.5m-4.5 0L9 15M20.25 3.75h-4.5m4.5 0v4.5m0-4.5L15 9m5.25 11.25h-4.5m4.5 0v-4.5m0 4.5L15 15"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
{/if}
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onclick={(e) => {
|
onclick={(e) => {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { goto } from '$app/navigation';
|
import { goto } from '$app/navigation';
|
||||||
import { getToken } from '$lib/auth';
|
import { getToken, getRole } from '$lib/auth';
|
||||||
import { addToQueue, loadQueue } from '$lib/upload-queue';
|
import { addToQueue, loadQueue } from '$lib/upload-queue';
|
||||||
import { toast } from '$lib/toast-store';
|
import { toast } from '$lib/toast-store';
|
||||||
import { showBottomNav } from '$lib/ui-store';
|
import { showBottomNav } from '$lib/ui-store';
|
||||||
@@ -24,6 +24,11 @@
|
|||||||
|
|
||||||
const MAX_CAPTION_LENGTH = 2000;
|
const MAX_CAPTION_LENGTH = 2000;
|
||||||
|
|
||||||
|
// The storage widget is staff-only (host/admin). Guests never see server-derived
|
||||||
|
// storage figures — the backend also zeroes the raw-disk fields for non-staff, so
|
||||||
|
// this is UI-consistency on top of an API guarantee, not the security boundary.
|
||||||
|
const isStaff = getRole() === 'host' || getRole() === 'admin';
|
||||||
|
|
||||||
// Quick-tag chips derived from caption as the user types
|
// Quick-tag chips derived from caption as the user types
|
||||||
let captionTags = $derived.by(() => {
|
let captionTags = $derived.by(() => {
|
||||||
const matches = [...caption.matchAll(/#(\w+)/g)];
|
const matches = [...caption.matchAll(/#(\w+)/g)];
|
||||||
@@ -254,8 +259,9 @@
|
|||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<!-- Per-user quota — hidden when admin disabled enforcement -->
|
<!-- Per-user quota — staff-only (never shown to guests), and also hidden when
|
||||||
{#if $quotaStore.enabled && $quotaStore.limit_bytes != null}
|
admin disabled enforcement. -->
|
||||||
|
{#if isStaff && $quotaStore.enabled && $quotaStore.limit_bytes != null}
|
||||||
<div class="px-4 pt-3 text-xs text-gray-500 dark:text-gray-400">
|
<div class="px-4 pt-3 text-xs text-gray-500 dark:text-gray-400">
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<span
|
<span
|
||||||
|
|||||||
Reference in New Issue
Block a user