Merge: promote dev to prod

This commit is contained in:
npeter83 2026-06-29 01:13:52 +02:00
commit f1c98dd708
54 changed files with 972 additions and 894 deletions

View file

@ -1 +1 @@
0.16.2
0.17.0

View file

@ -1,6 +1,4 @@
import hashlib
import logging
import re
import secrets
from datetime import datetime, timedelta, timezone
@ -8,7 +6,7 @@ import httpx
from authlib.integrations.starlette_client import OAuth, OAuthError
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Request
from fastapi.responses import JSONResponse, RedirectResponse
from sqlalchemy import delete, select
from sqlalchemy import delete, func, select
from sqlalchemy.orm import Session
from app import email as email_mod
@ -17,7 +15,8 @@ from app.config import settings
from app.db import get_db
from app.models import AuthToken, DemoWhitelist, Invite, OAuthToken, User
from app.ratelimit import RateLimiter
from app.security import decrypt, encrypt, hash_password, verify_password
from app.security import decrypt, encrypt, hash_password, hash_token, verify_password
from app.utils import valid_email
# Email+password auth tuning.
PASSWORD_MIN_LEN = 10
@ -43,8 +42,6 @@ def _notify_suspended(background: BackgroundTasks, email: str) -> None:
if _suspend_email_limiter.allow(email):
background.add_task(email_mod.send_account_suspended, email, operator_contact())
_EMAIL_RE = re.compile(r"^[^@\s]+@[^@\s]+\.[^@\s]+$")
# The single shared demo account's stable identity. The row is created lazily on first
# demo login (see get_or_create_demo_user); these are just its sentinel keys.
DEMO_GOOGLE_SUB = "__demo__"
@ -184,7 +181,7 @@ def upsert_pending_invite(db: Session, email: str) -> Invite | None:
"""Idempotently record an access request. Returns the Invite if a *new* pending row
was created (so the caller can notify admins), else None for already-known emails."""
email = (email or "").lower()
if not _EMAIL_RE.match(email):
if not valid_email(email):
return None
inv = db.execute(select(Invite).where(Invite.email == email)).scalar_one_or_none()
if inv is not None:
@ -443,7 +440,7 @@ async def request_access(
"""Public: ask for access. Idempotent — repeat requests for the same email don't
duplicate or re-spam admins (upsert returns None for an already-pending row)."""
email = (payload.get("email") or "").strip().lower()
if not _EMAIL_RE.match(email):
if not valid_email(email):
raise HTTPException(status_code=400, detail="Enter a valid email address.")
if is_allowed(db, email):
return {"status": "approved"} # already allowed — just sign in
@ -465,7 +462,7 @@ def demo_login(payload: dict, request: Request, db: Session = Depends(get_db)) -
if not _demo_limiter.allow(_client_ip(request)):
return {"authenticated": False}
email = (payload.get("email") or "").strip().lower()
if _EMAIL_RE.match(email) and is_demo_allowed(db, email):
if valid_email(email) and is_demo_allowed(db, email):
demo = get_or_create_demo_user(db)
request.session["user_id"] = demo.id
log.info("Demo login (key=%s, demo_id=%s)", email, demo.id)
@ -487,10 +484,6 @@ def _app_base() -> str:
return settings.app_base
def _hash_token(raw: str) -> str:
return hashlib.sha256(raw.encode()).hexdigest()
def _issue_token(db: Session, user: User, kind: str, ttl: timedelta) -> str:
"""Create a single-use token of `kind`; only its hash is stored. Returns the raw token
(goes only into the emailed link)."""
@ -499,7 +492,7 @@ def _issue_token(db: Session, user: User, kind: str, ttl: timedelta) -> str:
AuthToken(
user_id=user.id,
kind=kind,
token_hash=_hash_token(raw),
token_hash=hash_token(raw),
expires_at=datetime.now(timezone.utc) + ttl,
)
)
@ -513,7 +506,7 @@ def _consume_token(db: Session, raw: str | None, kind: str) -> User | None:
return None
row = db.execute(
select(AuthToken).where(
AuthToken.token_hash == _hash_token(raw), AuthToken.kind == kind
AuthToken.token_hash == hash_token(raw), AuthToken.kind == kind
)
).scalar_one_or_none()
if row is None or row.used_at is not None or row.expires_at < datetime.now(timezone.utc):
@ -537,7 +530,7 @@ def register(
email = (payload.get("email") or "").strip().lower()
password = payload.get("password") or ""
# Input validation is safe to reveal (independent of whether the email exists).
if not _EMAIL_RE.match(email):
if not valid_email(email):
raise HTTPException(status_code=400, detail="Enter a valid email address.")
if len(password) < PASSWORD_MIN_LEN:
raise HTTPException(
@ -633,7 +626,7 @@ def password_reset_request(
if not _reset_limiter.allow(_client_ip(request)):
return {"status": "ok"}
email = (payload.get("email") or "").strip().lower()
if _EMAIL_RE.match(email):
if valid_email(email):
user = db.execute(select(User).where(User.email == email)).scalar_one_or_none()
if user is not None and user.password_hash and not user.is_demo:
raw = _issue_token(db, user, "reset", RESET_TTL)
@ -694,6 +687,23 @@ def require_human(user: User = Depends(current_user)) -> User:
return user
def admin_user(user: User = Depends(current_user)) -> User:
"""Dependency: require the signed-in user to be an admin. Single source of truth for the
admin gate every admin route/action depends on this rather than re-checking the role."""
if user.role != "admin":
raise HTTPException(status_code=403, detail="Admin only")
return user
def count_admins(db: Session, *, active_only: bool = False) -> int:
"""How many admins exist (optionally only un-suspended ones). Used by the 'can't remove /
suspend / delete the last admin' guards so they don't each re-spell the count query."""
q = select(func.count()).select_from(User).where(User.role == "admin")
if active_only:
q = q.where(User.is_suspended.is_(False))
return db.scalar(q) or 0
@router.get("/link")
async def link_google(
request: Request,

View file

@ -19,7 +19,24 @@ from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.db import Base
class User(Base):
class TimestampMixin:
"""Adds a server-defaulted `created_at`. Models that index created_at or order it
differently (QuotaEvent, Notification, Message) keep their own column on purpose."""
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now()
)
class UpdatedAtMixin:
"""Adds an `updated_at` that server-defaults on insert and bumps on every UPDATE."""
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
)
class User(Base, TimestampMixin):
__tablename__ = "users"
id: Mapped[int] = mapped_column(primary_key=True)
@ -57,9 +74,6 @@ class User(Base):
)
# Free-form UI preferences (theme, color scheme, font scale, default filters…).
preferences: Mapped[dict | None] = mapped_column(JSON)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now()
)
token: Mapped["OAuthToken | None"] = relationship(
back_populates="user", uselist=False, cascade="all, delete-orphan"
@ -69,7 +83,7 @@ class User(Base):
)
class OAuthToken(Base):
class OAuthToken(Base, UpdatedAtMixin):
__tablename__ = "oauth_tokens"
id: Mapped[int] = mapped_column(primary_key=True)
@ -81,14 +95,11 @@ class OAuthToken(Base):
access_token: Mapped[str | None] = mapped_column(String)
expiry: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
scopes: Mapped[str | None] = mapped_column(String)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
)
user: Mapped["User"] = relationship(back_populates="token")
class AuthToken(Base):
class AuthToken(Base, TimestampMixin):
"""Single-use, expiring token for email verification or password reset. Only the SHA-256
HASH of the token is stored, so a DB leak can't be used to verify/reset; the raw token
lives only in the emailed link. Consumed (used_at set) on first valid use."""
@ -103,9 +114,6 @@ class AuthToken(Base):
token_hash: Mapped[str] = mapped_column(String(64), unique=True, index=True)
expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True))
used_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now()
)
class Invite(Base):
@ -128,7 +136,7 @@ class Invite(Base):
decided_by: Mapped[str | None] = mapped_column(String(320)) # admin email
class DemoWhitelist(Base):
class DemoWhitelist(Base, TimestampMixin):
"""Emails that may enter the shared demo account from the login page (no Google OAuth).
A hidden, admin-curated list of "keys to the same door" every entry logs into the one
shared demo user. Distinct from Invite (which gates real Google sign-in)."""
@ -139,12 +147,9 @@ class DemoWhitelist(Base):
email: Mapped[str] = mapped_column(String(320), unique=True, index=True)
note: Mapped[str | None] = mapped_column(Text)
added_by: Mapped[str | None] = mapped_column(String(320)) # admin email
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now()
)
class Channel(Base):
class Channel(Base, TimestampMixin):
"""A YouTube channel. Shared across all users (one channel's videos are the same
for everyone), so its expensive metadata is fetched and stored only once."""
@ -170,15 +175,12 @@ class Channel(Base):
backfill_done: Mapped[bool] = mapped_column(
Boolean, default=False, server_default="false"
)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now()
)
videos: Mapped[list["Video"]] = relationship(back_populates="channel")
subscriptions: Mapped[list["Subscription"]] = relationship(back_populates="channel")
class Subscription(Base):
class Subscription(Base, TimestampMixin):
"""Per-user link to a channel, with the user's own priority/visibility overrides."""
__tablename__ = "subscriptions"
@ -202,15 +204,12 @@ class Subscription(Base):
Boolean, default=False, server_default="false"
)
subscribed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now()
)
user: Mapped["User"] = relationship(back_populates="subscriptions")
channel: Mapped["Channel"] = relationship(back_populates="subscriptions")
class Video(Base):
class Video(Base, TimestampMixin):
"""A YouTube video. Shared across users; per-user state lives elsewhere."""
__tablename__ = "videos"
@ -260,14 +259,11 @@ class Video(Base):
DateTime(timezone=True), index=True
)
unavailable_reason: Mapped[str | None] = mapped_column(String(24))
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now()
)
channel: Mapped["Channel"] = relationship(back_populates="videos")
class Tag(Base):
class Tag(Base, TimestampMixin):
"""A label. System tags (user_id NULL) are computed automatically and shared by all
users; user tags (user_id set) are personal. category groups them in the UI."""
@ -283,9 +279,6 @@ class Tag(Base):
category: Mapped[str] = mapped_column(
String(16), default="other", server_default="other"
)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now()
)
class ChannelTag(Base):
@ -312,7 +305,7 @@ class ChannelTag(Base):
confidence: Mapped[float | None] = mapped_column(Float)
class VideoState(Base):
class VideoState(Base, UpdatedAtMixin):
"""Per-user watch state for a video. Rows exist only for non-default states; the
absence of a row means 'new/unseen'."""
@ -326,7 +319,7 @@ class VideoState(Base):
video_id: Mapped[str] = mapped_column(
ForeignKey("videos.id", ondelete="CASCADE"), index=True
)
# new | watched | saved | hidden
# new | watched | hidden ("saved" is now Watch-later playlist membership, not a status)
status: Mapped[str] = mapped_column(String(12), default="new", server_default="new")
watched_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
# Resume position (seconds into the video) for the in-app player. Orthogonal to
@ -337,9 +330,6 @@ class VideoState(Base):
Integer, default=0, server_default="0", nullable=False
)
progress_updated_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
)
class ApiQuotaUsage(Base):
@ -422,7 +412,7 @@ class SystemConfig(Base):
)
class Playlist(Base):
class Playlist(Base, TimestampMixin, UpdatedAtMixin):
"""A per-user named, ordered collection of videos from the shared catalog.
`kind`: 'user' (normal) or 'watch_later' (built-in, undeletable, one per user).
@ -450,12 +440,6 @@ class Playlist(Base):
# baseline `dirty` is derived against. NULL = no baseline yet (treated as dirty).
synced_fingerprint: Mapped[str | None] = mapped_column(String)
position: Mapped[int] = mapped_column(Integer, default=0, server_default="0")
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now()
)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
)
items: Mapped[list["PlaylistItem"]] = relationship(
back_populates="playlist",
@ -560,7 +544,7 @@ class Message(Base):
)
class MessageKey(Base):
class MessageKey(Base, TimestampMixin):
"""A user's end-to-end-encryption key material for direct messaging (phase 2).
One row per user. `public_key` (base64 SPKI, ECDH P-256) is shared with others so they can
@ -581,6 +565,3 @@ class MessageKey(Base):
salt: Mapped[str] = mapped_column(String(64))
wrap_iv: Mapped[str] = mapped_column(String(32))
key_check: Mapped[str] = mapped_column(Text)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now()
)

View file

@ -66,6 +66,26 @@ def attribute(actor_id: int | None, action: str):
_action.reset(t2)
class Measure:
"""Result handle from `measured()` — read .used / .remaining after the block exits."""
used = 0
remaining = 0
@contextlib.contextmanager
def measured(db: Session, actor_id: int | None, action: str):
"""Like `attribute()`, but also reports how much quota the block spent. Yields a `Measure`
whose `.used` (units charged inside the block) and `.remaining` (today's remaining after)
are populated on exit so manual sync routes don't each re-spell the before/after diff."""
m = Measure()
before = units_used_today(db)
with attribute(actor_id, action):
yield m
m.used = units_used_today(db) - before
m.remaining = remaining_today(db)
def pacific_today():
return datetime.now(_PACIFIC).date()

View file

@ -1,6 +1,5 @@
"""Admin-only onboarding: review access requests (Invites), approve/deny, manual add.
Also the demo-account admin surface: the demo email whitelist + a manual state reset."""
import re
from datetime import datetime, timezone
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException
@ -8,7 +7,13 @@ from sqlalchemy import delete, func, select
from sqlalchemy.orm import Session
from app import email as email_mod
from app.auth import current_user, get_or_create_demo_user, operator_contact, purge_user
from app.auth import (
admin_user,
count_admins,
get_or_create_demo_user,
operator_contact,
purge_user,
)
from app.db import get_db
from app.models import (
DemoWhitelist,
@ -20,17 +25,10 @@ from app.models import (
Video,
VideoState,
)
from app.utils import valid_email
router = APIRouter(prefix="/api/admin", tags=["admin"])
_EMAIL_RE = re.compile(r"^[^@\s]+@[^@\s]+\.[^@\s]+$")
def admin_user(user: User = Depends(current_user)) -> User:
if user.role != "admin":
raise HTTPException(status_code=403, detail="Admin only")
return user
def _serialize(inv: Invite) -> dict:
return {
@ -110,7 +108,7 @@ def add_invite(
) -> dict:
"""Manually whitelist an email (approved straight away). Convenience for the admin."""
email = (payload.get("email") or "").strip().lower()
if "@" not in email:
if not valid_email(email):
raise HTTPException(status_code=400, detail="Enter a valid email address.")
inv = db.execute(select(Invite).where(Invite.email == email)).scalar_one_or_none()
if inv is None:
@ -168,10 +166,8 @@ def set_user_role(
raise HTTPException(status_code=400, detail="The demo account's role can't be changed.")
if target.id == admin.id:
raise HTTPException(status_code=400, detail="You can't change your own role.")
if target.role == "admin" and role == "user":
admin_count = db.scalar(select(func.count()).select_from(User).where(User.role == "admin"))
if (admin_count or 0) <= 1:
raise HTTPException(status_code=400, detail="Can't remove the last admin.")
if target.role == "admin" and role == "user" and count_admins(db) <= 1:
raise HTTPException(status_code=400, detail="Can't remove the last admin.")
changed = target.role != role
target.role = role
db.commit()
@ -201,14 +197,13 @@ def set_user_suspended(
raise HTTPException(status_code=400, detail="The demo account can't be suspended.")
if target.id == admin.id:
raise HTTPException(status_code=400, detail="You can't suspend your own account.")
if suspended and target.role == "admin" and not target.is_suspended:
active_admins = db.scalar(
select(func.count())
.select_from(User)
.where(User.role == "admin", User.is_suspended.is_(False))
)
if (active_admins or 0) <= 1:
raise HTTPException(status_code=400, detail="Can't suspend the last active admin.")
if (
suspended
and target.role == "admin"
and not target.is_suspended
and count_admins(db, active_only=True) <= 1
):
raise HTTPException(status_code=400, detail="Can't suspend the last active admin.")
was_suspended = target.is_suspended
target.is_suspended = suspended
db.commit()
@ -238,12 +233,8 @@ def admin_delete_user(
raise HTTPException(
status_code=400, detail="Delete your own account from Settings → Account."
)
if target.role == "admin":
admin_count = db.scalar(
select(func.count()).select_from(User).where(User.role == "admin")
)
if (admin_count or 0) <= 1:
raise HTTPException(status_code=400, detail="Can't delete the last admin.")
if target.role == "admin" and count_admins(db) <= 1:
raise HTTPException(status_code=400, detail="Can't delete the last admin.")
purge_user(db, target, background)
return {"deleted": user_id}
@ -281,7 +272,7 @@ def add_demo_whitelist(
"""Add an email that may enter the shared demo account from the login page (no Google
sign-in). Idempotent re-adding an existing email just returns it."""
email = (payload.get("email") or "").strip().lower()
if not _EMAIL_RE.match(email):
if not valid_email(email):
raise HTTPException(status_code=400, detail="Enter a valid email address.")
row = db.execute(
select(DemoWhitelist).where(DemoWhitelist.email == email)

View file

@ -62,6 +62,29 @@ def _saved_expr(user: User):
)
def feed_columns(user: User, state) -> list:
"""The shared feed-row projection (joined Channel fields + per-user watch state +
`saved`), consumed by both the feed query and the playlist-detail query so a new column
is added in exactly one place. `state` is the caller's aliased VideoState (outer-joined)."""
return [
Video.id,
Video.title,
Video.channel_id,
Channel.title.label("channel_title"),
Channel.thumbnail_url.label("channel_thumbnail"),
Channel.handle.label("channel_handle"),
Video.published_at,
Video.thumbnail_url,
Video.duration_seconds,
Video.view_count,
Video.is_short,
Video.live_status,
func.coalesce(state.status, "new").label("status"),
func.coalesce(state.position_seconds, 0).label("position_seconds"),
_saved_expr(user),
]
def _serialize(row) -> dict:
return {
"id": row.id,
@ -115,23 +138,9 @@ def _filtered_query(
status_expr = func.coalesce(state.status, "new")
position_expr = func.coalesce(state.position_seconds, 0)
query = select(
Video.id,
Video.title,
Video.channel_id,
Channel.title.label("channel_title"),
Channel.thumbnail_url.label("channel_thumbnail"),
Channel.handle.label("channel_handle"),
Video.published_at,
Video.thumbnail_url,
Video.duration_seconds,
Video.view_count,
Video.is_short,
Video.live_status,
status_expr.label("status"),
position_expr.label("position_seconds"),
_saved_expr(user),
).join(Channel, Channel.id == Video.channel_id)
query = select(*feed_columns(user, state)).join(
Channel, Channel.id == Video.channel_id
)
if scope == "all":
# Whole shared catalog; subscription is optional (only for priority sort).

View file

@ -3,6 +3,7 @@ from sqlalchemy import func, select
from sqlalchemy.orm import Session
from app.auth import (
count_admins,
current_user,
google_enabled,
has_read_scope,
@ -111,13 +112,11 @@ def delete_my_account(
(that would lock everyone out of the admin surfaces)."""
if user.is_demo:
raise HTTPException(status_code=403, detail="The shared demo account can't be deleted.")
if user.role == "admin":
admins = db.scalar(select(func.count()).select_from(User).where(User.role == "admin")) or 0
if admins <= 1:
raise HTTPException(
status_code=400,
detail="You're the only admin — promote another admin before deleting your account.",
)
if user.role == "admin" and count_admins(db) <= 1:
raise HTTPException(
status_code=400,
detail="You're the only admin — promote another admin before deleting your account.",
)
user_id = user.id
# Full erasure (cascades) + access-request cleanup + Google-grant revocation; shared with the
# admin delete path. Capture the id first — `user` is detached once purge_user deletes the row.

View file

@ -103,6 +103,7 @@ def _serialize_msg(m: Message) -> dict:
def _messageable() -> list:
"""SQL clauses for "a real, active, non-suspended human" — for directory/recipient queries."""
return [
User.is_demo.is_(False),
User.is_active.is_(True),
@ -110,6 +111,11 @@ def _messageable() -> list:
]
def is_messageable_user(u: User | None) -> bool:
"""Python mirror of `_messageable()` for an already-loaded row (WS auth, send recipient)."""
return bool(u and not u.is_demo and u.is_active and not u.is_suspended)
def ensure_welcome(db: Session, user: User) -> None:
"""Create the one-time system welcome message for a (human) user the first time it's needed.
Idempotent: guarded on whether any system message already exists for them."""
@ -315,7 +321,7 @@ def send_message(
if payload.recipient_id == user.id:
raise HTTPException(status_code=400, detail="You can't message yourself.")
recipient = db.get(User, payload.recipient_id)
if recipient is None or recipient.is_demo or not recipient.is_active or recipient.is_suspended:
if not is_messageable_user(recipient):
raise HTTPException(status_code=404, detail="Recipient not available.")
if db.get(MessageKey, recipient.id) is None:
raise HTTPException(status_code=404, detail="That member hasn't set up messaging yet.")
@ -363,8 +369,7 @@ async def messages_ws(ws: WebSocket) -> None:
return
db = SessionLocal()
try:
u = db.get(User, uid)
ok = bool(u and not u.is_demo and not u.is_suspended and u.is_active)
ok = is_messageable_user(db.get(User, uid))
finally:
db.close()
if not ok:

View file

@ -9,7 +9,7 @@ from app import quota
from app.auth import current_user, has_read_scope, has_write_scope
from app.db import get_db
from app.models import Channel, Playlist, PlaylistItem, User, Video, VideoState
from app.routes.feed import _saved_expr, _serialize
from app.routes.feed import _serialize, feed_columns
from app.sync.playlists import (
plan_push,
push_playlist,
@ -29,6 +29,51 @@ def _own_playlist(db: Session, user: User, playlist_id: int) -> Playlist:
return pl
def _next_playlist_position(db: Session, user_id: int) -> int:
"""Append position for a new playlist in this user's rail."""
return (
db.scalar(
select(func.coalesce(func.max(Playlist.position), -1)).where(
Playlist.user_id == user_id
)
)
+ 1
)
def _next_item_position(db: Session, playlist_id: int) -> int:
"""Append position for a new item at the end of a playlist."""
return (
db.scalar(
select(func.coalesce(func.max(PlaylistItem.position), -1)).where(
PlaylistItem.playlist_id == playlist_id
)
)
+ 1
)
def _add_item(db: Session, pl: Playlist, video_id: str, *, mark_dirty: bool) -> bool:
"""Append a video to a playlist if not already present (idempotent). Returns whether it
was added. `mark_dirty` recomputes the YouTube-sync dirty flag (skip for Watch later)."""
exists = db.scalar(
select(PlaylistItem).where(
PlaylistItem.playlist_id == pl.id, PlaylistItem.video_id == video_id
)
)
if exists is not None:
return False
db.add(
PlaylistItem(
playlist_id=pl.id, video_id=video_id, position=_next_item_position(db, pl.id)
)
)
if mark_dirty:
recompute_dirty(db, pl)
db.commit()
return True
def _summary(
db: Session,
pl: Playlist,
@ -142,15 +187,7 @@ def create_playlist(
name = (payload.get("name") or "").strip()
if not name:
raise HTTPException(status_code=400, detail="name is required")
next_pos = (
db.scalar(
select(func.coalesce(func.max(Playlist.position), -1)).where(
Playlist.user_id == user.id
)
)
+ 1
)
pl = Playlist(user_id=user.id, name=name, position=next_pos)
pl = Playlist(user_id=user.id, name=name, position=_next_playlist_position(db, user.id))
db.add(pl)
db.commit()
return _summary(db, pl, 0)
@ -164,16 +201,11 @@ def _watch_later(db: Session, user: User) -> Playlist:
)
)
if pl is None:
next_pos = (
db.scalar(
select(func.coalesce(func.max(Playlist.position), -1)).where(
Playlist.user_id == user.id
)
)
+ 1
)
pl = Playlist(
user_id=user.id, name="Watch later", kind="watch_later", position=next_pos
user_id=user.id,
name="Watch later",
kind="watch_later",
position=_next_playlist_position(db, user.id),
)
db.add(pl)
db.commit()
@ -191,22 +223,7 @@ def add_watch_later(
if not video_id or db.get(Video, video_id) is None:
raise HTTPException(status_code=404, detail="Unknown video")
pl = _watch_later(db, user)
exists = db.scalar(
select(PlaylistItem).where(
PlaylistItem.playlist_id == pl.id, PlaylistItem.video_id == video_id
)
)
if exists is None:
next_pos = (
db.scalar(
select(func.coalesce(func.max(PlaylistItem.position), -1)).where(
PlaylistItem.playlist_id == pl.id
)
)
+ 1
)
db.add(PlaylistItem(playlist_id=pl.id, video_id=video_id, position=next_pos))
db.commit()
_add_item(db, pl, video_id, mark_dirty=False)
return {"saved": True, "playlist_id": pl.id}
@ -344,23 +361,7 @@ def get_playlist(
pl = _own_playlist(db, user, playlist_id)
state = aliased(VideoState)
rows = db.execute(
select(
Video.id,
Video.title,
Video.channel_id,
Channel.title.label("channel_title"),
Channel.thumbnail_url.label("channel_thumbnail"),
Channel.handle.label("channel_handle"),
Video.published_at,
Video.thumbnail_url,
Video.duration_seconds,
Video.view_count,
Video.is_short,
Video.live_status,
func.coalesce(state.status, "new").label("status"),
func.coalesce(state.position_seconds, 0).label("position_seconds"),
_saved_expr(user),
)
select(*feed_columns(user, state))
.join(PlaylistItem, PlaylistItem.video_id == Video.id)
.join(Channel, Channel.id == Video.channel_id)
.outerjoin(state, and_(state.video_id == Video.id, state.user_id == user.id))
@ -439,25 +440,7 @@ def add_item(
video_id = payload.get("video_id")
if not video_id or db.get(Video, video_id) is None:
raise HTTPException(status_code=404, detail="Unknown video")
exists = db.scalar(
select(PlaylistItem).where(
PlaylistItem.playlist_id == pl.id, PlaylistItem.video_id == video_id
)
)
if exists is None:
next_pos = (
db.scalar(
select(func.coalesce(func.max(PlaylistItem.position), -1)).where(
PlaylistItem.playlist_id == pl.id
)
)
+ 1
)
db.add(
PlaylistItem(playlist_id=pl.id, video_id=video_id, position=next_pos)
)
recompute_dirty(db, pl)
db.commit()
_add_item(db, pl, video_id, mark_dirty=True)
return {"playlist_id": pl.id, "video_id": video_id}

View file

@ -3,7 +3,7 @@ from sqlalchemy import and_, case, func, select, update
from sqlalchemy.orm import Session
from app import quota, state
from app.auth import current_user, has_read_scope, require_human
from app.auth import admin_user, current_user, has_read_scope, require_human
from app.db import get_db
from app.models import Channel, Subscription, User, Video
from app.sync.runner import (
@ -40,11 +40,10 @@ def sync_subscriptions(
status_code=403,
detail="Connect your YouTube account (read access) to import subscriptions.",
)
before = quota.units_used_today(db)
with quota.attribute(user.id, quota.QuotaAction.SUBSCRIPTIONS_PULL):
with quota.measured(db, user.id, quota.QuotaAction.SUBSCRIPTIONS_PULL) as m:
result = import_subscriptions(db, user)
result["quota_used_estimate"] = quota.units_used_today(db) - before
result["quota_remaining_today"] = quota.remaining_today(db)
result["quota_used_estimate"] = m.used
result["quota_remaining_today"] = m.remaining
return result
@ -62,10 +61,9 @@ def sync_backfill(
db: Session = Depends(get_db),
max_channels: int = 25,
) -> dict:
before = quota.units_used_today(db)
with quota.attribute(user.id, quota.QuotaAction.VIDEOS_BACKFILL_RECENT):
with quota.measured(db, user.id, quota.QuotaAction.VIDEOS_BACKFILL_RECENT) as m:
result = run_recent_backfill(db, _user_channels(db, user), max_channels=max_channels)
result["quota_used_estimate"] = quota.units_used_today(db) - before
result["quota_used_estimate"] = m.used
return result
@ -73,13 +71,9 @@ def sync_backfill(
def sync_enrich(
user: User = Depends(require_human), db: Session = Depends(get_db)
) -> dict:
before = quota.units_used_today(db)
with quota.attribute(user.id, quota.QuotaAction.VIDEOS_ENRICH):
with quota.measured(db, user.id, quota.QuotaAction.VIDEOS_ENRICH) as m:
enriched = run_enrich(db)
return {
"enriched": enriched,
"quota_used_estimate": quota.units_used_today(db) - before,
}
return {"enriched": enriched, "quota_used_estimate": m.used}
@router.get("/status")
@ -194,20 +188,12 @@ def deep_all(
@router.post("/pause")
def pause_sync(
user: User = Depends(current_user), db: Session = Depends(get_db)
) -> dict:
if user.role != "admin":
raise HTTPException(status_code=403, detail="Admin only")
def pause_sync(_: User = Depends(admin_user), db: Session = Depends(get_db)) -> dict:
state.set_sync_paused(db, True)
return {"paused": True}
@router.post("/resume")
def resume_sync(
user: User = Depends(current_user), db: Session = Depends(get_db)
) -> dict:
if user.role != "admin":
raise HTTPException(status_code=403, detail="Admin only")
def resume_sync(_: User = Depends(admin_user), db: Session = Depends(get_db)) -> dict:
state.set_sync_paused(db, False)
return {"paused": False}

View file

@ -3,7 +3,7 @@ from sqlalchemy import func, or_, select
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session
from app.auth import current_user
from app.auth import admin_user, current_user
from app.db import get_db
from app.models import ChannelTag, Tag, User
from app.sync.autotag import run_autotag_all
@ -103,10 +103,8 @@ def delete_tag(
@router.post("/recompute")
def recompute(
user: User = Depends(current_user),
_: User = Depends(admin_user),
db: Session = Depends(get_db),
only_missing: bool = False,
) -> dict:
if user.role != "admin":
raise HTTPException(status_code=403, detail="Admin only")
return run_autotag_all(db, only_missing=only_missing)

View file

@ -1,3 +1,5 @@
import hashlib
from argon2 import PasswordHasher
from cryptography.fernet import Fernet
@ -7,6 +9,12 @@ from app.config import settings
_ph = PasswordHasher()
def hash_token(raw: str) -> str:
"""SHA-256 hex digest for storing single-use tokens (email-link tokens, the setup token)
by hash rather than in cleartext. Not a password hash these are high-entropy randoms."""
return hashlib.sha256(raw.encode()).hexdigest()
def hash_password(password: str) -> str:
return _ph.hash(password)

View file

@ -1,5 +1,4 @@
"""Global admin-controlled app state (e.g. pausing background sync, first-run setup)."""
import hashlib
import logging
import secrets
@ -7,6 +6,7 @@ from sqlalchemy.orm import Session
from app.config import settings
from app.models import AppState
from app.security import hash_token
log = logging.getLogger("siftlode.state")
@ -73,16 +73,12 @@ def mark_configured(db: Session) -> None:
log.info("Instance marked configured; setup wizard disabled.")
def _hash_token(raw: str) -> str:
return hashlib.sha256(raw.encode()).hexdigest()
def rotate_setup_token(db: Session) -> str:
"""Generate a fresh one-time setup token, persist only its hash, and return the raw token
(logged at startup so the admin can open the wizard)."""
raw = secrets.token_urlsafe(24)
row = _row(db)
row.setup_token_hash = _hash_token(raw)
row.setup_token_hash = hash_token(raw)
db.add(row)
db.commit()
return raw
@ -93,7 +89,7 @@ def check_setup_token(db: Session, raw: str | None) -> bool:
if not raw:
return False
stored = _row(db).setup_token_hash
return stored is not None and secrets.compare_digest(stored, _hash_token(raw))
return stored is not None and secrets.compare_digest(stored, hash_token(raw))
def get_maintenance_batch(db: Session) -> int:

View file

@ -20,18 +20,19 @@ Quota: phase 1 costs ~(flagged / 50) units, phase 2 ~(batch / 50). Both respect
backfill reserve as the other jobs so interactive syncs never starve.
"""
import logging
from datetime import datetime, timedelta, timezone
from datetime import timedelta
from sqlalchemy import or_, select
from sqlalchemy import select
from sqlalchemy.orm import Session
from app import progress, quota, sysconfig
from app.config import settings
from app.models import Playlist, PlaylistItem, Video, VideoState
from app.models import Playlist, PlaylistItem, Video
from app.notifications import create_notification
from app.state import get_maintenance_batch
from app.sync.runner import get_service_user
from app.sync.videos import apply_video_details, parse_dt
from app.utils import now_utc as _now
from app.youtube.client import YouTubeClient
log = logging.getLogger("siftlode.maintenance")
@ -45,10 +46,6 @@ GRACE_DAYS_BY_REASON = {
DEFAULT_GRACE_DAYS = settings.maintenance_grace_days
def _now() -> datetime:
return datetime.now(timezone.utc)
def _grace_days(reason: str | None) -> int:
return GRACE_DAYS_BY_REASON.get(reason or "", DEFAULT_GRACE_DAYS)
@ -91,14 +88,9 @@ def _collect_user_impact(db: Session, videos: list[Video]) -> dict[int, list[dic
impact: dict[int, set[str]] = {}
if not ids:
return {}
# Saved (VideoState.status == "saved").
for user_id, video_id in db.execute(
select(VideoState.user_id, VideoState.video_id).where(
VideoState.video_id.in_(ids), VideoState.status == "saved"
)
):
impact.setdefault(user_id, set()).add(video_id)
# In any of the user's playlists (covers watch-later, which is a playlist).
# Anyone who had it in a playlist — which now also covers "saved": Watch later became a
# built-in playlist, so "saved" is no longer a VideoState status (the old status=="saved"
# scan matched nothing and under-counted impacted users).
for user_id, video_id in db.execute(
select(Playlist.user_id, PlaylistItem.video_id)
.join(PlaylistItem, PlaylistItem.playlist_id == Playlist.id)

View file

@ -94,6 +94,34 @@ def _ensure_videos(db: Session, yt: YouTubeClient, video_ids: list[str]) -> None
db.commit()
def _replace_items_from_youtube(
db: Session, yt: YouTubeClient, pl: Playlist, ids: list[str]
) -> list[str]:
"""Mirror a YouTube playlist's ordered video ids into `pl`'s local items (authoritative
replace): ensure the videos exist in the catalog, keep only those present in their YT
order (de-duplicated), swap the items out, and reset the synced fingerprint + dirty flag.
`pl.name` must already be set to the desired name. The caller commits. Returns the final
ordered ids."""
_ensure_videos(db, yt, ids)
present = (
set(db.execute(select(Video.id).where(Video.id.in_(ids))).scalars().all())
if ids
else set()
)
ordered: list[str] = []
seen: set[str] = set()
for v in ids:
if v in present and v not in seen:
seen.add(v)
ordered.append(v)
db.execute(delete(PlaylistItem).where(PlaylistItem.playlist_id == pl.id))
for pos, vid in enumerate(ordered):
db.add(PlaylistItem(playlist_id=pl.id, video_id=vid, position=pos))
pl.synced_fingerprint = fingerprint(pl.name, ordered)
pl.dirty = False
return ordered
def sync_user_playlists(db: Session, user: User) -> dict:
"""Mirror the user's YouTube playlists into local source='youtube' playlists. Leaves
the user's local playlists and the built-in Watch later untouched."""
@ -152,25 +180,9 @@ def sync_user_playlists(db: Session, user: User) -> dict:
db.flush()
else:
pl.name = p.get("title") or pl.name
ids = list(yt.iter_my_playlist_video_ids(ytid))
_ensure_videos(db, yt, ids)
present = (
set(db.execute(select(Video.id).where(Video.id.in_(ids))).scalars().all())
if ids
else set()
)
ordered: list[str] = []
seen: set[str] = set()
for v in ids:
if v in present and v not in seen:
seen.add(v)
ordered.append(v)
# Mirror is authoritative: replace the items to match YouTube's order.
db.execute(delete(PlaylistItem).where(PlaylistItem.playlist_id == pl.id))
for pos, vid in enumerate(ordered):
db.add(PlaylistItem(playlist_id=pl.id, video_id=vid, position=pos))
pl.synced_fingerprint = fingerprint(pl.name, ordered)
pl.dirty = False
ids = list(yt.iter_my_playlist_video_ids(ytid))
_replace_items_from_youtube(db, yt, pl, ids)
db.commit()
synced += 1
return {"synced": synced}
@ -349,25 +361,9 @@ def repull_playlist(db: Session, user: User, pl: Playlist) -> dict:
snippet = yt.get_playlist_snippet(pl.yt_playlist_id)
if snippet is None:
raise YouTubeError("Playlist no longer exists on YouTube")
ids = list(yt.iter_my_playlist_video_ids(pl.yt_playlist_id))
_ensure_videos(db, yt, ids)
present = (
set(db.execute(select(Video.id).where(Video.id.in_(ids))).scalars().all())
if ids
else set()
)
ordered: list[str] = []
seen: set[str] = set()
for v in ids:
if v in present and v not in seen:
seen.add(v)
ordered.append(v)
pl.name = snippet.get("title") or pl.name
db.execute(delete(PlaylistItem).where(PlaylistItem.playlist_id == pl.id))
for pos, vid in enumerate(ordered):
db.add(PlaylistItem(playlist_id=pl.id, video_id=vid, position=pos))
pl.synced_fingerprint = fingerprint(pl.name, ordered)
pl.dirty = False
ids = list(yt.iter_my_playlist_video_ids(pl.yt_playlist_id))
ordered = _replace_items_from_youtube(db, yt, pl, ids)
db.commit()
return {"items": len(ordered)}

17
backend/app/utils.py Normal file
View file

@ -0,0 +1,17 @@
"""Small cross-cutting helpers shared across modules (kept dependency-light on purpose)."""
import re
from datetime import datetime, timezone
# Single source of truth for "looks like an email" — used by every endpoint that accepts an
# address (auth register/reset/demo, admin invites + demo whitelist, the setup wizard). Keep
# the strictness in one place so routes don't drift between this and a looser "@"-in check.
_EMAIL_RE = re.compile(r"^[^@\s]+@[^@\s]+\.[^@\s]+$")
def valid_email(value: str | None) -> bool:
return bool(value and _EMAIL_RE.match(value))
def now_utc() -> datetime:
"""Current time as a timezone-aware UTC datetime."""
return datetime.now(timezone.utc)

View file

@ -19,6 +19,7 @@ import {
} from "./lib/sidebarLayout";
import { configureNotifications, getNotifSettings, notify, removeByMetaKind, type NotifSettings } from "./lib/notifications";
import { hintsEnabled, setHintsEnabled } from "./lib/hints";
import { LS, readMerged, usePersistedState } from "./lib/storage";
import { useConfirm } from "./components/ConfirmProvider";
import type { PrefsController } from "./components/SettingsPanel";
import Welcome from "./components/Welcome";
@ -59,9 +60,9 @@ const DEFAULT_FILTERS: FeedFilters = {
show: "unwatched",
};
const FILTERS_KEY = "siftlode.filters";
const PAGE_KEY = "siftlode.page";
const PERF_KEY = "siftlode.perfMode";
const FILTERS_KEY = LS.filters;
const PAGE_KEY = LS.page;
const PERF_KEY = LS.perfMode;
// The preferences edited on the Settings page. They apply locally for instant preview but
// persist to the server only on an explicit Save — so App holds both the live "draft" (the
@ -84,11 +85,7 @@ function loadInitialPage(): Page {
}
function loadStoredFilters(): FeedFilters {
try {
return { ...DEFAULT_FILTERS, ...JSON.parse(localStorage.getItem(FILTERS_KEY) || "{}") };
} catch {
return DEFAULT_FILTERS;
}
return readMerged(FILTERS_KEY, DEFAULT_FILTERS);
}
// URL wins over localStorage so a pasted link reproduces the exact view.
@ -126,18 +123,13 @@ export default function App() {
const [sidebarLayout, setSidebarLayoutState] = useState<SidebarLayout>(loadLayout);
const [page, setPageState] = useState<Page>(loadInitialPage);
const [wizardOpen, setWizardOpen] = useState(false);
const CHANNEL_FILTER_KEY = "siftlode.channelFilter";
const [channelFilter, setChannelFilterState] = useState<ChannelStatusFilter>(() => {
const v = localStorage.getItem(CHANNEL_FILTER_KEY);
return v === "needs_full" || v === "fully_synced" || v === "hidden" || v === "all"
? v
: "all";
});
// Persist the channel status chip so a reload (F5) keeps it.
const setChannelFilter = (f: ChannelStatusFilter) => {
setChannelFilterState(f);
localStorage.setItem(CHANNEL_FILTER_KEY, f);
};
// Channel status chip, persisted so a reload (F5) keeps it; clamp a stale value at render.
const [channelFilterRaw, setChannelFilter] = usePersistedState(LS.channelFilter, "all");
const channelFilter: ChannelStatusFilter = (
["needs_full", "fully_synced", "hidden", "all"] as const
).includes(channelFilterRaw as ChannelStatusFilter)
? (channelFilterRaw as ChannelStatusFilter)
: "all";
const [aboutOpen, setAboutOpen] = useState(false);
const [notesOpen, setNotesOpen] = useState(false);
const [notesHighlight, setNotesHighlight] = useState<string | undefined>(undefined);
@ -145,14 +137,9 @@ export default function App() {
// persisted so navigation intents that target the subscriptions table — the header's
// "without full history" link, focus-channel — can force it back to "subscribed" instead
// of dumping the user on whatever tab they last left open.
const CHANNELS_VIEW_KEY = "siftlode.channelsView";
const [channelsView, setChannelsViewState] = useState<ChannelsView>(() =>
localStorage.getItem(CHANNELS_VIEW_KEY) === "discovery" ? "discovery" : "subscribed"
);
const setChannelsView = (v: ChannelsView) => {
setChannelsViewState(v);
localStorage.setItem(CHANNELS_VIEW_KEY, v);
};
const [channelsViewRaw, setChannelsView] = usePersistedState(LS.channelsView, "subscribed");
const channelsView: ChannelsView =
channelsViewRaw === "discovery" ? "discovery" : "subscribed";
// Bumped to tell the channel manager to drop a stale column filter when we send the user
// there to see a specific set (the header's "without full history" link).
const [channelsFilterReset, setChannelsFilterReset] = useState(0);
@ -318,7 +305,7 @@ export default function App() {
if (result === "ok") {
notify({ level: "success", message: t("settings.account.googleLink.linked") });
void meQuery.refetch();
localStorage.setItem("siftlode.settingsTab", "account");
localStorage.setItem(LS.settingsTab, "account");
setPage("settings");
} else {
const key = result === "conflict" || result === "mismatch" ? result : "error";

View file

@ -4,6 +4,7 @@ import { useTranslation } from "react-i18next";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { Check, ListPlus, Plus, Youtube } from "lucide-react";
import { api, type Playlist } from "../lib/api";
import { useDismiss } from "../lib/useDismiss";
// A small popover (portaled to body, so the feed grid can't clip it) for toggling a
// video's membership in the user's playlists, with inline "new playlist" creation.
@ -59,25 +60,13 @@ export default function AddToPlaylist({
setOpen((o) => !o);
}
useDismiss(open, () => setOpen(false), [panelRef, triggerRef]);
// Keep the panel anchored to the trigger as the page resizes/scrolls while open.
useEffect(() => {
if (!open) return;
function onDoc(e: MouseEvent) {
if (
!panelRef.current?.contains(e.target as Node) &&
!triggerRef.current?.contains(e.target as Node)
)
setOpen(false);
}
function onKey(e: KeyboardEvent) {
if (e.key === "Escape") setOpen(false);
}
document.addEventListener("mousedown", onDoc);
document.addEventListener("keydown", onKey);
window.addEventListener("resize", place);
window.addEventListener("scroll", place, true);
return () => {
document.removeEventListener("mousedown", onDoc);
document.removeEventListener("keydown", onKey);
window.removeEventListener("resize", place);
window.removeEventListener("scroll", place, true);
};

View file

@ -4,7 +4,9 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Ban, Check, RotateCcw, Shield, Trash2, UserPlus, X } from "lucide-react";
import { api, type AdminUserRow, type Invite, type Me } from "../lib/api";
import { notify } from "../lib/notifications";
import { LS } from "../lib/storage";
import Tooltip from "./Tooltip";
import { Section } from "./ui/form";
import { useConfirm } from "./ConfirmProvider";
import Tabs, { usePersistedTab } from "./Tabs";
@ -14,7 +16,7 @@ import Tabs, { usePersistedTab } from "./Tabs";
// The persisted-tab storage key + the access-requests tab id, exported so a notification's
// "Review" link can pre-select that tab before navigating here (usePersistedTab reads the key
// on mount, and this page mounts fresh on navigation).
export const ADMIN_USERS_TAB_KEY = "siftlode.adminUsersTab";
export const ADMIN_USERS_TAB_KEY = LS.adminUsersTab;
export const ADMIN_USERS_ACCESS_TAB = "access";
export function focusAccessRequestsTab(): void {
@ -40,15 +42,6 @@ export default function AdminUsers({ me }: { me: Me }) {
);
}
function Section({ title, children }: { title: string; children: React.ReactNode }) {
return (
<div className="glass rounded-2xl p-4 mb-4">
<div className="text-xs uppercase tracking-wide text-muted mb-3">{title}</div>
{children}
</div>
);
}
function Badge({ tone, children }: { tone: "accent" | "muted" | "warning"; children: React.ReactNode }) {
const cls =
tone === "accent"
@ -137,7 +130,7 @@ function UsersRoles({ me }: { me: Me }) {
const rows = q.data ?? [];
return (
<Section title={t("users.roles.title")}>
<Section card title={t("users.roles.title")}>
<p className="text-xs text-muted leading-relaxed mb-3">{t("users.roles.intro")}</p>
{rows.length === 0 ? (
<p className="text-sm text-muted">{t("users.roles.empty")}</p>
@ -265,7 +258,7 @@ function AdminInvites() {
const decided = list.filter((i) => i.status !== "pending");
return (
<Section title={t("settings.invites.title")}>
<Section card title={t("settings.invites.title")}>
{pending.length === 0 ? (
<p className="text-sm text-muted">{t("settings.invites.noPending")}</p>
) : (
@ -359,7 +352,7 @@ function AdminDemo() {
const rows = list.data ?? [];
return (
<Section title={t("settings.demo.title")}>
<Section card title={t("settings.demo.title")}>
<p className="text-xs text-muted leading-relaxed mb-2">{t("settings.demo.intro")}</p>
{rows.length > 0 && (

View file

@ -1,4 +1,4 @@
import { useEffect, useRef, useState } from "react";
import { useRef, useState } from "react";
import { Trans, useTranslation } from "react-i18next";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
@ -14,7 +14,8 @@ import {
UserMinus,
} from "lucide-react";
import { api, HttpError, type ManagedChannel, type Tag } from "../lib/api";
import { formatEta, formatViews, relativeTime } from "../lib/format";
import { useDismiss } from "../lib/useDismiss";
import { formatEta, formatTotalHours, formatViews, relativeTime } from "../lib/format";
import { notify } from "../lib/notifications";
import Tooltip from "./Tooltip";
import DataTable, { type Column } from "./DataTable";
@ -27,13 +28,6 @@ export type ChannelsView = "subscribed" | "discovery";
export type ChannelStatusFilter = "all" | "needs_full" | "fully_synced" | "hidden";
// Compact total-duration label for a whole channel (hours-scale, so H:MM:SS would be huge).
function fmtTotalDuration(sec: number): string {
if (!sec) return "—";
const h = Math.round(sec / 3600);
return h >= 1 ? `${h.toLocaleString()} h` : `${Math.max(1, Math.round(sec / 60))} m`;
}
const STATUS_FILTERS: { id: ChannelStatusFilter; labelKey: string }[] = [
{ id: "all", labelKey: "channels.filters.all" },
{ id: "needs_full", labelKey: "channels.filters.needsFull" },
@ -286,7 +280,7 @@ export default function Channels({
nowrap: true,
sortable: true,
sortValue: (c) => c.total_duration_seconds,
render: (c) => <span className="text-muted tabular-nums">{fmtTotalDuration(c.total_duration_seconds)}</span>,
render: (c) => <span className="text-muted tabular-nums">{formatTotalHours(c.total_duration_seconds)}</span>,
},
{
key: "types",
@ -616,19 +610,7 @@ function TagsCell({
const { t } = useTranslation();
const [open, setOpen] = useState(false);
const ref = useRef<HTMLDivElement | null>(null);
useEffect(() => {
if (!open) return;
const onDown = (e: MouseEvent) => {
if (!ref.current?.contains(e.target as Node)) setOpen(false);
};
const onKey = (e: KeyboardEvent) => e.key === "Escape" && setOpen(false);
document.addEventListener("mousedown", onDown);
document.addEventListener("keydown", onKey);
return () => {
document.removeEventListener("mousedown", onDown);
document.removeEventListener("keydown", onKey);
};
}, [open]);
useDismiss(open, () => setOpen(false), ref);
if (userTags.length === 0) return null;
// Show only the tags actually attached to this channel; the “+” opens a per-channel
// picker to attach/detach (kept the convenient "kirakás" without listing every tag

View file

@ -1,11 +1,13 @@
import { useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Check, RotateCcw, Save, Send } from "lucide-react";
import { RotateCcw, Send } from "lucide-react";
import { api, type ConfigItem } from "../lib/api";
import { notify } from "../lib/notifications";
import Tooltip from "./Tooltip";
import Tabs, { usePersistedTab } from "./Tabs";
import { Switch } from "./ui/form";
import { DraftSaveBar } from "./ui/DraftSaveBar";
// Admin Configuration page: edit DB-overridable operational settings (registry-driven from the
// backend — adding a key there makes it appear here automatically). Edits are drafted and
@ -150,40 +152,21 @@ export default function ConfigPanel() {
</div>
)}
{(dirty || saveState !== "idle") && (
<div className="fixed bottom-4 left-1/2 -translate-x-1/2 z-40 glass rounded-2xl shadow-lg flex items-center justify-between gap-4 px-4 py-3 w-[min(48rem,calc(100%-2rem))]">
<span className="text-sm text-muted">
{saveState === "saved" ? (
<span className="text-emerald-500 inline-flex items-center gap-1.5">
<Check className="w-4 h-4" />
{t("config.saved")}
</span>
) : saveState === "error" ? (
<span className="text-red-500">{t("config.saveFailed")}</span>
) : (
t("config.unsaved")
)}
</span>
<div className="flex items-center gap-2">
<button
onClick={discard}
disabled={saveState === "saving" || !dirty}
className="glass-card glass-hover flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-sm disabled:opacity-40 transition"
>
<RotateCcw className="w-4 h-4" />
{t("config.discard")}
</button>
<button
onClick={save}
disabled={saveState === "saving" || !dirty}
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-sm bg-accent text-accent-fg font-medium shadow-md shadow-accent/20 disabled:opacity-40 transition"
>
<Save className="w-4 h-4" />
{saveState === "saving" ? t("config.saving") : t("config.save")}
</button>
</div>
</div>
)}
<DraftSaveBar
variant="floating"
dirty={dirty}
state={saveState}
onSave={save}
onDiscard={discard}
labels={{
saved: t("config.saved"),
failed: t("config.saveFailed"),
unsaved: t("config.unsaved"),
discard: t("config.discard"),
save: t("config.save"),
saving: t("config.saving"),
}}
/>
</div>
);
}
@ -241,7 +224,7 @@ function Field({
<div className="flex flex-col items-end gap-1.5 shrink-0">
{isBool ? (
<Toggle checked={value === "true"} onChange={(v) => onChange(v ? "true" : "false")} />
<Switch checked={value === "true"} onChange={(v) => onChange(v ? "true" : "false")} />
) : (
<input
type={item.secret ? "password" : isNum ? "number" : "text"}
@ -273,19 +256,3 @@ function Field({
</div>
);
}
function Toggle({ checked, onChange }: { checked: boolean; onChange: (v: boolean) => void }) {
return (
<button
type="button"
onClick={() => onChange(!checked)}
className={`w-9 h-5 rounded-full transition relative shrink-0 ${checked ? "bg-accent" : "bg-border"}`}
>
<span
className={`absolute top-0.5 w-4 h-4 rounded-full bg-white shadow transition-all ${
checked ? "left-[18px]" : "left-0.5"
}`}
/>
</button>
);
}

View file

@ -1,6 +1,7 @@
import { useEffect, useRef, useState, type ReactNode } from "react";
import { useTranslation } from "react-i18next";
import { ArrowDown, ArrowUp, ChevronLeft, ChevronRight, ListFilter, X } from "lucide-react";
import { useDismiss } from "../lib/useDismiss";
// A reusable, client-side data table: per-column sort + filter (in the header) + pagination,
// with its sort/filter/page state optionally persisted to localStorage so a reload (F5) keeps
@ -121,21 +122,7 @@ export default function DataTable<T>({
setPage(0);
}, [resetFiltersToken]);
useEffect(() => {
if (!openFilter) return;
function onDown(e: MouseEvent) {
if (!popRef.current?.contains(e.target as Node)) setOpenFilter(null);
}
function onKey(e: KeyboardEvent) {
if (e.key === "Escape") setOpenFilter(null);
}
document.addEventListener("mousedown", onDown);
document.addEventListener("keydown", onKey);
return () => {
document.removeEventListener("mousedown", onDown);
document.removeEventListener("keydown", onKey);
};
}, [openFilter]);
useDismiss(!!openFilter, () => setOpenFilter(null), popRef);
const filtered = rows.filter((row) =>
columns.every((col) => {

View file

@ -22,7 +22,9 @@ export default function Messages({ meId }: { meId: number }) {
// the module (open() pushes an entry; back() = history.back()).
const { view, open, back } = useHistorySubview<View>("list");
if (typeof view === "object") {
// Guard against a stale sub-view whose partnerId is the current user's own id (you can't
// message yourself). This can be inherited across an account switch — fall back to the list.
if (typeof view === "object" && view.partnerId !== meId) {
return (
<PageThread
meId={meId}

View file

@ -23,6 +23,7 @@ import {
import { api, type Me } from "../lib/api";
import { useLiveQuery } from "../lib/useLiveQuery";
import { getUnreadCount, subscribe } from "../lib/notifications";
import { LS } from "../lib/storage";
import type { Page } from "../lib/urlState";
import { type LangCode } from "../i18n";
import AvatarImg from "./Avatar";
@ -48,7 +49,7 @@ export default function NavSidebar({
}) {
const { t } = useTranslation();
const [collapsed, setCollapsed] = useState<boolean>(
() => localStorage.getItem("siftlode.navCollapsed") === "1"
() => localStorage.getItem(LS.navCollapsed) === "1"
);
const [acctOpen, setAcctOpen] = useState(false);
const acctBtnRef = useRef<HTMLButtonElement | null>(null);
@ -90,7 +91,7 @@ export default function NavSidebar({
function toggleCollapsed() {
setCollapsed((c) => {
const next = !c;
localStorage.setItem("siftlode.navCollapsed", next ? "1" : "0");
localStorage.setItem(LS.navCollapsed, next ? "1" : "0");
return next;
});
}
@ -111,6 +112,14 @@ export default function NavSidebar({
async function switchTo(id: number) {
try {
await api.switchAccount(id);
// Drop the previous account's in-module sub-view/overlay before the reload (which would
// otherwise preserve history.state across the swap). Otherwise the new account lands on a
// stale sub-view — e.g. an open chat thread whose partnerId belongs to the OLD identity
// (often the new account's own id → an empty self-thread). Start at the module root instead.
const st = { ...(window.history.state || {}) };
delete st._sub;
delete st._ov;
window.history.replaceState(st, "");
location.reload(); // cleanest way to reload all per-user state for the new account
} catch {
/* a revoked/removed account — leave the popover open */

View file

@ -19,7 +19,7 @@ import {
type VideoHiddenMeta,
type VideoWatchedMeta,
} from "../lib/notifications";
import { channelYouTubeUrl } from "../lib/format";
import { channelYouTubeUrl, relativeFromMs, relativeTime } from "../lib/format";
import type { Page } from "../lib/urlState";
import { LEVEL_STYLE } from "./Toaster";
@ -29,22 +29,6 @@ import { LEVEL_STYLE } from "./Toaster";
// there's one indicator and one place to look. Transient toasts still pop via the Toaster.
const POLL_MS = 8000;
function relativeTime(iso: string | null, t: (k: string, o?: any) => string): string {
if (!iso) return "";
return relativeFromMs(new Date(iso).getTime(), t);
}
function relativeFromMs(ms: number, t: (k: string, o?: any) => string): string {
const secs = Math.max(0, Math.round((Date.now() - ms) / 1000));
// Reuse the bell's relative-time strings (notifications.time.*) — same format, no dupes.
if (secs < 60) return t("notifications.time.justNow");
const mins = Math.round(secs / 60);
if (mins < 60) return t("notifications.time.minutes", { count: mins });
const hours = Math.round(mins / 60);
if (hours < 24) return t("notifications.time.hours", { count: hours });
return t("notifications.time.days", { count: Math.round(hours / 24) });
}
export default function NotificationsPanel({
filters,
setFilters,
@ -253,7 +237,7 @@ function NotificationRow({
<div className="flex items-baseline gap-2">
<span className="font-medium text-sm">{title}</span>
<span className="text-[11px] text-muted shrink-0">
{relativeTime(n.created_at, t)}
{relativeTime(n.created_at)}
</span>
</div>
{body && <div className="text-sm text-muted mt-0.5">{body}</div>}
@ -338,7 +322,7 @@ function ClientActivityRow({
{n.title && <div className="text-sm font-semibold">{n.title}</div>}
<div className="text-sm break-words">{n.message}</div>
<div className="flex items-center gap-2 mt-0.5">
<span className="text-[11px] text-muted">{relativeFromMs(n.ts, t)}</span>
<span className="text-[11px] text-muted">{relativeFromMs(n.ts)}</span>
{needsAction && (
<span className="text-[10px] uppercase tracking-wide font-semibold text-accent">
{t("notifications.actionNeeded")}

View file

@ -1,6 +1,5 @@
import { useEffect, useRef, useState, type ReactNode } from "react";
import { useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import type { TFunction } from "i18next";
import { createPortal } from "react-dom";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { AlertTriangle, ArrowLeft, Check, CheckCheck, ExternalLink, SkipBack, SkipForward, X } from "lucide-react";
@ -8,152 +7,9 @@ import Avatar from "./Avatar";
import AddToPlaylist from "./AddToPlaylist";
import { api, type Video } from "../lib/api";
import { channelYouTubeUrl, formatDuration, formatViews, relativeTime } from "../lib/format";
import { renderDescription } from "../lib/descriptionLinks";
import { useBackToClose } from "../lib/history";
// Turn a description into clickable nodes:
// - bare timestamps (mm:ss / hh:mm:ss) → seek the inline player
// - a YouTube link to the *current* video → seek (to its t= if any)
// - a YouTube link to *another* video → load it in the inline player
// - emails → mailto:, hashtags → YouTube's hashtag page, other URLs → new tab
const URL_RE = /(https?:\/\/[^\s<>]+)/g;
// One pass over plain text for the three inline token kinds (email | hashtag | timestamp).
const INLINE_RE =
/([A-Za-z0-9._%+-]+@[A-Za-z0-9-]+\.[A-Za-z0-9.-]+)|(#[\p{L}\p{N}_]+)|(\b(?:\d{1,2}:)?\d{1,2}:\d{2}\b)/gu;
function tsToSeconds(ts: string): number {
const parts = ts.split(":").map((n) => parseInt(n, 10));
return parts.length === 3
? parts[0] * 3600 + parts[1] * 60 + parts[2]
: parts[0] * 60 + parts[1];
}
// Parse a YouTube `t`/`start` param: "90", "90s", or "1h2m3s".
function parseStart(t: string | null): number | null {
if (!t) return null;
if (/^\d+$/.test(t)) return parseInt(t, 10) || null;
const m = t.match(/^(?:(\d+)h)?(?:(\d+)m)?(?:(\d+)s)?$/);
if (!m) return null;
const total =
parseInt(m[1] || "0", 10) * 3600 +
parseInt(m[2] || "0", 10) * 60 +
parseInt(m[3] || "0", 10);
return total || null;
}
// Extract a video id (+ optional start) from a YouTube URL, else null.
function parseYouTube(url: string): { id: string; start: number | null } | null {
let u: URL;
try {
u = new URL(url);
} catch {
return null;
}
const host = u.hostname.replace(/^www\./, "");
let id: string | null = null;
if (host === "youtu.be") {
id = u.pathname.slice(1) || null;
} else if (host === "youtube.com" || host === "m.youtube.com" || host === "music.youtube.com") {
if (u.pathname === "/watch") id = u.searchParams.get("v");
else {
const m = u.pathname.match(/^\/(?:shorts|embed|live)\/([^/?#]+)/);
if (m) id = m[1];
}
}
if (!id) return null;
return { id, start: parseStart(u.searchParams.get("t") || u.searchParams.get("start")) };
}
function renderDescription(
text: string,
opts: {
currentId: string;
onSeek: (seconds: number) => void;
onLoadVideo: (id: string, start: number | null) => void;
t: TFunction;
}
): ReactNode[] {
const { t } = opts;
const out: ReactNode[] = [];
let key = 0;
const linkCls = "text-accent hover:underline break-all";
// Tidy YouTube descriptions: drop trailing spaces and remove blank lines entirely
// (they're just noise in the popover), keeping single line breaks.
const clean = text.replace(/[ \t]+\n/g, "\n").replace(/\n{2,}/g, "\n").trim();
for (const chunk of clean.split(URL_RE)) {
if (/^https?:\/\//.test(chunk)) {
const href = chunk.replace(/[.,;:!?)\]]+$/, "");
const yt = parseYouTube(href);
if (yt) {
const sameVideo = yt.id === opts.currentId;
out.push(
<button
key={key++}
onClick={() =>
sameVideo ? opts.onSeek(yt.start ?? 0) : opts.onLoadVideo(yt.id, yt.start)
}
className={linkCls}
title={sameVideo ? t("player.jumpToTime") : t("player.playInApp")}
>
{href}
</button>
);
} else {
out.push(
<a key={key++} href={href} target="_blank" rel="noreferrer" className={linkCls}>
{href}
</a>
);
}
continue;
}
// Plain text: pull out emails, hashtags and bare timestamps.
INLINE_RE.lastIndex = 0;
let last = 0;
let m: RegExpExecArray | null;
while ((m = INLINE_RE.exec(chunk))) {
if (m.index > last) out.push(<span key={key++}>{chunk.slice(last, m.index)}</span>);
if (m[1]) {
// Email — trim trailing punctuation the domain rule may have swallowed.
const email = m[1].replace(/[.,;:]+$/, "");
out.push(
<a key={key++} href={`mailto:${email}`} className={linkCls}>
{email}
</a>
);
if (m[1].length > email.length) out.push(<span key={key++}>{m[1].slice(email.length)}</span>);
} else if (m[2]) {
// Hashtag → YouTube's hashtag feed (mirrors native YouTube behavior).
const tag = m[2].slice(1);
out.push(
<a
key={key++}
href={`https://www.youtube.com/hashtag/${encodeURIComponent(tag)}`}
target="_blank"
rel="noreferrer"
className={linkCls}
>
{m[2]}
</a>
);
} else {
const ts = m[3];
out.push(
<button
key={key++}
onClick={() => opts.onSeek(tsToSeconds(ts))}
className="text-accent hover:underline"
>
{ts}
</button>
);
}
last = m.index + m[0].length;
}
if (last < chunk.length) out.push(<span key={key++}>{chunk.slice(last)}</span>);
}
return out;
}
// Experiment (branch experiment/inline-player): play the video in-app via the
// YouTube IFrame Player API (not a bare embed) so we can read playback position
// and resume where the user left off. The modal closes via the in-card Close

View file

@ -37,6 +37,7 @@ import {
import { api, type Playlist, type Video } from "../lib/api";
import { formatDuration } from "../lib/format";
import { notify } from "../lib/notifications";
import { LS, readMerged, writeJSON } from "../lib/storage";
import { useUndoable } from "../lib/useUndoable";
import PlayerModal from "./PlayerModal";
import UndoToolbar from "./UndoToolbar";
@ -187,26 +188,18 @@ export default function Playlists({ canWrite }: { canWrite: boolean }) {
const confirm = useConfirm();
// Persist the selected playlist so F5 keeps it (the de-URL refactor dropped it from the URL).
const [selectedId, setSelectedId] = useState<number | null>(() => {
const s = localStorage.getItem("siftlode.playlist");
const s = localStorage.getItem(LS.playlist);
return s ? Number(s) : null;
});
useEffect(() => {
if (selectedId != null) localStorage.setItem("siftlode.playlist", String(selectedId));
if (selectedId != null) localStorage.setItem(LS.playlist, String(selectedId));
}, [selectedId]);
const selectedRef = useRef<HTMLButtonElement | null>(null);
const scrolledRef = useRef(false);
// How the left playlist rail is ordered (persisted).
const [plSort, setPlSort] = useState<PlSort>(() => {
try {
const s = localStorage.getItem("siftlode.plSort");
if (s) return { ...PL_SORT_DEFAULT, ...JSON.parse(s) };
} catch {
/* ignore */
}
return PL_SORT_DEFAULT;
});
const [plSort, setPlSort] = useState<PlSort>(() => readMerged(LS.plSort, PL_SORT_DEFAULT));
useEffect(() => {
localStorage.setItem("siftlode.plSort", JSON.stringify(plSort));
writeJSON(LS.plSort, plSort);
}, [plSort]);
const [newName, setNewName] = useState("");
const [renaming, setRenaming] = useState(false);

View file

@ -1,5 +1,6 @@
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import i18n from "../i18n";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import {
Activity,
@ -31,7 +32,7 @@ function secsUntil(iso: string | null): number | null {
}
function fmtCountdown(s: number): string {
if (s <= 0) return "now";
if (s <= 0) return i18n.t("time.eta.now");
if (s < 60) return `${s}s`;
const m = Math.floor(s / 60);
if (m < 60) return `${m}m ${s % 60}s`;

View file

@ -1,12 +1,15 @@
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { useQueryClient } from "@tanstack/react-query";
import { Bell, Check, Monitor, RotateCcw, Save, Trash2, User } from "lucide-react";
import { Bell, Monitor, Trash2, User } from "lucide-react";
import { SCHEMES, type Scheme, type ThemePrefs } from "../lib/theme";
import { api, type Me } from "../lib/api";
import { LS, usePersistedState } from "../lib/storage";
import Avatar from "./Avatar";
import { notify, type NotifSettings } from "../lib/notifications";
import Tooltip from "./Tooltip";
import { Section, SettingRow, Switch } from "./ui/form";
import { DraftSaveBar } from "./ui/DraftSaveBar";
import { useConfirm } from "./ConfirmProvider";
// The Settings page edits server-persisted preferences as a draft: changes apply locally
@ -38,14 +41,6 @@ const TABS: { id: TabId; icon: typeof Monitor }[] = [
{ id: "account", icon: User },
];
// Persist the active tab so a reload (F5) keeps the user where they were instead of
// snapping back to "appearance".
const SETTINGS_TAB_KEY = "siftlode.settingsTab";
function loadSettingsTab(): TabId {
const v = localStorage.getItem(SETTINGS_TAB_KEY);
return TABS.some((tabItem) => tabItem.id === v) ? (v as TabId) : "appearance";
}
// Settings as a page (Design B): rendered in the main content area, not an overlay. It keeps
// the frosted `.glass` surface so it still refracts the ambient backdrop. The page title is
// shown by the Header; the tab rail stays as in-page sub-navigation.
@ -59,11 +54,11 @@ export default function SettingsPanel({
onOpenWizard: () => void;
}) {
const { t } = useTranslation();
const [tab, setTabState] = useState<TabId>(loadSettingsTab);
const setTab = (id: TabId) => {
setTabState(id);
localStorage.setItem(SETTINGS_TAB_KEY, id);
};
const [tabRaw, setTab] = usePersistedState(LS.settingsTab, "appearance");
// Clamp at render so a stale/removed tab id falls back to Appearance.
const tab: TabId = TABS.some((tabItem) => tabItem.id === tabRaw)
? (tabRaw as TabId)
: "appearance";
return (
<div className="p-4 max-w-3xl w-full mx-auto">
@ -110,87 +105,22 @@ export default function SettingsPanel({
function PrefsSaveBar({ prefs }: { prefs: PrefsController }) {
const { t } = useTranslation();
// Stay mounted while a transient "saved"/"error" message is fading, even after dirty clears.
if (!prefs.dirty && prefs.saveState === "idle") return null;
const saving = prefs.saveState === "saving";
return (
<div className="flex items-center justify-between gap-3 border-t border-border/60 px-4 py-3 bg-card/40">
<span className="text-sm text-muted">
{prefs.saveState === "saved"
? <span className="text-emerald-500 inline-flex items-center gap-1.5"><Check className="w-4 h-4" />{t("settings.save.saved")}</span>
: prefs.saveState === "error"
? <span className="text-red-500">{t("settings.save.failed")}</span>
: t("settings.save.unsaved")}
</span>
<div className="flex items-center gap-2">
<button
onClick={prefs.discard}
disabled={saving || !prefs.dirty}
className="glass-card glass-hover flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-sm disabled:opacity-40 transition"
>
<RotateCcw className="w-4 h-4" />
{t("settings.save.discard")}
</button>
<button
onClick={prefs.save}
disabled={saving || !prefs.dirty}
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-sm bg-accent text-accent-fg font-medium shadow-md shadow-accent/20 disabled:opacity-40 transition"
>
<Save className="w-4 h-4" />
{saving ? t("settings.save.saving") : t("settings.save.save")}
</button>
</div>
</div>
);
}
function Section({ title, children }: { title: string; children: React.ReactNode }) {
return (
<div className="mb-6">
<div className="text-xs uppercase tracking-wide text-muted mb-2">{title}</div>
{children}
</div>
);
}
function Row({
label,
hint,
children,
}: {
label: string;
hint?: string;
children: React.ReactNode;
}) {
return (
<div className="flex items-center justify-between gap-3 py-1.5 text-sm">
<Tooltip hint={hint ?? ""}>
<span
className={
hint ? "underline decoration-dotted decoration-muted/40 underline-offset-4 cursor-help" : ""
}
>
{label}
</span>
</Tooltip>
{children}
</div>
);
}
function Switch({ checked, onChange }: { checked: boolean; onChange: (v: boolean) => void }) {
return (
<button
type="button"
onClick={() => onChange(!checked)}
className={`w-9 h-5 rounded-full transition relative shrink-0 ${checked ? "bg-accent" : "bg-border"}`}
>
<span
className={`absolute top-0.5 w-4 h-4 rounded-full bg-white shadow transition-all ${
checked ? "left-[18px]" : "left-0.5"
}`}
/>
</button>
<DraftSaveBar
variant="inline"
dirty={prefs.dirty}
state={prefs.saveState}
onSave={prefs.save}
onDiscard={prefs.discard}
labels={{
saved: t("settings.save.saved"),
failed: t("settings.save.failed"),
unsaved: t("settings.save.unsaved"),
discard: t("settings.save.discard"),
save: t("settings.save.save"),
saving: t("settings.save.saving"),
}}
/>
);
}
@ -219,27 +149,27 @@ function Appearance({ prefs }: { prefs: PrefsController }) {
</Section>
<Section title={t("settings.appearance.display")}>
<Row label={t("settings.appearance.darkMode")}>
<SettingRow label={t("settings.appearance.darkMode")}>
<Switch
checked={theme.mode === "dark"}
onChange={(v) => setTheme({ ...theme, mode: v ? "dark" : "light" })}
/>
</Row>
<Row label={t("settings.appearance.listView")} hint={t("settings.appearance.listViewHint")}>
</SettingRow>
<SettingRow label={t("settings.appearance.listView")} hint={t("settings.appearance.listViewHint")}>
<Switch checked={view === "list"} onChange={(v) => setView(v ? "list" : "grid")} />
</Row>
<Row
</SettingRow>
<SettingRow
label={t("settings.appearance.performanceMode")}
hint={t("settings.appearance.performanceModeHint")}
>
<Switch checked={perf} onChange={setPerf} />
</Row>
<Row
</SettingRow>
<SettingRow
label={t("settings.appearance.showHints")}
hint={t("settings.appearance.showHintsHint")}
>
<Switch checked={hints} onChange={setHints} />
</Row>
</SettingRow>
</Section>
<Section title={t("settings.appearance.textSize")}>
@ -268,18 +198,18 @@ function Notifications({ prefs }: { prefs: PrefsController }) {
return (
<Section title={t("settings.notifications.title")}>
<Row
<SettingRow
label={t("settings.notifications.sound")}
hint={t("settings.notifications.soundHint")}
>
<Switch checked={cfg.sound} onChange={(v) => update({ sound: v })} />
</Row>
<Row
</SettingRow>
<SettingRow
label={t("settings.notifications.autoDismiss")}
hint={t("settings.notifications.autoDismissHint")}
>
<Switch checked={autoOn} onChange={(v) => update({ durationMs: v ? 6000 : 0 })} />
</Row>
</SettingRow>
{autoOn && (
<div className="py-1.5 text-sm">
<div className="flex items-center justify-between">

View file

@ -36,6 +36,7 @@ import {
} from "../lib/sidebarLayout";
import { shareUrl } from "../lib/urlState";
import { notify } from "../lib/notifications";
import { Switch } from "./ui/form";
import TagManager from "./TagManager";
// Filter ids; display labels resolved at render time via t("sidebar.sort.<id>") etc.
@ -622,19 +623,7 @@ function Toggle({
return (
<label className="flex items-center justify-between py-1 cursor-pointer text-sm">
<span>{label}</span>
<button
type="button"
onClick={() => onChange(!checked)}
className={`w-9 h-5 rounded-full transition relative ${
checked ? "bg-accent" : "bg-border"
}`}
>
<span
className={`absolute top-0.5 w-4 h-4 rounded-full bg-white transition-all ${
checked ? "left-[18px]" : "left-0.5"
}`}
/>
</button>
<Switch checked={checked} onChange={onChange} />
</label>
);
}

View file

@ -5,10 +5,11 @@ import { History, Pause, Play, RefreshCw } from "lucide-react";
import { api, type AdminQuotaRow, type Me } from "../lib/api";
import { formatEta, quotaActionLabel } from "../lib/format";
import { notify } from "../lib/notifications";
import { LS, usePersistedState } from "../lib/storage";
import Tooltip from "./Tooltip";
import { Section, SettingRow } from "./ui/form";
const RANGES = [7, 30, 90] as const;
const STATS_TAB_KEY = "siftlode.statsTab";
type StatsTab = "overview" | "system";
// Usage & stats page. "Overview" is per-user (sync status + your own API usage + actions);
@ -17,14 +18,9 @@ type StatsTab = "overview" | "system";
export default function Stats({ me }: { me: Me }) {
const { t } = useTranslation();
const isAdmin = me.role === "admin";
const [tab, setTabState] = useState<StatsTab>(() => {
const v = localStorage.getItem(STATS_TAB_KEY);
return v === "system" && isAdmin ? "system" : "overview";
});
const setTab = (id: StatsTab) => {
setTabState(id);
localStorage.setItem(STATS_TAB_KEY, id);
};
const [tabRaw, setTab] = usePersistedState(LS.statsTab, "overview");
// Clamp at render: only admins may land on the System tab (covers a stale stored value).
const tab: StatsTab = tabRaw === "system" && isAdmin ? "system" : "overview";
return (
<div className="p-4 max-w-4xl mx-auto">
@ -52,31 +48,6 @@ export default function Stats({ me }: { me: Me }) {
);
}
function Section({ title, children }: { title: string; children: React.ReactNode }) {
return (
<div className="mb-6">
<div className="text-xs uppercase tracking-wide text-muted mb-2">{title}</div>
{children}
</div>
);
}
function Row({ label, hint, children }: { label: string; hint?: string; children: React.ReactNode }) {
return (
<div className="flex items-center justify-between gap-3 py-1.5 text-sm">
<Tooltip hint={hint ?? ""}>
<span
className={
hint ? "underline decoration-dotted decoration-muted/40 underline-offset-4 cursor-help" : ""
}
>
{label}
</span>
</Tooltip>
{children}
</div>
);
}
// Per-user view: sync status + your own API usage + (for real accounts) the manual sync actions.
function Overview({ me }: { me: Me }) {
@ -112,29 +83,29 @@ function Overview({ me }: { me: Me }) {
<Section title={t("stats.sync.myStatus")}>
{s ? (
<div className="space-y-1.5 text-sm">
<Row label={t("stats.sync.channels")} hint={t("stats.sync.channelsHint")}>
<SettingRow label={t("stats.sync.channels")} hint={t("stats.sync.channelsHint")}>
{s.channels_total}
</Row>
<Row label={t("stats.sync.recentSynced")} hint={t("stats.sync.recentSyncedHint")}>
</SettingRow>
<SettingRow label={t("stats.sync.recentSynced")} hint={t("stats.sync.recentSyncedHint")}>
{`${s.channels_recent_synced}/${s.channels_total}`}
</Row>
<Row label={t("stats.sync.fullHistory")} hint={t("stats.sync.fullHistoryHint")}>
</SettingRow>
<SettingRow label={t("stats.sync.fullHistory")} hint={t("stats.sync.fullHistoryHint")}>
{`${s.channels_deep_done}/${s.channels_deep_requested}`}
</Row>
</SettingRow>
{s.deep_pending_count > 0 && (
<Row
<SettingRow
label={t("stats.sync.fullHistoryEta")}
hint={t("stats.sync.fullHistoryEtaHint", { count: s.deep_pending_count })}
>
{formatEta(s.deep_eta_seconds)}
</Row>
</SettingRow>
)}
<Row label={t("stats.sync.myVideos")} hint={t("stats.sync.myVideosHint")}>
<SettingRow label={t("stats.sync.myVideos")} hint={t("stats.sync.myVideosHint")}>
{s.my_videos.toLocaleString()}
</Row>
<Row label={t("stats.sync.quotaLeft")} hint={t("stats.sync.quotaLeftHint")}>
</SettingRow>
<SettingRow label={t("stats.sync.quotaLeft")} hint={t("stats.sync.quotaLeftHint")}>
{s.quota_remaining_today.toLocaleString()}
</Row>
</SettingRow>
</div>
) : (
<div className="text-muted text-sm">{t("stats.sync.loading")}</div>
@ -145,12 +116,12 @@ function Overview({ me }: { me: Me }) {
{usage.data ? (
<>
<div className="space-y-1.5 text-sm">
<Row label={t("stats.sync.today")} hint={t("stats.sync.todayHint")}>
<SettingRow label={t("stats.sync.today")} hint={t("stats.sync.todayHint")}>
{usage.data.today.toLocaleString()}
</Row>
<Row label={t("stats.sync.last7d")}>{usage.data.last_7d.toLocaleString()}</Row>
<Row label={t("stats.sync.last30d")}>{usage.data.last_30d.toLocaleString()}</Row>
<Row label={t("stats.sync.allTime")}>{usage.data.all_time.toLocaleString()}</Row>
</SettingRow>
<SettingRow label={t("stats.sync.last7d")}>{usage.data.last_7d.toLocaleString()}</SettingRow>
<SettingRow label={t("stats.sync.last30d")}>{usage.data.last_30d.toLocaleString()}</SettingRow>
<SettingRow label={t("stats.sync.allTime")}>{usage.data.all_time.toLocaleString()}</SettingRow>
</div>
{Object.keys(usage.data.by_action).length > 0 && (
<div className="mt-2 pt-2 border-t border-border space-y-1 text-xs text-muted">

View file

@ -1,4 +1,4 @@
import { useState } from "react";
import { usePersistedState } from "../lib/storage";
// Reusable horizontal pill tab-rail for in-page sub-navigation (Configuration, Users & roles…).
// The active tab is persisted to localStorage so a reload (F5) keeps the user where they were,
@ -10,16 +10,9 @@ export interface TabDef {
badge?: number; // optional count pill (e.g. pending requests); hidden when 0/undefined
}
/** Persisted active-tab state. Validation is deferred to the caller (it clamps to a valid id at
* render) so this can be called before an async-loaded tab list is known, keeping hook order stable. */
export function usePersistedTab(storageKey: string, fallback = ""): [string, (id: string) => void] {
const [tab, setTabState] = useState<string>(() => localStorage.getItem(storageKey) ?? fallback);
const setTab = (id: string) => {
setTabState(id);
localStorage.setItem(storageKey, id);
};
return [tab, setTab];
}
/** Persisted active-tab state the canonical helper now lives in lib/storage as
* `usePersistedState`; re-exported here under its original name for existing call sites. */
export const usePersistedTab = usePersistedState;
export default function Tabs({
tabs,

View file

@ -0,0 +1,75 @@
import { Check, RotateCcw, Save } from "lucide-react";
// The Save / Discard bar shown while an edited draft has unsaved changes (Settings prefs,
// admin Configuration). Two visual variants share one state machine + markup:
// - "inline": a bar pinned to the bottom of a card (Settings panel)
// - "floating": a centered floating pill over the page (Configuration page)
// Renders nothing while clean and idle; stays mounted through the fading saved/error message.
export type SaveState = "idle" | "saving" | "saved" | "error";
export interface DraftSaveBarLabels {
saved: string;
failed: string;
unsaved: string;
discard: string;
save: string;
saving: string;
}
export function DraftSaveBar({
dirty,
state,
onSave,
onDiscard,
labels,
variant = "inline",
}: {
dirty: boolean;
state: SaveState;
onSave: () => void;
onDiscard: () => void;
labels: DraftSaveBarLabels;
variant?: "inline" | "floating";
}) {
if (!dirty && state === "idle") return null;
const saving = state === "saving";
const wrap =
variant === "floating"
? "fixed bottom-4 left-1/2 -translate-x-1/2 z-40 glass rounded-2xl shadow-lg flex items-center justify-between gap-4 px-4 py-3 w-[min(48rem,calc(100%-2rem))]"
: "flex items-center justify-between gap-3 border-t border-border/60 px-4 py-3 bg-card/40";
return (
<div className={wrap}>
<span className="text-sm text-muted">
{state === "saved" ? (
<span className="text-emerald-500 inline-flex items-center gap-1.5">
<Check className="w-4 h-4" />
{labels.saved}
</span>
) : state === "error" ? (
<span className="text-red-500">{labels.failed}</span>
) : (
labels.unsaved
)}
</span>
<div className="flex items-center gap-2">
<button
onClick={onDiscard}
disabled={saving || !dirty}
className="glass-card glass-hover flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-sm disabled:opacity-40 transition"
>
<RotateCcw className="w-4 h-4" />
{labels.discard}
</button>
<button
onClick={onSave}
disabled={saving || !dirty}
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-sm bg-accent text-accent-fg font-medium shadow-md shadow-accent/20 disabled:opacity-40 transition"
>
<Save className="w-4 h-4" />
{saving ? labels.saving : labels.save}
</button>
</div>
</div>
);
}

View file

@ -0,0 +1,85 @@
import type { ReactNode } from "react";
import Tooltip from "../Tooltip";
// Shared form/settings primitives, factored out of the many panels that each re-declared
// them (Settings, Config, Stats, admin pages, Setup wizard). Visual contract unchanged.
/** Pill on/off switch — the bare control; compose it with your own label/row. */
export function Switch({
checked,
onChange,
}: {
checked: boolean;
onChange: (v: boolean) => void;
}) {
return (
<button
type="button"
onClick={() => onChange(!checked)}
className={`w-9 h-5 rounded-full transition relative shrink-0 ${checked ? "bg-accent" : "bg-border"}`}
>
<span
className={`absolute top-0.5 w-4 h-4 rounded-full bg-white shadow transition-all ${
checked ? "left-[18px]" : "left-0.5"
}`}
/>
</button>
);
}
/** A titled settings block. Default = a plain block with an uppercase caption; `card` wraps it
* in a glass card (the admin pages' style). */
export function Section({
title,
card,
children,
}: {
title: string;
card?: boolean;
children: ReactNode;
}) {
return (
<div className={card ? "glass rounded-2xl p-4 mb-4" : "mb-6"}>
<div className={`text-xs uppercase tracking-wide text-muted ${card ? "mb-3" : "mb-2"}`}>
{title}
</div>
{children}
</div>
);
}
/** A label that reveals an explanatory caption on hover (dotted underline) when `hint` is set.
* No-op styling when there's no hint, so it's safe to use unconditionally. */
export function HintLabel({ hint, children }: { hint?: string; children: ReactNode }) {
return (
<Tooltip hint={hint ?? ""}>
<span
className={
hint
? "underline decoration-dotted decoration-muted/40 underline-offset-4 cursor-help"
: ""
}
>
{children}
</span>
</Tooltip>
);
}
/** A settings row: a (hint-able) label on the left, a control on the right. */
export function SettingRow({
label,
hint,
children,
}: {
label: string;
hint?: string;
children: ReactNode;
}) {
return (
<div className="flex items-center justify-between gap-3 py-1.5 text-sm">
<HintLabel hint={hint}>{label}</HintLabel>
{children}
</div>
);
}

View file

@ -1,5 +1,6 @@
import i18n from "i18next";
import { initReactI18next } from "react-i18next";
import { LS } from "../lib/storage";
// Endonyms (shown as-is regardless of the active language — the convention for language pickers).
export const LANGUAGES = [
@ -9,7 +10,7 @@ export const LANGUAGES = [
] as const;
export type LangCode = (typeof LANGUAGES)[number]["code"];
const SUPPORTED = LANGUAGES.map((l) => l.code) as readonly string[];
export const LANG_KEY = "siftlode.lang";
export const LANG_KEY = LS.lang;
// Auto-load every locale file (locales/<lang>/<area>.json) and merge each <area> under one
// `translation` namespace, so components use t("area.key") and adding an area needs no wiring.

View file

@ -11,11 +11,5 @@
"openInManager": "Kanalverwaltung",
"openOnYouTube": "Auf YouTube öffnen",
"unhidden": "Eingeblendet „{{title}}”",
"unwatched": "Als nicht angesehen markiert „{{title}}”",
"time": {
"justNow": "gerade eben",
"minutes": "vor {{count}} Min.",
"hours": "vor {{count}} Std.",
"days": "vor {{count}} T."
}
"unwatched": "Als nicht angesehen markiert „{{title}}”"
}

View file

@ -6,5 +6,14 @@
"daysAgo": "vor {{count}} T.",
"weeksAgo": "vor {{count}} Wo.",
"monthsAgo": "vor {{count}} Mon.",
"yearsAgo": "vor {{count}} J."
"yearsAgo": "vor {{count}} J.",
"eta": {
"now": "jetzt",
"done": "fertig",
"lessThanHour": "< 1 Stunde",
"hours_one": "~{{count}} Stunde",
"hours_other": "~{{count}} Stunden",
"days_one": "~{{count}} Tag",
"days_other": "~{{count}} Tage"
}
}

View file

@ -11,11 +11,5 @@
"openInManager": "Channel manager",
"openOnYouTube": "Open on YouTube",
"unhidden": "Unhidden “{{title}}”",
"unwatched": "Unwatched “{{title}}”",
"time": {
"justNow": "just now",
"minutes": "{{count}}m ago",
"hours": "{{count}}h ago",
"days": "{{count}}d ago"
}
"unwatched": "Unwatched “{{title}}”"
}

View file

@ -6,5 +6,14 @@
"daysAgo": "{{count}}d ago",
"weeksAgo": "{{count}} wk ago",
"monthsAgo": "{{count}} mo ago",
"yearsAgo": "{{count}} yr ago"
"yearsAgo": "{{count}} yr ago",
"eta": {
"now": "now",
"done": "done",
"lessThanHour": "< 1 hour",
"hours_one": "~{{count}} hour",
"hours_other": "~{{count}} hours",
"days_one": "~{{count}} day",
"days_other": "~{{count}} days"
}
}

View file

@ -11,11 +11,5 @@
"openInManager": "Csatornakezelő",
"openOnYouTube": "Megnyitás a YouTube-on",
"unhidden": "Megjelenítve: „{{title}}”",
"unwatched": "Megtekintés visszavonva: „{{title}}”",
"time": {
"justNow": "az imént",
"minutes": "{{count}} perce",
"hours": "{{count}} órája",
"days": "{{count}} napja"
}
"unwatched": "Megtekintés visszavonva: „{{title}}”"
}

View file

@ -6,5 +6,14 @@
"daysAgo": "{{count}} napja",
"weeksAgo": "{{count}} hete",
"monthsAgo": "{{count}} hónapja",
"yearsAgo": "{{count}} éve"
"yearsAgo": "{{count}} éve",
"eta": {
"now": "most",
"done": "kész",
"lessThanHour": "< 1 óra",
"hours_one": "~{{count}} óra",
"hours_other": "~{{count}} óra",
"days_one": "~{{count}} nap",
"days_other": "~{{count}} nap"
}
}

View file

@ -0,0 +1,147 @@
import type { ReactNode } from "react";
import type { TFunction } from "i18next";
// Turn a video description into clickable nodes:
// - bare timestamps (mm:ss / hh:mm:ss) → seek the inline player
// - a YouTube link to the *current* video → seek (to its t= if any)
// - a YouTube link to *another* video → load it in the inline player
// - emails → mailto:, hashtags → YouTube's hashtag page, other URLs → new tab
// Pure logic (no React state), factored out of PlayerModal so it's testable on its own.
const URL_RE = /(https?:\/\/[^\s<>]+)/g;
// One pass over plain text for the three inline token kinds (email | hashtag | timestamp).
const INLINE_RE =
/([A-Za-z0-9._%+-]+@[A-Za-z0-9-]+\.[A-Za-z0-9.-]+)|(#[\p{L}\p{N}_]+)|(\b(?:\d{1,2}:)?\d{1,2}:\d{2}\b)/gu;
function tsToSeconds(ts: string): number {
const parts = ts.split(":").map((n) => parseInt(n, 10));
return parts.length === 3
? parts[0] * 3600 + parts[1] * 60 + parts[2]
: parts[0] * 60 + parts[1];
}
// Parse a YouTube `t`/`start` param: "90", "90s", or "1h2m3s".
function parseStart(t: string | null): number | null {
if (!t) return null;
if (/^\d+$/.test(t)) return parseInt(t, 10) || null;
const m = t.match(/^(?:(\d+)h)?(?:(\d+)m)?(?:(\d+)s)?$/);
if (!m) return null;
const total =
parseInt(m[1] || "0", 10) * 3600 +
parseInt(m[2] || "0", 10) * 60 +
parseInt(m[3] || "0", 10);
return total || null;
}
// Extract a video id (+ optional start) from a YouTube URL, else null.
function parseYouTube(url: string): { id: string; start: number | null } | null {
let u: URL;
try {
u = new URL(url);
} catch {
return null;
}
const host = u.hostname.replace(/^www\./, "");
let id: string | null = null;
if (host === "youtu.be") {
id = u.pathname.slice(1) || null;
} else if (host === "youtube.com" || host === "m.youtube.com" || host === "music.youtube.com") {
if (u.pathname === "/watch") id = u.searchParams.get("v");
else {
const m = u.pathname.match(/^\/(?:shorts|embed|live)\/([^/?#]+)/);
if (m) id = m[1];
}
}
if (!id) return null;
return { id, start: parseStart(u.searchParams.get("t") || u.searchParams.get("start")) };
}
export function renderDescription(
text: string,
opts: {
currentId: string;
onSeek: (seconds: number) => void;
onLoadVideo: (id: string, start: number | null) => void;
t: TFunction;
}
): ReactNode[] {
const { t } = opts;
const out: ReactNode[] = [];
let key = 0;
const linkCls = "text-accent hover:underline break-all";
// Tidy YouTube descriptions: drop trailing spaces and remove blank lines entirely
// (they're just noise in the popover), keeping single line breaks.
const clean = text.replace(/[ \t]+\n/g, "\n").replace(/\n{2,}/g, "\n").trim();
for (const chunk of clean.split(URL_RE)) {
if (/^https?:\/\//.test(chunk)) {
const href = chunk.replace(/[.,;:!?)\]]+$/, "");
const yt = parseYouTube(href);
if (yt) {
const sameVideo = yt.id === opts.currentId;
out.push(
<button
key={key++}
onClick={() =>
sameVideo ? opts.onSeek(yt.start ?? 0) : opts.onLoadVideo(yt.id, yt.start)
}
className={linkCls}
title={sameVideo ? t("player.jumpToTime") : t("player.playInApp")}
>
{href}
</button>
);
} else {
out.push(
<a key={key++} href={href} target="_blank" rel="noreferrer" className={linkCls}>
{href}
</a>
);
}
continue;
}
// Plain text: pull out emails, hashtags and bare timestamps.
INLINE_RE.lastIndex = 0;
let last = 0;
let m: RegExpExecArray | null;
while ((m = INLINE_RE.exec(chunk))) {
if (m.index > last) out.push(<span key={key++}>{chunk.slice(last, m.index)}</span>);
if (m[1]) {
// Email — trim trailing punctuation the domain rule may have swallowed.
const email = m[1].replace(/[.,;:]+$/, "");
out.push(
<a key={key++} href={`mailto:${email}`} className={linkCls}>
{email}
</a>
);
if (m[1].length > email.length) out.push(<span key={key++}>{m[1].slice(email.length)}</span>);
} else if (m[2]) {
// Hashtag → YouTube's hashtag feed (mirrors native YouTube behavior).
const tag = m[2].slice(1);
out.push(
<a
key={key++}
href={`https://www.youtube.com/hashtag/${encodeURIComponent(tag)}`}
target="_blank"
rel="noreferrer"
className={linkCls}
>
{m[2]}
</a>
);
} else {
const ts = m[3];
out.push(
<button
key={key++}
onClick={() => opts.onSeek(tsToSeconds(ts))}
className="text-accent hover:underline"
>
{ts}
</button>
);
}
last = m.index + m[0].length;
}
if (last < chunk.length) out.push(<span key={key++}>{chunk.slice(last)}</span>);
}
return out;
}

View file

@ -1,4 +1,5 @@
import i18n from "../i18n";
import { createStore } from "./store";
// A single, self-explanatory error dialog (modal) the user must acknowledge — used when the
// server can't carry out an action (a definitive 4xx/5xx response). Distinct from toasts
@ -9,30 +10,18 @@ export interface AppError {
message: string;
}
let current: AppError | null = null;
let listeners: Array<() => void> = [];
const emit = () => listeners.forEach((l) => l());
const store = createStore<AppError | null>(null);
export function reportError(message?: string, title?: string): void {
current = {
store.set({
title: title || i18n.t("errors.title"),
message: message || i18n.t("errors.generic"),
};
emit();
});
}
export function dismissError(): void {
current = null;
emit();
store.set(null);
}
export function subscribeError(listener: () => void): () => void {
listeners.push(listener);
return () => {
listeners = listeners.filter((l) => l !== listener);
};
}
export function getError(): AppError | null {
return current;
}
export const subscribeError = store.subscribe;
export const getError = store.get;

View file

@ -2,8 +2,14 @@ import i18n from "../i18n";
export function relativeTime(iso: string | null): string {
if (!iso) return "";
const then = new Date(iso).getTime();
const secs = Math.max(0, (Date.now() - then) / 1000);
return relativeFromMs(new Date(iso).getTime());
}
/** Relative-time label from an epoch-millis timestamp (the ms-based half of relativeTime, for
* client-side notifications whose timestamps are already numbers). One implementation, one
* set of `time.*` strings shared instead of re-implemented per component. */
export function relativeFromMs(ms: number): string {
const secs = Math.max(0, (Date.now() - ms) / 1000);
const units: [number, string][] = [
[60, "time.secondsAgo"],
[3600, "time.minutesAgo"],
@ -51,17 +57,21 @@ export function quotaActionLabel(action: string): string {
return i18n.t(`quotaActions.${action}`, { defaultValue: action });
}
/** Coarse, human ETA for a remaining duration in seconds ("~3 hours", "~2 days"). */
/** Coarse, human ETA for a remaining duration in seconds ("~3 hours", "~2 days"). Translated
* via the `time.eta.*` keys (was hardcoded English, violating the trilingual rule). */
export function formatEta(seconds: number): string {
if (seconds <= 0) return "done";
if (seconds <= 0) return i18n.t("time.eta.done");
const hours = seconds / 3600;
if (hours < 1) return "< 1 hour";
if (hours < 24) {
const h = Math.round(hours);
return `~${h} hour${h === 1 ? "" : "s"}`;
}
const d = Math.round(hours / 24);
return `~${d} day${d === 1 ? "" : "s"}`;
if (hours < 1) return i18n.t("time.eta.lessThanHour");
if (hours < 24) return i18n.t("time.eta.hours", { count: Math.round(hours) });
return i18n.t("time.eta.days", { count: Math.round(hours / 24) });
}
/** Compact whole-channel total length ("12 h", "45 m"), hours-scale so H:MM:SS isn't huge. */
export function formatTotalHours(sec: number): string {
if (!sec) return "—";
const h = Math.round(sec / 3600);
return h >= 1 ? `${h.toLocaleString()} h` : `${Math.max(1, Math.round(sec / 60))} m`;
}
export function formatViews(n: number | null): string {

View file

@ -1,36 +1,27 @@
// App-wide "hints" toggle: when on, Tooltip components reveal short explanatory
// captions on hover so the UI is somewhat self-documenting for new users. Experienced
// users can turn it off in Settings. Persisted to localStorage (+ server preferences).
const KEY = "siftlode.hints";
let enabled = load();
let listeners: Array<() => void> = [];
import { createStore } from "./store";
import { LS } from "./storage";
function load(): boolean {
try {
return localStorage.getItem(KEY) !== "0"; // default ON
return localStorage.getItem(LS.hints) !== "0"; // default ON
} catch {
return true;
}
}
export function hintsEnabled(): boolean {
return enabled;
}
const store = createStore<boolean>(load());
export const hintsEnabled = store.get;
export const subscribeHints = store.subscribe;
export function setHintsEnabled(v: boolean): void {
enabled = v;
store.set(v);
try {
localStorage.setItem(KEY, v ? "1" : "0");
localStorage.setItem(LS.hints, v ? "1" : "0");
} catch {
/* ignore */
}
listeners.forEach((l) => l());
}
export function subscribeHints(listener: () => void): () => void {
listeners.push(listener);
return () => {
listeners = listeners.filter((l) => l !== listener);
};
}

View file

@ -2,6 +2,7 @@
// the Notification Center shows (info events, actions awaiting interaction, and app
// errors). History is persisted to localStorage so it survives reloads; action
// callbacks are live-only and dropped on reload.
import { LS, readJSON, readMerged, writeJSON } from "./storage";
export type NotifLevel = "info" | "success" | "warning" | "error" | "fatal";
@ -71,8 +72,8 @@ export interface NotifyInput {
transient?: boolean; // live status: sticky toast, not persisted (see Notification.transient)
}
const HISTORY_KEY = "siftlode.notifications";
const SETTINGS_KEY = "siftlode.notifSettings";
const HISTORY_KEY = LS.notifHistory;
const SETTINGS_KEY = LS.notifSettings;
const MAX_HISTORY = 100;
const ERROR_TTL = 15000;
@ -86,11 +87,7 @@ const DEFAULT_SETTINGS: NotifSettings = { sound: false, durationMs: 6000 };
let config: NotifSettings = loadSettings();
function loadSettings(): NotifSettings {
try {
return { ...DEFAULT_SETTINGS, ...JSON.parse(localStorage.getItem(SETTINGS_KEY) || "{}") };
} catch {
return DEFAULT_SETTINGS;
}
return readMerged(SETTINGS_KEY, DEFAULT_SETTINGS);
}
export function getNotifSettings(): NotifSettings {
@ -99,11 +96,7 @@ export function getNotifSettings(): NotifSettings {
export function configureNotifications(p: Partial<NotifSettings>): void {
config = { ...config, ...p };
try {
localStorage.setItem(SETTINGS_KEY, JSON.stringify(config));
} catch {
/* ignore */
}
writeJSON(SETTINGS_KEY, config);
}
let audioCtx: AudioContext | null = null;
@ -137,7 +130,7 @@ let cachedUnread = 0;
function load(): Notification[] {
try {
const raw = JSON.parse(localStorage.getItem(HISTORY_KEY) || "[]");
const raw = readJSON<unknown>(HISTORY_KEY, []);
if (!Array.isArray(raw)) return [];
// Restored entries are history only. Mark them dismissed so they don't
// resurrect as active toasts on reload — their auto-dismiss timers aren't
@ -154,7 +147,7 @@ function persist() {
// Transient status notices are live-session only — never write them to history,
// so a reload can't leave one stranded with no producer left to clear it.
const slim = items.filter((n) => !n.transient).map(({ action: _action, ...n }) => n);
localStorage.setItem(HISTORY_KEY, JSON.stringify(slim));
writeJSON(HISTORY_KEY, slim);
} catch {
/* ignore quota / serialization errors */
}

View file

@ -12,8 +12,10 @@
// suppresses the auto-popup on future logins (they
// can still reopen it from Settings → Account).
export const ONBOARD_ACTIVE = "siftlode.onboarding.active";
export const ONBOARD_DISMISSED = "siftlode.onboarding.dismissed";
import { LS } from "./storage";
export const ONBOARD_ACTIVE = "siftlode.onboarding.active"; // sessionStorage (not in LS, which is localStorage)
export const ONBOARD_DISMISSED = LS.onboardingDismissed;
export function shouldAutoOpenOnboarding(me: {
can_read: boolean;

View file

@ -14,6 +14,19 @@ export interface ReleaseEntry {
}
export const RELEASE_NOTES: ReleaseEntry[] = [
{
version: "0.17.0",
date: "2026-06-29",
summary: "Messaging and notification fixes, plus a large under-the-hood cleanup.",
fixes: [
"Switching between signed-in accounts no longer drops you on a blank chat: after you switch, a message you just received shows up in the right conversation instead of an empty screen.",
"You're now reliably notified when a video you'd saved or added to a playlist is removed because it's no longer available on YouTube — this notice could previously be missed.",
"“Time remaining” estimates (e.g. on sync progress) and a few small labels are now translated in Hungarian and German like the rest of the app.",
],
chores: [
"A large internal code cleanup: removed duplicated logic across the backend and frontend and factored out shared building blocks (form controls, the save/discard bar, popovers, browser-storage helpers, time/date formatters). No change to how the app behaves — groundwork that keeps future features consistent and quicker to build.",
],
},
{
version: "0.16.2",
date: "2026-06-26",

View file

@ -4,6 +4,8 @@
// `show`, `sort` and `content` moved to the feed toolbar (above the cards); they are no
// longer sidebar widgets. normalizeLayout drops them from any persisted layout automatically.
import { LS, readJSON, writeJSON } from "./storage";
export type WidgetId = "date" | "language" | "topic" | "tags";
export const ALL_WIDGETS: WidgetId[] = ["date", "language", "topic", "tags"];
@ -27,8 +29,6 @@ export const DEFAULT_LAYOUT: SidebarLayout = {
hidden: {},
};
const KEY = "siftlode.sidebarLayout";
// Tolerate stale/partial data: keep only known widgets and append any that are missing
// (e.g. a widget added in a later version) so nothing silently disappears.
export function normalizeLayout(raw: unknown): SidebarLayout {
@ -45,13 +45,9 @@ export function normalizeLayout(raw: unknown): SidebarLayout {
}
export function loadLayout(): SidebarLayout {
try {
return normalizeLayout(JSON.parse(localStorage.getItem(KEY) || "{}"));
} catch {
return DEFAULT_LAYOUT;
}
return normalizeLayout(readJSON<unknown>(LS.sidebarLayout, {}));
}
export function saveLayoutLocal(l: SidebarLayout): void {
localStorage.setItem(KEY, JSON.stringify(l));
writeJSON(LS.sidebarLayout, l);
}

View file

@ -0,0 +1,80 @@
import { useState } from "react";
// Central registry of every localStorage key the app uses. One place to see them all, rename
// safely, and avoid collisions — instead of `"siftlode.x"` string literals scattered across
// files. Parametric (per-user) keys are functions. All keys are namespaced `siftlode.*`.
export const LS = {
theme: "siftlode.theme",
sidebarLayout: "siftlode.sidebarLayout",
hints: "siftlode.hints",
lang: "siftlode.lang",
filters: "siftlode.filters",
page: "siftlode.page",
perfMode: "siftlode.perfMode",
channelFilter: "siftlode.channelFilter",
channelsView: "siftlode.channelsView",
settingsTab: "siftlode.settingsTab",
statsTab: "siftlode.statsTab",
adminUsersTab: "siftlode.adminUsersTab",
navCollapsed: "siftlode.navCollapsed",
playlist: "siftlode.playlist",
plSort: "siftlode.plSort",
notifHistory: "siftlode.notifications",
notifSettings: "siftlode.notifSettings",
onboardingDismissed: "siftlode.onboarding.dismissed",
seenVersion: "siftlode.seenVersion",
chatDock: (userId: number) => `siftlode.chatDock.${userId}`,
} as const;
// --- typed read/write helpers (do the JSON + try/catch once) ----------------------------
/** Read an object-shaped value merged over `defaults` (so missing/added keys are tolerated),
* falling back to `defaults` on missing or corrupt data. */
export function readMerged<T extends object>(key: string, defaults: T): T {
try {
return { ...defaults, ...JSON.parse(localStorage.getItem(key) || "{}") };
} catch {
return { ...defaults };
}
}
/** Read any JSON value (arrays/scalars), falling back on missing or corrupt data. */
export function readJSON<T>(key: string, fallback: T): T {
try {
const raw = localStorage.getItem(key);
return raw == null ? fallback : (JSON.parse(raw) as T);
} catch {
return fallback;
}
}
/** Persist a value as JSON, swallowing quota/serialization errors. */
export function writeJSON(key: string, value: unknown): void {
try {
localStorage.setItem(key, JSON.stringify(value));
} catch {
/* ignore quota / serialization errors */
}
}
// --- reactive persisted string state ----------------------------------------------------
/** A `useState` whose string value is mirrored to localStorage, so it survives a reload (F5).
* Validation is deferred to the caller (clamp at render) so it can be used before an
* async-loaded option list is known, keeping hook order stable. Generalizes the per-component
* "persisted active tab" pattern (Settings, Stats, admin pages, the channel filter). */
export function usePersistedState(
key: string,
fallback = "",
): [string, (value: string) => void] {
const [value, setValue] = useState<string>(() => localStorage.getItem(key) ?? fallback);
const set = (next: string) => {
setValue(next);
try {
localStorage.setItem(key, next);
} catch {
/* ignore */
}
};
return [value, set];
}

33
frontend/src/lib/store.ts Normal file
View file

@ -0,0 +1,33 @@
import { useSyncExternalStore } from "react";
// A minimal observable value shared across React components AND non-React code (the api
// layer, websocket handlers, etc.). Replaces the hand-rolled `listeners`/`subscribe`/`emit`
// triad that several modules each re-implemented slightly differently.
export interface Store<T> {
get(): T;
set(next: T | ((prev: T) => T)): void;
subscribe(listener: () => void): () => void;
/** Subscribe from a component; re-renders on every change. */
use(): T;
}
export function createStore<T>(initial: T): Store<T> {
let value = initial;
const listeners = new Set<() => void>();
const get = () => value;
const subscribe = (l: () => void) => {
listeners.add(l);
return () => {
listeners.delete(l);
};
};
return {
get,
set(next) {
value = typeof next === "function" ? (next as (p: T) => T)(value) : next;
listeners.forEach((l) => l());
},
subscribe,
use: () => useSyncExternalStore(subscribe, get, get),
};
}

View file

@ -1,3 +1,5 @@
import { LS, readMerged, writeJSON } from "./storage";
export type Mode = "dark" | "light";
export type Scheme = "midnight" | "forest" | "slate" | "youtube";
@ -27,16 +29,10 @@ export function applyTheme(t: ThemePrefs): void {
el.style.setProperty("--font-scale", String(t.fontScale));
}
const KEY = "siftlode.theme";
export function loadLocalTheme(): ThemePrefs {
try {
return { ...DEFAULT_THEME, ...JSON.parse(localStorage.getItem(KEY) || "{}") };
} catch {
return DEFAULT_THEME;
}
return readMerged(LS.theme, DEFAULT_THEME);
}
export function saveLocalTheme(t: ThemePrefs): void {
localStorage.setItem(KEY, JSON.stringify(t));
writeJSON(LS.theme, t);
}

View file

@ -0,0 +1,32 @@
import { useEffect, type RefObject } from "react";
/** While `active`, close (call `onClose`) on Escape or a mousedown outside ALL of the given
* element(s). The reusable half of the popover/dropdown pattern that several components each
* hand-rolled; positioning stays with the caller (it varies per popover). Pass the panel ref
* (and optionally a trigger ref so clicking the trigger doesn't immediately re-close). */
export function useDismiss(
active: boolean,
onClose: () => void,
refs: RefObject<HTMLElement | null> | RefObject<HTMLElement | null>[],
): void {
useEffect(() => {
if (!active) return;
const list = Array.isArray(refs) ? refs : [refs];
const onDown = (e: MouseEvent) => {
const target = e.target as Node;
if (!list.some((r) => r.current?.contains(target))) onClose();
};
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") onClose();
};
document.addEventListener("mousedown", onDown);
document.addEventListener("keydown", onKey);
return () => {
document.removeEventListener("mousedown", onDown);
document.removeEventListener("keydown", onKey);
};
// Matches the original effects: (re)subscribe only when open/closed toggles. Refs are
// stable and onClose is read fresh from this run's closure.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [active]);
}

View file

@ -1,8 +1,10 @@
// Build info baked into the SPA at build time (Vite inlines VITE_*-prefixed env).
// Injected via Docker build-args (see Dockerfile); falls back to "dev" for un-stamped builds.
import { LS } from "./storage";
export const FRONTEND_VERSION = import.meta.env.VITE_APP_VERSION || "dev";
export const FRONTEND_SHA = import.meta.env.VITE_GIT_SHA || "unknown";
export const FRONTEND_BUILD_DATE = import.meta.env.VITE_BUILD_DATE || "";
// Key for the "last version the user has seen release notes for" (drives the banner).
export const SEEN_VERSION_KEY = "siftlode.seenVersion";
export const SEEN_VERSION_KEY = LS.seenVersion;