Merge: promote dev to prod

This commit is contained in:
2026-07-15 19:54:07 +02:00
13 changed files with 142 additions and 96 deletions
+33 -23
View File
@@ -1,20 +1,27 @@
# syntax=docker/dockerfile:1
# Build/version info. APP_VERSION comes from the committed VERSION file (always correct,
# even for a plain `docker compose build`); GIT_SHA/BUILD_DATE are best-effort build-args
# the deploy scripts pass. An explicit APP_VERSION build-arg still overrides the file.
#
# GIT_SHA/BUILD_DATE differ on EVERY build: declare them as late as possible in each stage —
# anything below them can never cache.
ARG APP_VERSION=
ARG GIT_SHA=unknown
ARG BUILD_DATE=
# Stage 1: build the frontend SPA
FROM node:20-alpine AS frontend
WORKDIR /fe
# Deps first, keyed on the lockfile, so editing app source never re-resolves node_modules.
COPY frontend/package.json frontend/package-lock.json ./
RUN --mount=type=cache,target=/root/.npm npm ci
COPY frontend/ ./
COPY VERSION ./.version
# Version args land here — immediately before the only step that reads them — so they cannot
# invalidate the dependency layers above.
ARG APP_VERSION
ARG GIT_SHA
ARG BUILD_DATE
WORKDIR /fe
COPY frontend/package.json ./
RUN npm install
COPY frontend/ ./
COPY VERSION ./.version
# Vite inlines VITE_*-prefixed env at build time; version falls back to the VERSION file.
RUN VITE_APP_VERSION="${APP_VERSION:-$(cat ./.version)}" \
VITE_GIT_SHA="$GIT_SHA" \
@@ -23,17 +30,21 @@ RUN VITE_APP_VERSION="${APP_VERSION:-$(cat ./.version)}" \
# Stage 2: backend runtime, serving the built SPA
FROM python:3.12-slim
ARG GIT_SHA
ARG BUILD_DATE
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 \
PIP_NO_CACHE_DIR=1 \
PIP_DISABLE_PIP_VERSION_CHECK=1 \
GIT_SHA=$GIT_SHA \
BUILD_DATE=$BUILD_DATE
PIP_DISABLE_PIP_VERSION_CHECK=1
WORKDIR /app
# Create the app user BEFORE the copies so files land already-owned via COPY --chown (a trailing
# `chown -R /app` would rewrite every file into a duplicate layer). /downloads must be appuser-owned
# so a fresh named volume mounted there inherits that ownership (Docker copies the image dir's
# ownership into a new empty volume) — the worker/API can then write without a manual chown. A bind
# mount to a host path instead needs that path writable by this uid (see docs/self-hosting.md).
RUN adduser --disabled-password --gecos "" appuser \
&& mkdir -p /downloads \
&& chown appuser /app /downloads
# ffmpeg: merge separate video+audio streams + postprocessing. Deno: the JavaScript runtime
# yt-dlp uses to solve YouTube's "n" signature challenge for the web player clients (required
# alongside the PO token, else only storyboards are offered). yt-dlp auto-detects deno on PATH.
@@ -47,21 +58,20 @@ RUN apt-get update \
&& rm -rf /var/lib/apt/lists/*
COPY backend/requirements.txt .
RUN pip install --upgrade pip && pip install -r requirements.txt
RUN --mount=type=cache,target=/root/.cache/pip \
pip install --upgrade pip && pip install -r requirements.txt
COPY backend/ .
COPY --chown=appuser backend/ .
# Read at runtime by config.py as the app_version (a committed single source of truth).
COPY VERSION ./VERSION
COPY --from=frontend /fe/dist ./app/static_spa
COPY --chown=appuser VERSION ./VERSION
COPY --chown=appuser --from=frontend /fe/dist ./app/static_spa
# Version stamps LAST — keep them below everything expensive (see the header note).
ARG GIT_SHA
ARG BUILD_DATE
ENV GIT_SHA=$GIT_SHA \
BUILD_DATE=$BUILD_DATE
# Create the download-center mount point owned by the app user, so a fresh named volume mounted
# at /downloads inherits appuser ownership (Docker copies the image dir's ownership into a new
# empty volume) — the worker/API can then write there without a manual chown. A bind mount to a
# host path instead needs that path writable by this uid (see docs/self-hosting.md).
RUN adduser --disabled-password --gecos "" appuser \
&& mkdir -p /downloads \
&& chown -R appuser /app /downloads
USER appuser
EXPOSE 8000
CMD ["sh", "entrypoint.sh"]
+1 -1
View File
@@ -1 +1 @@
0.42.0
0.43.0
+4 -3
View File
@@ -369,8 +369,9 @@ export default function App() {
// Collapse state for the two full-height panels (left nav + filter sidebar). Persisted to the
// user's preferences so it follows the account across devices; a localStorage cache seeds the
// initial render synchronously so nothing flashes open before the server prefs arrive.
// Default unpinned (slim + hover-expand overlay); accounts that explicitly pin keep their choice.
const [navCollapsed, setNavCollapsedState] = useState<boolean>(() =>
Boolean(readAccount(LS.navCollapsed, false))
Boolean(readAccount(LS.navCollapsed, true))
);
const [filterCollapsed, setFilterCollapsedState] = useState<boolean>(() =>
Boolean(readAccount(LS.filterCollapsed, false))
@@ -984,8 +985,8 @@ export default function App() {
<ErrorDialog />
{/* Re-binds to the active page's scroll container on navigation (page or channel change). */}
<BackToTop dep={channelView ? `chan:${channelView.id}` : page} />
{/* Local-only live appearance tuner (renders only on localhost; null everywhere else). */}
<GlassTuner />
{/* Opt-in live appearance tuner (Settings → Appearance; off by default). */}
<GlassTuner enabled={theme.showTuner} />
</div>
);
}
+7 -10
View File
@@ -1,16 +1,13 @@
import { useEffect, useMemo, useState } from "react";
/**
* Local-only live appearance tuner. Mounted at the app root so it's reachable on every page; it
* A floating live appearance tuner. Mounted at the app root so it's reachable on every page; it
* writes the glass CSS custom properties (index.css `:root`) inline on the document element for
* instant, build-free feedback, and persists to localStorage.
*
* Only active on localhost, so it never appears in a hosted build. A scratch tool for dialling in
* the surface values "Copy CSS" exports them to paste into index.css.
* Opt-in via Settings → Appearance (`theme.showTuner`, off by default) — a secondary "geek toy"
* for dialling in the surface values. "Copy CSS" exports the values.
*/
const DEV_TUNER =
typeof window !== "undefined" && /^(localhost|127\.0\.0\.1)$/.test(window.location.hostname);
const LS_KEY = "siftlode.devGlassTuner";
type Slider = {
@@ -93,7 +90,7 @@ function loadSaved(): Saved {
}
}
export default function GlassTuner() {
export default function GlassTuner({ enabled }: { enabled: boolean }) {
const initial = useMemo(loadSaved, []);
const [vars, setVars] = useState<Record<string, number>>(() => {
const base: Record<string, number> = {};
@@ -216,7 +213,7 @@ export default function GlassTuner() {
persist({ open: v });
}
if (!DEV_TUNER) return null;
if (!enabled) return null;
return (
<div className="fixed right-0 top-24 z-[9999] flex items-start" style={{ pointerEvents: "none" }}>
@@ -225,7 +222,7 @@ export default function GlassTuner() {
onClick={() => persistOpen(true)}
style={{ pointerEvents: "auto" }}
className="glass-menu glass-hover rounded-l-xl rounded-r-none px-2 py-3 text-xs font-semibold tracking-wide text-fg"
title="Open the glass tuner (dev only)"
title="Open the glass tuner"
>
<span style={{ writingMode: "vertical-rl" }}> GLASS</span>
</button>
@@ -237,7 +234,7 @@ export default function GlassTuner() {
>
<div className="flex items-center gap-2 px-3 py-2 border-b border-border/60">
<span className="text-sm font-semibold text-fg"> Glass Tuner</span>
<span className="text-[10px] uppercase tracking-wider text-muted">dev</span>
<span className="text-[10px] uppercase tracking-wider text-muted">lab</span>
<button
onClick={() => persistOpen(false)}
className="ml-auto text-muted hover:text-fg text-lg leading-none px-1"
+4 -3
View File
@@ -103,9 +103,10 @@ export default function Header({
}
return (
// Fixed left = the left zone at its widest: nav card (12 margin + 208 + 12 margin = 232) +
// filter sidebar (256) + 16 gap = 504. Constant, so it never shifts on collapse/module change.
<div className="pointer-events-none fixed top-3 left-4 right-[10%] z-30 flex items-center gap-3 md:left-[504px]">
// Absolutely positioned within the (relative) content column, so its left edge tracks the
// actual left zone: it sits just inside the content, reclaiming the space when the nav rail is
// slim and/or no filter sidebar is present — no hard-coded offset.
<div className="pointer-events-none absolute top-3 left-3 right-[10%] z-30 flex items-center gap-3">
<ModuleName
label={t(moduleLabelKey[page])}
labels={moduleLabels}
+60 -52
View File
@@ -6,8 +6,6 @@ import {
Activity,
BarChart3,
Bell,
ChevronLeft,
ChevronRight,
Clapperboard,
Download,
Home,
@@ -15,6 +13,8 @@ import {
ListVideo,
LogOut,
MessageSquare,
Pin,
PinOff,
ScrollText,
Settings,
Shield,
@@ -62,6 +62,14 @@ export default function NavSidebar({
onGoToFullHistory: () => void;
}) {
const { t } = useTranslation();
// Hover/focus expand: when unpinned, the rail sits slim (icons only) and expands to a labelled
// overlay on pointer hover or keyboard focus WITHOUT pushing content. Pinned keeps it expanded
// in-flow like before. `collapsed` (persisted per-account) now means "unpinned".
const [hovered, setHovered] = useState(false);
const [focused, setFocused] = useState(false);
const pinned = !collapsed;
const expanded = pinned || hovered || focused;
const slim = !expanded; // visual: icon-only rail
const [acctOpen, setAcctOpen] = useState(false);
const acctBtnRef = useRef<HTMLButtonElement | null>(null);
const acctPanelRef = useRef<HTMLDivElement | null>(null);
@@ -213,21 +221,14 @@ export default function NavSidebar({
const rowBase =
"w-full flex items-center gap-3 rounded-lg px-2.5 py-2 text-sm transition";
const name = me.display_name ?? me.email.split("@")[0];
// Role shown next to the name (expanded) / as an avatar dot (collapsed). Demo isn't a real
// role (it's a flag on a `user` row), so surface it first.
// Role is surfaced as a plain title tooltip on the account row (no visual badge). Demo isn't a
// real role (it's a flag on a `user` row), so surface it first.
const roleKey: "admin" | "user" | "demo" = me.is_demo
? "demo"
: me.role === "admin"
? "admin"
: "user";
const roleChipCls =
roleKey === "admin"
? "bg-accent/15 text-accent"
: roleKey === "demo"
? "bg-amber-500/20 text-amber-500"
: "bg-muted/15 text-muted";
const roleDotCls =
roleKey === "admin" ? "bg-accent" : roleKey === "demo" ? "bg-amber-500" : "bg-muted";
const roleLabel = t(`nav.role.${roleKey}`);
const renderItem = ({ page: p, icon: Icon, label, badge }: NavItem) => {
const active = page === p;
@@ -239,9 +240,9 @@ export default function NavSidebar({
<button
key={p}
onClick={() => setPage(p)}
title={collapsed ? label : undefined}
title={slim ? label : undefined}
aria-current={active ? "page" : undefined}
className={`${rowBase} ${collapsed ? "justify-center px-0" : ""} relative ${
className={`${rowBase} ${slim ? "justify-center px-0" : ""} relative ${
active ? "bg-accent text-accent-fg" : "text-muted hover:text-fg hover:bg-card"
}`}
>
@@ -249,7 +250,7 @@ export default function NavSidebar({
<Icon className="w-[18px] h-[18px]" />
{/* Collapsed rail: a numeric badge centred on a circle at the icon corner; the ring
matches the row background so it reads cleanly whether the row is active or not. */}
{show && collapsed && (
{show && slim && (
<span
className={`absolute -top-2 -right-2 min-w-[16px] h-4 px-1 rounded-full text-[10px] font-bold leading-none grid place-items-center ring-2 ${badgeColor} ${
active ? "ring-accent" : "ring-bg"
@@ -259,8 +260,8 @@ export default function NavSidebar({
</span>
)}
</span>
{!collapsed && <span className="truncate">{label}</span>}
{show && !collapsed && (
{!slim && <span className="truncate">{label}</span>}
{show && !slim && (
<span
className={`ml-auto min-w-[18px] h-[18px] px-1.5 rounded-full text-[11px] font-semibold leading-none inline-flex items-center justify-center tabular-nums ${badgeColor}`}
>
@@ -272,14 +273,25 @@ export default function NavSidebar({
};
return (
<nav
className={`glass relative shrink-0 rounded-2xl flex flex-col py-3 m-3 transition-[width] ${
collapsed ? "w-[56px] px-2" : "w-52 px-3"
}`}
aria-label={t("nav.primary")}
// Layout slot: reserves only the slim footprint when unpinned, so the expanded rail (below)
// overlays content instead of pushing it. Pinned reserves the full width (in-flow).
<div
className={`relative shrink-0 transition-[width] ${pinned ? "w-[232px]" : "w-20"}`}
onMouseEnter={() => setHovered(true)}
onMouseLeave={() => setHovered(false)}
onFocus={() => setFocused(true)}
onBlur={(e) => {
if (!e.currentTarget.contains(e.relatedTarget as Node)) setFocused(false);
}}
>
<div className={`flex items-center mb-3 ${collapsed ? "justify-center" : "justify-between"}`}>
{!collapsed && (
<nav
className={`glass absolute top-0 bottom-0 left-0 z-40 rounded-2xl flex flex-col py-3 m-3 transition-[width] ${
slim ? "w-[56px] px-2" : "w-52 px-3"
}`}
aria-label={t("nav.primary")}
>
<div className={`flex items-center mb-3 ${slim ? "justify-center" : "justify-between"}`}>
{!slim && (
<button
onClick={() => setPage("feed")}
className="text-lg font-bold tracking-tight select-none cursor-pointer rounded-md -mx-1 px-1 hover:bg-card hover:opacity-90 transition"
@@ -290,15 +302,11 @@ export default function NavSidebar({
)}
<button
onClick={onToggleCollapse}
title={collapsed ? t("nav.expand") : t("nav.collapse")}
aria-label={collapsed ? t("nav.expand") : t("nav.collapse")}
title={pinned ? t("nav.unpin") : t("nav.pin")}
aria-label={pinned ? t("nav.unpin") : t("nav.pin")}
className="p-1 rounded-md text-muted hover:text-fg hover:bg-card transition"
>
{collapsed ? (
<ChevronRight className="w-4 h-4" />
) : (
<ChevronLeft className="w-4 h-4" />
)}
{pinned ? <PinOff className="w-4 h-4" /> : <Pin className="w-4 h-4" />}
</button>
</div>
@@ -308,7 +316,7 @@ export default function NavSidebar({
isAdmin={me.role === "admin"}
onGoToFullHistory={onGoToFullHistory}
variant="rail"
collapsed={collapsed}
collapsed={slim}
/>
</div>
@@ -319,7 +327,7 @@ export default function NavSidebar({
>
{userItems.map(renderItem)}
{systemItems.length > 0 &&
(collapsed ? (
(slim ? (
// Collapsed rail: a short, thicker centred rule is the clearest "new section" cue
// when there's no room for a label.
<div className="my-2 mx-auto w-7 border-t-2 border-border" />
@@ -337,7 +345,7 @@ export default function NavSidebar({
<div className="mt-2 pt-2 border-t border-border flex flex-col gap-1">
<div
className={`flex items-center justify-center gap-2 pb-2 mb-1 border-b border-border ${
collapsed ? "flex-col" : "flex-row"
slim ? "flex-col" : "flex-row"
}`}
>
<LanguageSwitcher variant="rail" value={language} onChange={onChangeLanguage} />
@@ -349,27 +357,35 @@ export default function NavSidebar({
>
<Info className="w-5 h-5" />
</button>
<button
onClick={logout}
title={t("header.account.signOut")}
aria-label={t("header.account.signOut")}
className="p-2 rounded-lg text-muted hover:text-fg hover:bg-card transition"
>
<LogOut className="w-5 h-5" />
</button>
</div>
<button
onClick={() => setPage("settings")}
title={collapsed ? t("header.account.settings") : undefined}
title={slim ? t("header.account.settings") : undefined}
aria-current={page === "settings" ? "page" : undefined}
className={`${rowBase} ${collapsed ? "justify-center px-0" : ""} ${
className={`${rowBase} ${slim ? "justify-center px-0" : ""} ${
page === "settings"
? "bg-accent text-accent-fg"
: "text-muted hover:text-fg hover:bg-card"
}`}
>
<Settings className="w-[18px] h-[18px] shrink-0" />
{!collapsed && <span className="truncate">{t("header.account.settings")}</span>}
{!slim && <span className="truncate">{t("header.account.settings")}</span>}
</button>
<div className="relative">
<button
ref={acctBtnRef}
onClick={toggleAccount}
title={collapsed ? name : undefined}
className={`${rowBase} ${collapsed ? "justify-center px-0" : ""} text-muted hover:text-fg hover:bg-card`}
title={slim ? `${name} · ${roleLabel}` : undefined}
className={`${rowBase} ${slim ? "justify-center px-0" : ""} text-muted hover:text-fg hover:bg-card`}
>
<span className="relative shrink-0">
<AvatarImg
@@ -377,19 +393,10 @@ export default function NavSidebar({
fallback={name}
className="w-[22px] h-[22px] rounded-full text-[10px]"
/>
{collapsed && (
<span
className={`absolute -bottom-0.5 -right-0.5 w-2.5 h-2.5 rounded-full ring-2 ring-bg ${roleDotCls}`}
title={t(`nav.role.${roleKey}`)}
/>
)}
</span>
{!collapsed && <span className="truncate flex-1 text-left">{name}</span>}
{!collapsed && (
<span
className={`shrink-0 text-[10px] font-semibold uppercase tracking-wide px-1.5 py-0.5 rounded ${roleChipCls}`}
>
{t(`nav.role.${roleKey}`)}
{!slim && (
<span title={roleLabel} className="truncate flex-1 text-left">
{name}
</span>
)}
</button>
@@ -469,6 +476,7 @@ export default function NavSidebar({
)}
</div>
</div>
</nav>
</nav>
</div>
);
}
+10
View File
@@ -205,6 +205,16 @@ function Appearance({ prefs }: { prefs: PrefsController }) {
>
<Switch label={t("settings.appearance.showHints")} checked={hints} onChange={setHints} />
</SettingRow>
<SettingRow
label={t("settings.appearance.showTuner")}
hint={t("settings.appearance.showTunerHint")}
>
<Switch
label={t("settings.appearance.showTuner")}
checked={theme.showTuner}
onChange={(v) => setTheme({ ...theme, showTuner: v })}
/>
</SettingRow>
</Section>
<Section title={t("settings.appearance.textSize")}>
+2 -2
View File
@@ -1,7 +1,7 @@
{
"primary": "Primary navigation",
"collapse": "Collapse sidebar",
"expand": "Expand sidebar",
"pin": "Pin sidebar open",
"unpin": "Unpin sidebar",
"adminSection": "Admin",
"role": {
"admin": "Admin",
@@ -42,6 +42,8 @@
"performanceModeHint": "Turns off the translucent glass blur and soft shadows for snappier rendering on slower machines.",
"showHints": "Show hints",
"showHintsHint": "These little hover explanations. Turn them off once you know your way around.",
"showTuner": "Show the glass tuner",
"showTunerHint": "A floating panel for live-tweaking the frosted-glass look. A geek toy — off by default.",
"textSize": "Text size"
},
"notifications": {
+2 -2
View File
@@ -1,7 +1,7 @@
{
"primary": "Fő navigáció",
"collapse": "Oldalsáv összecsukása",
"expand": "Oldalsáv kinyitása",
"pin": "Oldalsáv rögzítése nyitva",
"unpin": "Oldalsáv rögzítésének feloldása",
"adminSection": "Admin",
"role": {
"admin": "Admin",
@@ -42,6 +42,8 @@
"performanceModeHint": "Kikapcsolja az áttetsző üvegelmosást és a lágy árnyékokat a gyorsabb megjelenítésért lassabb gépeken.",
"showHints": "Tippek megjelenítése",
"showHintsHint": "Ezek a kis lebegő magyarázatok. Kapcsold ki őket, ha már kiismerted magad.",
"showTuner": "Glass tuner megjelenítése",
"showTunerHint": "Lebegő panel a mattüveg-hatás élő hangolásához. Geek-játékszer — alapból kikapcsolva.",
"textSize": "Betűméret"
},
"notifications": {
+12
View File
@@ -14,6 +14,18 @@ export interface ReleaseEntry {
}
export const RELEASE_NOTES: ReleaseEntry[] = [
{
version: "0.43.0",
date: "2026-07-14",
summary:
"A slimmer, smarter navigation sidebar, a couple of account tweaks, and an optional live appearance tuner.",
features: [
"The navigation sidebar now stays slim by default and slides open over the page when you hover it or tab into it — so it barely takes any room until you need it. Prefer it always open? Pin it with the button at the top of the rail.",
"The top bar reclaims the empty space on the left: it now hugs your content instead of leaving a fixed gap when the sidebar is slim.",
"A logout button now sits right next to the language and about buttons, and your role shows as a tooltip on your name (the separate badge is gone).",
"New optional glass tuner: a floating panel for live-tweaking the frosted-glass look. It's off by default — switch it on under Settings → Appearance if you like to fiddle.",
],
},
{
version: "0.42.0",
date: "2026-07-13",
+3
View File
@@ -20,6 +20,8 @@ export interface ThemePrefs {
bgImage: boolean;
/** How strongly the background image is faded out, 0100% (higher = fainter). */
bgFade: number;
/** Show the floating glass/theme tuner (a secondary "geek toy"); off by default. */
showTuner: boolean;
}
export const DEFAULT_THEME: ThemePrefs = {
@@ -28,6 +30,7 @@ export const DEFAULT_THEME: ThemePrefs = {
fontScale: 1.06,
bgImage: true,
bgFade: 60,
showTuner: false,
};
export function applyTheme(t: ThemePrefs): void {