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 logging
import re
import secrets import secrets
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
@ -8,7 +6,7 @@ import httpx
from authlib.integrations.starlette_client import OAuth, OAuthError from authlib.integrations.starlette_client import OAuth, OAuthError
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Request from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Request
from fastapi.responses import JSONResponse, RedirectResponse from fastapi.responses import JSONResponse, RedirectResponse
from sqlalchemy import delete, select from sqlalchemy import delete, func, select
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app import email as email_mod from app import email as email_mod
@ -17,7 +15,8 @@ from app.config import settings
from app.db import get_db from app.db import get_db
from app.models import AuthToken, DemoWhitelist, Invite, OAuthToken, User from app.models import AuthToken, DemoWhitelist, Invite, OAuthToken, User
from app.ratelimit import RateLimiter 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. # Email+password auth tuning.
PASSWORD_MIN_LEN = 10 PASSWORD_MIN_LEN = 10
@ -43,8 +42,6 @@ def _notify_suspended(background: BackgroundTasks, email: str) -> None:
if _suspend_email_limiter.allow(email): if _suspend_email_limiter.allow(email):
background.add_task(email_mod.send_account_suspended, email, operator_contact()) 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 # 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 login (see get_or_create_demo_user); these are just its sentinel keys.
DEMO_GOOGLE_SUB = "__demo__" 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 """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.""" was created (so the caller can notify admins), else None for already-known emails."""
email = (email or "").lower() email = (email or "").lower()
if not _EMAIL_RE.match(email): if not valid_email(email):
return None return None
inv = db.execute(select(Invite).where(Invite.email == email)).scalar_one_or_none() inv = db.execute(select(Invite).where(Invite.email == email)).scalar_one_or_none()
if inv is not 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 """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).""" duplicate or re-spam admins (upsert returns None for an already-pending row)."""
email = (payload.get("email") or "").strip().lower() 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.") raise HTTPException(status_code=400, detail="Enter a valid email address.")
if is_allowed(db, email): if is_allowed(db, email):
return {"status": "approved"} # already allowed — just sign in 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)): if not _demo_limiter.allow(_client_ip(request)):
return {"authenticated": False} return {"authenticated": False}
email = (payload.get("email") or "").strip().lower() 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) demo = get_or_create_demo_user(db)
request.session["user_id"] = demo.id request.session["user_id"] = demo.id
log.info("Demo login (key=%s, demo_id=%s)", email, 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 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: 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 """Create a single-use token of `kind`; only its hash is stored. Returns the raw token
(goes only into the emailed link).""" (goes only into the emailed link)."""
@ -499,7 +492,7 @@ def _issue_token(db: Session, user: User, kind: str, ttl: timedelta) -> str:
AuthToken( AuthToken(
user_id=user.id, user_id=user.id,
kind=kind, kind=kind,
token_hash=_hash_token(raw), token_hash=hash_token(raw),
expires_at=datetime.now(timezone.utc) + ttl, 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 return None
row = db.execute( row = db.execute(
select(AuthToken).where( 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() ).scalar_one_or_none()
if row is None or row.used_at is not None or row.expires_at < datetime.now(timezone.utc): 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() email = (payload.get("email") or "").strip().lower()
password = payload.get("password") or "" password = payload.get("password") or ""
# Input validation is safe to reveal (independent of whether the email exists). # 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.") raise HTTPException(status_code=400, detail="Enter a valid email address.")
if len(password) < PASSWORD_MIN_LEN: if len(password) < PASSWORD_MIN_LEN:
raise HTTPException( raise HTTPException(
@ -633,7 +626,7 @@ def password_reset_request(
if not _reset_limiter.allow(_client_ip(request)): if not _reset_limiter.allow(_client_ip(request)):
return {"status": "ok"} return {"status": "ok"}
email = (payload.get("email") or "").strip().lower() 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() 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: if user is not None and user.password_hash and not user.is_demo:
raw = _issue_token(db, user, "reset", RESET_TTL) raw = _issue_token(db, user, "reset", RESET_TTL)
@ -694,6 +687,23 @@ def require_human(user: User = Depends(current_user)) -> User:
return 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") @router.get("/link")
async def link_google( async def link_google(
request: Request, request: Request,

View file

@ -19,7 +19,24 @@ from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.db import Base 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" __tablename__ = "users"
id: Mapped[int] = mapped_column(primary_key=True) 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…). # Free-form UI preferences (theme, color scheme, font scale, default filters…).
preferences: Mapped[dict | None] = mapped_column(JSON) 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( token: Mapped["OAuthToken | None"] = relationship(
back_populates="user", uselist=False, cascade="all, delete-orphan" 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" __tablename__ = "oauth_tokens"
id: Mapped[int] = mapped_column(primary_key=True) id: Mapped[int] = mapped_column(primary_key=True)
@ -81,14 +95,11 @@ class OAuthToken(Base):
access_token: Mapped[str | None] = mapped_column(String) access_token: Mapped[str | None] = mapped_column(String)
expiry: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) expiry: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
scopes: Mapped[str | None] = mapped_column(String) 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") 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 """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 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.""" 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) token_hash: Mapped[str] = mapped_column(String(64), unique=True, index=True)
expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True)) expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True))
used_at: Mapped[datetime | None] = 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): class Invite(Base):
@ -128,7 +136,7 @@ class Invite(Base):
decided_by: Mapped[str | None] = mapped_column(String(320)) # admin email 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). """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 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).""" 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) email: Mapped[str] = mapped_column(String(320), unique=True, index=True)
note: Mapped[str | None] = mapped_column(Text) note: Mapped[str | None] = mapped_column(Text)
added_by: Mapped[str | None] = mapped_column(String(320)) # admin email 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 """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.""" 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( backfill_done: Mapped[bool] = mapped_column(
Boolean, default=False, server_default="false" 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") videos: Mapped[list["Video"]] = relationship(back_populates="channel")
subscriptions: Mapped[list["Subscription"]] = 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.""" """Per-user link to a channel, with the user's own priority/visibility overrides."""
__tablename__ = "subscriptions" __tablename__ = "subscriptions"
@ -202,15 +204,12 @@ class Subscription(Base):
Boolean, default=False, server_default="false" Boolean, default=False, server_default="false"
) )
subscribed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) 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") user: Mapped["User"] = relationship(back_populates="subscriptions")
channel: Mapped["Channel"] = 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.""" """A YouTube video. Shared across users; per-user state lives elsewhere."""
__tablename__ = "videos" __tablename__ = "videos"
@ -260,14 +259,11 @@ class Video(Base):
DateTime(timezone=True), index=True DateTime(timezone=True), index=True
) )
unavailable_reason: Mapped[str | None] = mapped_column(String(24)) 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") 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 """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.""" 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( category: Mapped[str] = mapped_column(
String(16), default="other", server_default="other" String(16), default="other", server_default="other"
) )
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now()
)
class ChannelTag(Base): class ChannelTag(Base):
@ -312,7 +305,7 @@ class ChannelTag(Base):
confidence: Mapped[float | None] = mapped_column(Float) 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 """Per-user watch state for a video. Rows exist only for non-default states; the
absence of a row means 'new/unseen'.""" absence of a row means 'new/unseen'."""
@ -326,7 +319,7 @@ class VideoState(Base):
video_id: Mapped[str] = mapped_column( video_id: Mapped[str] = mapped_column(
ForeignKey("videos.id", ondelete="CASCADE"), index=True 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") status: Mapped[str] = mapped_column(String(12), default="new", server_default="new")
watched_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) watched_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
# Resume position (seconds into the video) for the in-app player. Orthogonal to # 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 Integer, default=0, server_default="0", nullable=False
) )
progress_updated_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) 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): 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. """A per-user named, ordered collection of videos from the shared catalog.
`kind`: 'user' (normal) or 'watch_later' (built-in, undeletable, one per user). `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). # baseline `dirty` is derived against. NULL = no baseline yet (treated as dirty).
synced_fingerprint: Mapped[str | None] = mapped_column(String) synced_fingerprint: Mapped[str | None] = mapped_column(String)
position: Mapped[int] = mapped_column(Integer, default=0, server_default="0") 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( items: Mapped[list["PlaylistItem"]] = relationship(
back_populates="playlist", 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). """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 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)) salt: Mapped[str] = mapped_column(String(64))
wrap_iv: Mapped[str] = mapped_column(String(32)) wrap_iv: Mapped[str] = mapped_column(String(32))
key_check: Mapped[str] = mapped_column(Text) 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) _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(): def pacific_today():
return datetime.now(_PACIFIC).date() return datetime.now(_PACIFIC).date()

View file

@ -1,6 +1,5 @@
"""Admin-only onboarding: review access requests (Invites), approve/deny, manual add. """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.""" Also the demo-account admin surface: the demo email whitelist + a manual state reset."""
import re
from datetime import datetime, timezone from datetime import datetime, timezone
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException
@ -8,7 +7,13 @@ from sqlalchemy import delete, func, select
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app import email as email_mod 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.db import get_db
from app.models import ( from app.models import (
DemoWhitelist, DemoWhitelist,
@ -20,17 +25,10 @@ from app.models import (
Video, Video,
VideoState, VideoState,
) )
from app.utils import valid_email
router = APIRouter(prefix="/api/admin", tags=["admin"]) 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: def _serialize(inv: Invite) -> dict:
return { return {
@ -110,7 +108,7 @@ def add_invite(
) -> dict: ) -> dict:
"""Manually whitelist an email (approved straight away). Convenience for the admin.""" """Manually whitelist an email (approved straight away). Convenience for the admin."""
email = (payload.get("email") or "").strip().lower() 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.") raise HTTPException(status_code=400, detail="Enter a valid email address.")
inv = db.execute(select(Invite).where(Invite.email == email)).scalar_one_or_none() inv = db.execute(select(Invite).where(Invite.email == email)).scalar_one_or_none()
if inv is None: if inv is None:
@ -168,9 +166,7 @@ def set_user_role(
raise HTTPException(status_code=400, detail="The demo account's role can't be changed.") raise HTTPException(status_code=400, detail="The demo account's role can't be changed.")
if target.id == admin.id: if target.id == admin.id:
raise HTTPException(status_code=400, detail="You can't change your own role.") raise HTTPException(status_code=400, detail="You can't change your own role.")
if target.role == "admin" and role == "user": if target.role == "admin" and role == "user" and count_admins(db) <= 1:
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.") raise HTTPException(status_code=400, detail="Can't remove the last admin.")
changed = target.role != role changed = target.role != role
target.role = role target.role = role
@ -201,13 +197,12 @@ def set_user_suspended(
raise HTTPException(status_code=400, detail="The demo account can't be suspended.") raise HTTPException(status_code=400, detail="The demo account can't be suspended.")
if target.id == admin.id: if target.id == admin.id:
raise HTTPException(status_code=400, detail="You can't suspend your own account.") raise HTTPException(status_code=400, detail="You can't suspend your own account.")
if suspended and target.role == "admin" and not target.is_suspended: if (
active_admins = db.scalar( suspended
select(func.count()) and target.role == "admin"
.select_from(User) and not target.is_suspended
.where(User.role == "admin", User.is_suspended.is_(False)) and count_admins(db, active_only=True) <= 1
) ):
if (active_admins or 0) <= 1:
raise HTTPException(status_code=400, detail="Can't suspend the last active admin.") raise HTTPException(status_code=400, detail="Can't suspend the last active admin.")
was_suspended = target.is_suspended was_suspended = target.is_suspended
target.is_suspended = suspended target.is_suspended = suspended
@ -238,11 +233,7 @@ def admin_delete_user(
raise HTTPException( raise HTTPException(
status_code=400, detail="Delete your own account from Settings → Account." status_code=400, detail="Delete your own account from Settings → Account."
) )
if target.role == "admin": if target.role == "admin" and count_admins(db) <= 1:
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.") raise HTTPException(status_code=400, detail="Can't delete the last admin.")
purge_user(db, target, background) purge_user(db, target, background)
return {"deleted": user_id} 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 """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.""" sign-in). Idempotent re-adding an existing email just returns it."""
email = (payload.get("email") or "").strip().lower() 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.") raise HTTPException(status_code=400, detail="Enter a valid email address.")
row = db.execute( row = db.execute(
select(DemoWhitelist).where(DemoWhitelist.email == email) 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: def _serialize(row) -> dict:
return { return {
"id": row.id, "id": row.id,
@ -115,23 +138,9 @@ def _filtered_query(
status_expr = func.coalesce(state.status, "new") status_expr = func.coalesce(state.status, "new")
position_expr = func.coalesce(state.position_seconds, 0) position_expr = func.coalesce(state.position_seconds, 0)
query = select( query = select(*feed_columns(user, state)).join(
Video.id, Channel, Channel.id == Video.channel_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)
if scope == "all": if scope == "all":
# Whole shared catalog; subscription is optional (only for priority sort). # 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 sqlalchemy.orm import Session
from app.auth import ( from app.auth import (
count_admins,
current_user, current_user,
google_enabled, google_enabled,
has_read_scope, has_read_scope,
@ -111,9 +112,7 @@ def delete_my_account(
(that would lock everyone out of the admin surfaces).""" (that would lock everyone out of the admin surfaces)."""
if user.is_demo: if user.is_demo:
raise HTTPException(status_code=403, detail="The shared demo account can't be deleted.") raise HTTPException(status_code=403, detail="The shared demo account can't be deleted.")
if user.role == "admin": if user.role == "admin" and count_admins(db) <= 1:
admins = db.scalar(select(func.count()).select_from(User).where(User.role == "admin")) or 0
if admins <= 1:
raise HTTPException( raise HTTPException(
status_code=400, status_code=400,
detail="You're the only admin — promote another admin before deleting your account.", detail="You're the only admin — promote another admin before deleting your account.",

View file

@ -103,6 +103,7 @@ def _serialize_msg(m: Message) -> dict:
def _messageable() -> list: def _messageable() -> list:
"""SQL clauses for "a real, active, non-suspended human" — for directory/recipient queries."""
return [ return [
User.is_demo.is_(False), User.is_demo.is_(False),
User.is_active.is_(True), 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: 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. """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.""" Idempotent: guarded on whether any system message already exists for them."""
@ -315,7 +321,7 @@ def send_message(
if payload.recipient_id == user.id: if payload.recipient_id == user.id:
raise HTTPException(status_code=400, detail="You can't message yourself.") raise HTTPException(status_code=400, detail="You can't message yourself.")
recipient = db.get(User, payload.recipient_id) 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.") raise HTTPException(status_code=404, detail="Recipient not available.")
if db.get(MessageKey, recipient.id) is None: if db.get(MessageKey, recipient.id) is None:
raise HTTPException(status_code=404, detail="That member hasn't set up messaging yet.") 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 return
db = SessionLocal() db = SessionLocal()
try: try:
u = db.get(User, uid) ok = is_messageable_user(db.get(User, uid))
ok = bool(u and not u.is_demo and not u.is_suspended and u.is_active)
finally: finally:
db.close() db.close()
if not ok: 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.auth import current_user, has_read_scope, has_write_scope
from app.db import get_db from app.db import get_db
from app.models import Channel, Playlist, PlaylistItem, User, Video, VideoState 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 ( from app.sync.playlists import (
plan_push, plan_push,
push_playlist, push_playlist,
@ -29,6 +29,51 @@ def _own_playlist(db: Session, user: User, playlist_id: int) -> Playlist:
return pl 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( def _summary(
db: Session, db: Session,
pl: Playlist, pl: Playlist,
@ -142,15 +187,7 @@ def create_playlist(
name = (payload.get("name") or "").strip() name = (payload.get("name") or "").strip()
if not name: if not name:
raise HTTPException(status_code=400, detail="name is required") raise HTTPException(status_code=400, detail="name is required")
next_pos = ( pl = Playlist(user_id=user.id, name=name, position=_next_playlist_position(db, user.id))
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)
db.add(pl) db.add(pl)
db.commit() db.commit()
return _summary(db, pl, 0) return _summary(db, pl, 0)
@ -164,16 +201,11 @@ def _watch_later(db: Session, user: User) -> Playlist:
) )
) )
if pl is None: 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( 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.add(pl)
db.commit() db.commit()
@ -191,22 +223,7 @@ def add_watch_later(
if not video_id or db.get(Video, video_id) is None: if not video_id or db.get(Video, video_id) is None:
raise HTTPException(status_code=404, detail="Unknown video") raise HTTPException(status_code=404, detail="Unknown video")
pl = _watch_later(db, user) pl = _watch_later(db, user)
exists = db.scalar( _add_item(db, pl, video_id, mark_dirty=False)
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()
return {"saved": True, "playlist_id": pl.id} return {"saved": True, "playlist_id": pl.id}
@ -344,23 +361,7 @@ def get_playlist(
pl = _own_playlist(db, user, playlist_id) pl = _own_playlist(db, user, playlist_id)
state = aliased(VideoState) state = aliased(VideoState)
rows = db.execute( rows = db.execute(
select( select(*feed_columns(user, state))
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),
)
.join(PlaylistItem, PlaylistItem.video_id == Video.id) .join(PlaylistItem, PlaylistItem.video_id == Video.id)
.join(Channel, Channel.id == Video.channel_id) .join(Channel, Channel.id == Video.channel_id)
.outerjoin(state, and_(state.video_id == Video.id, state.user_id == user.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") video_id = payload.get("video_id")
if not video_id or db.get(Video, video_id) is None: if not video_id or db.get(Video, video_id) is None:
raise HTTPException(status_code=404, detail="Unknown video") raise HTTPException(status_code=404, detail="Unknown video")
exists = db.scalar( _add_item(db, pl, video_id, mark_dirty=True)
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()
return {"playlist_id": pl.id, "video_id": video_id} 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 sqlalchemy.orm import Session
from app import quota, state 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.db import get_db
from app.models import Channel, Subscription, User, Video from app.models import Channel, Subscription, User, Video
from app.sync.runner import ( from app.sync.runner import (
@ -40,11 +40,10 @@ def sync_subscriptions(
status_code=403, status_code=403,
detail="Connect your YouTube account (read access) to import subscriptions.", detail="Connect your YouTube account (read access) to import subscriptions.",
) )
before = quota.units_used_today(db) with quota.measured(db, user.id, quota.QuotaAction.SUBSCRIPTIONS_PULL) as m:
with quota.attribute(user.id, quota.QuotaAction.SUBSCRIPTIONS_PULL):
result = import_subscriptions(db, user) result = import_subscriptions(db, user)
result["quota_used_estimate"] = quota.units_used_today(db) - before result["quota_used_estimate"] = m.used
result["quota_remaining_today"] = quota.remaining_today(db) result["quota_remaining_today"] = m.remaining
return result return result
@ -62,10 +61,9 @@ def sync_backfill(
db: Session = Depends(get_db), db: Session = Depends(get_db),
max_channels: int = 25, max_channels: int = 25,
) -> dict: ) -> dict:
before = quota.units_used_today(db) with quota.measured(db, user.id, quota.QuotaAction.VIDEOS_BACKFILL_RECENT) as m:
with quota.attribute(user.id, quota.QuotaAction.VIDEOS_BACKFILL_RECENT):
result = run_recent_backfill(db, _user_channels(db, user), max_channels=max_channels) 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 return result
@ -73,13 +71,9 @@ def sync_backfill(
def sync_enrich( def sync_enrich(
user: User = Depends(require_human), db: Session = Depends(get_db) user: User = Depends(require_human), db: Session = Depends(get_db)
) -> dict: ) -> dict:
before = quota.units_used_today(db) with quota.measured(db, user.id, quota.QuotaAction.VIDEOS_ENRICH) as m:
with quota.attribute(user.id, quota.QuotaAction.VIDEOS_ENRICH):
enriched = run_enrich(db) enriched = run_enrich(db)
return { return {"enriched": enriched, "quota_used_estimate": m.used}
"enriched": enriched,
"quota_used_estimate": quota.units_used_today(db) - before,
}
@router.get("/status") @router.get("/status")
@ -194,20 +188,12 @@ def deep_all(
@router.post("/pause") @router.post("/pause")
def pause_sync( def pause_sync(_: User = Depends(admin_user), db: Session = Depends(get_db)) -> dict:
user: User = Depends(current_user), db: Session = Depends(get_db)
) -> dict:
if user.role != "admin":
raise HTTPException(status_code=403, detail="Admin only")
state.set_sync_paused(db, True) state.set_sync_paused(db, True)
return {"paused": True} return {"paused": True}
@router.post("/resume") @router.post("/resume")
def resume_sync( def resume_sync(_: User = Depends(admin_user), db: Session = Depends(get_db)) -> dict:
user: User = Depends(current_user), db: Session = Depends(get_db)
) -> dict:
if user.role != "admin":
raise HTTPException(status_code=403, detail="Admin only")
state.set_sync_paused(db, False) state.set_sync_paused(db, False)
return {"paused": False} return {"paused": False}

View file

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

View file

@ -1,3 +1,5 @@
import hashlib
from argon2 import PasswordHasher from argon2 import PasswordHasher
from cryptography.fernet import Fernet from cryptography.fernet import Fernet
@ -7,6 +9,12 @@ from app.config import settings
_ph = PasswordHasher() _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: def hash_password(password: str) -> str:
return _ph.hash(password) return _ph.hash(password)

View file

@ -1,5 +1,4 @@
"""Global admin-controlled app state (e.g. pausing background sync, first-run setup).""" """Global admin-controlled app state (e.g. pausing background sync, first-run setup)."""
import hashlib
import logging import logging
import secrets import secrets
@ -7,6 +6,7 @@ from sqlalchemy.orm import Session
from app.config import settings from app.config import settings
from app.models import AppState from app.models import AppState
from app.security import hash_token
log = logging.getLogger("siftlode.state") log = logging.getLogger("siftlode.state")
@ -73,16 +73,12 @@ def mark_configured(db: Session) -> None:
log.info("Instance marked configured; setup wizard disabled.") 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: def rotate_setup_token(db: Session) -> str:
"""Generate a fresh one-time setup token, persist only its hash, and return the raw token """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).""" (logged at startup so the admin can open the wizard)."""
raw = secrets.token_urlsafe(24) raw = secrets.token_urlsafe(24)
row = _row(db) row = _row(db)
row.setup_token_hash = _hash_token(raw) row.setup_token_hash = hash_token(raw)
db.add(row) db.add(row)
db.commit() db.commit()
return raw return raw
@ -93,7 +89,7 @@ def check_setup_token(db: Session, raw: str | None) -> bool:
if not raw: if not raw:
return False return False
stored = _row(db).setup_token_hash 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: 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. backfill reserve as the other jobs so interactive syncs never starve.
""" """
import logging 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 sqlalchemy.orm import Session
from app import progress, quota, sysconfig from app import progress, quota, sysconfig
from app.config import settings 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.notifications import create_notification
from app.state import get_maintenance_batch from app.state import get_maintenance_batch
from app.sync.runner import get_service_user from app.sync.runner import get_service_user
from app.sync.videos import apply_video_details, parse_dt from app.sync.videos import apply_video_details, parse_dt
from app.utils import now_utc as _now
from app.youtube.client import YouTubeClient from app.youtube.client import YouTubeClient
log = logging.getLogger("siftlode.maintenance") log = logging.getLogger("siftlode.maintenance")
@ -45,10 +46,6 @@ GRACE_DAYS_BY_REASON = {
DEFAULT_GRACE_DAYS = settings.maintenance_grace_days DEFAULT_GRACE_DAYS = settings.maintenance_grace_days
def _now() -> datetime:
return datetime.now(timezone.utc)
def _grace_days(reason: str | None) -> int: def _grace_days(reason: str | None) -> int:
return GRACE_DAYS_BY_REASON.get(reason or "", DEFAULT_GRACE_DAYS) 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]] = {} impact: dict[int, set[str]] = {}
if not ids: if not ids:
return {} return {}
# Saved (VideoState.status == "saved"). # Anyone who had it in a playlist — which now also covers "saved": Watch later became a
for user_id, video_id in db.execute( # built-in playlist, so "saved" is no longer a VideoState status (the old status=="saved"
select(VideoState.user_id, VideoState.video_id).where( # scan matched nothing and under-counted impacted users).
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).
for user_id, video_id in db.execute( for user_id, video_id in db.execute(
select(Playlist.user_id, PlaylistItem.video_id) select(Playlist.user_id, PlaylistItem.video_id)
.join(PlaylistItem, PlaylistItem.playlist_id == Playlist.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() 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: def sync_user_playlists(db: Session, user: User) -> dict:
"""Mirror the user's YouTube playlists into local source='youtube' playlists. Leaves """Mirror the user's YouTube playlists into local source='youtube' playlists. Leaves
the user's local playlists and the built-in Watch later untouched.""" 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() db.flush()
else: else:
pl.name = p.get("title") or pl.name 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. # Mirror is authoritative: replace the items to match YouTube's order.
db.execute(delete(PlaylistItem).where(PlaylistItem.playlist_id == pl.id)) ids = list(yt.iter_my_playlist_video_ids(ytid))
for pos, vid in enumerate(ordered): _replace_items_from_youtube(db, yt, pl, ids)
db.add(PlaylistItem(playlist_id=pl.id, video_id=vid, position=pos))
pl.synced_fingerprint = fingerprint(pl.name, ordered)
pl.dirty = False
db.commit() db.commit()
synced += 1 synced += 1
return {"synced": synced} 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) snippet = yt.get_playlist_snippet(pl.yt_playlist_id)
if snippet is None: if snippet is None:
raise YouTubeError("Playlist no longer exists on YouTube") 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 pl.name = snippet.get("title") or pl.name
db.execute(delete(PlaylistItem).where(PlaylistItem.playlist_id == pl.id)) ids = list(yt.iter_my_playlist_video_ids(pl.yt_playlist_id))
for pos, vid in enumerate(ordered): ordered = _replace_items_from_youtube(db, yt, pl, ids)
db.add(PlaylistItem(playlist_id=pl.id, video_id=vid, position=pos))
pl.synced_fingerprint = fingerprint(pl.name, ordered)
pl.dirty = False
db.commit() db.commit()
return {"items": len(ordered)} 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"; } from "./lib/sidebarLayout";
import { configureNotifications, getNotifSettings, notify, removeByMetaKind, type NotifSettings } from "./lib/notifications"; import { configureNotifications, getNotifSettings, notify, removeByMetaKind, type NotifSettings } from "./lib/notifications";
import { hintsEnabled, setHintsEnabled } from "./lib/hints"; import { hintsEnabled, setHintsEnabled } from "./lib/hints";
import { LS, readMerged, usePersistedState } from "./lib/storage";
import { useConfirm } from "./components/ConfirmProvider"; import { useConfirm } from "./components/ConfirmProvider";
import type { PrefsController } from "./components/SettingsPanel"; import type { PrefsController } from "./components/SettingsPanel";
import Welcome from "./components/Welcome"; import Welcome from "./components/Welcome";
@ -59,9 +60,9 @@ const DEFAULT_FILTERS: FeedFilters = {
show: "unwatched", show: "unwatched",
}; };
const FILTERS_KEY = "siftlode.filters"; const FILTERS_KEY = LS.filters;
const PAGE_KEY = "siftlode.page"; const PAGE_KEY = LS.page;
const PERF_KEY = "siftlode.perfMode"; const PERF_KEY = LS.perfMode;
// The preferences edited on the Settings page. They apply locally for instant preview but // 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 // 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 { function loadStoredFilters(): FeedFilters {
try { return readMerged(FILTERS_KEY, DEFAULT_FILTERS);
return { ...DEFAULT_FILTERS, ...JSON.parse(localStorage.getItem(FILTERS_KEY) || "{}") };
} catch {
return DEFAULT_FILTERS;
}
} }
// URL wins over localStorage so a pasted link reproduces the exact view. // 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 [sidebarLayout, setSidebarLayoutState] = useState<SidebarLayout>(loadLayout);
const [page, setPageState] = useState<Page>(loadInitialPage); const [page, setPageState] = useState<Page>(loadInitialPage);
const [wizardOpen, setWizardOpen] = useState(false); const [wizardOpen, setWizardOpen] = useState(false);
const CHANNEL_FILTER_KEY = "siftlode.channelFilter"; // Channel status chip, persisted so a reload (F5) keeps it; clamp a stale value at render.
const [channelFilter, setChannelFilterState] = useState<ChannelStatusFilter>(() => { const [channelFilterRaw, setChannelFilter] = usePersistedState(LS.channelFilter, "all");
const v = localStorage.getItem(CHANNEL_FILTER_KEY); const channelFilter: ChannelStatusFilter = (
return v === "needs_full" || v === "fully_synced" || v === "hidden" || v === "all" ["needs_full", "fully_synced", "hidden", "all"] as const
? v ).includes(channelFilterRaw as ChannelStatusFilter)
? (channelFilterRaw as ChannelStatusFilter)
: "all"; : "all";
});
// Persist the channel status chip so a reload (F5) keeps it.
const setChannelFilter = (f: ChannelStatusFilter) => {
setChannelFilterState(f);
localStorage.setItem(CHANNEL_FILTER_KEY, f);
};
const [aboutOpen, setAboutOpen] = useState(false); const [aboutOpen, setAboutOpen] = useState(false);
const [notesOpen, setNotesOpen] = useState(false); const [notesOpen, setNotesOpen] = useState(false);
const [notesHighlight, setNotesHighlight] = useState<string | undefined>(undefined); 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 // 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 // "without full history" link, focus-channel — can force it back to "subscribed" instead
// of dumping the user on whatever tab they last left open. // of dumping the user on whatever tab they last left open.
const CHANNELS_VIEW_KEY = "siftlode.channelsView"; const [channelsViewRaw, setChannelsView] = usePersistedState(LS.channelsView, "subscribed");
const [channelsView, setChannelsViewState] = useState<ChannelsView>(() => const channelsView: ChannelsView =
localStorage.getItem(CHANNELS_VIEW_KEY) === "discovery" ? "discovery" : "subscribed" channelsViewRaw === "discovery" ? "discovery" : "subscribed";
);
const setChannelsView = (v: ChannelsView) => {
setChannelsViewState(v);
localStorage.setItem(CHANNELS_VIEW_KEY, v);
};
// Bumped to tell the channel manager to drop a stale column filter when we send the user // 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). // there to see a specific set (the header's "without full history" link).
const [channelsFilterReset, setChannelsFilterReset] = useState(0); const [channelsFilterReset, setChannelsFilterReset] = useState(0);
@ -318,7 +305,7 @@ export default function App() {
if (result === "ok") { if (result === "ok") {
notify({ level: "success", message: t("settings.account.googleLink.linked") }); notify({ level: "success", message: t("settings.account.googleLink.linked") });
void meQuery.refetch(); void meQuery.refetch();
localStorage.setItem("siftlode.settingsTab", "account"); localStorage.setItem(LS.settingsTab, "account");
setPage("settings"); setPage("settings");
} else { } else {
const key = result === "conflict" || result === "mismatch" ? result : "error"; 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 { useQuery, useQueryClient } from "@tanstack/react-query";
import { Check, ListPlus, Plus, Youtube } from "lucide-react"; import { Check, ListPlus, Plus, Youtube } from "lucide-react";
import { api, type Playlist } from "../lib/api"; 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 // 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. // video's membership in the user's playlists, with inline "new playlist" creation.
@ -59,25 +60,13 @@ export default function AddToPlaylist({
setOpen((o) => !o); setOpen((o) => !o);
} }
useDismiss(open, () => setOpen(false), [panelRef, triggerRef]);
// Keep the panel anchored to the trigger as the page resizes/scrolls while open.
useEffect(() => { useEffect(() => {
if (!open) return; 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("resize", place);
window.addEventListener("scroll", place, true); window.addEventListener("scroll", place, true);
return () => { return () => {
document.removeEventListener("mousedown", onDoc);
document.removeEventListener("keydown", onKey);
window.removeEventListener("resize", place); window.removeEventListener("resize", place);
window.removeEventListener("scroll", place, true); 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 { Ban, Check, RotateCcw, Shield, Trash2, UserPlus, X } from "lucide-react";
import { api, type AdminUserRow, type Invite, type Me } from "../lib/api"; import { api, type AdminUserRow, type Invite, type Me } from "../lib/api";
import { notify } from "../lib/notifications"; import { notify } from "../lib/notifications";
import { LS } from "../lib/storage";
import Tooltip from "./Tooltip"; import Tooltip from "./Tooltip";
import { Section } from "./ui/form";
import { useConfirm } from "./ConfirmProvider"; import { useConfirm } from "./ConfirmProvider";
import Tabs, { usePersistedTab } from "./Tabs"; 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 // 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 // "Review" link can pre-select that tab before navigating here (usePersistedTab reads the key
// on mount, and this page mounts fresh on navigation). // 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 const ADMIN_USERS_ACCESS_TAB = "access";
export function focusAccessRequestsTab(): void { 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 }) { function Badge({ tone, children }: { tone: "accent" | "muted" | "warning"; children: React.ReactNode }) {
const cls = const cls =
tone === "accent" tone === "accent"
@ -137,7 +130,7 @@ function UsersRoles({ me }: { me: Me }) {
const rows = q.data ?? []; const rows = q.data ?? [];
return ( 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> <p className="text-xs text-muted leading-relaxed mb-3">{t("users.roles.intro")}</p>
{rows.length === 0 ? ( {rows.length === 0 ? (
<p className="text-sm text-muted">{t("users.roles.empty")}</p> <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"); const decided = list.filter((i) => i.status !== "pending");
return ( return (
<Section title={t("settings.invites.title")}> <Section card title={t("settings.invites.title")}>
{pending.length === 0 ? ( {pending.length === 0 ? (
<p className="text-sm text-muted">{t("settings.invites.noPending")}</p> <p className="text-sm text-muted">{t("settings.invites.noPending")}</p>
) : ( ) : (
@ -359,7 +352,7 @@ function AdminDemo() {
const rows = list.data ?? []; const rows = list.data ?? [];
return ( 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> <p className="text-xs text-muted leading-relaxed mb-2">{t("settings.demo.intro")}</p>
{rows.length > 0 && ( {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 { Trans, useTranslation } from "react-i18next";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { import {
@ -14,7 +14,8 @@ import {
UserMinus, UserMinus,
} from "lucide-react"; } from "lucide-react";
import { api, HttpError, type ManagedChannel, type Tag } from "../lib/api"; 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 { notify } from "../lib/notifications";
import Tooltip from "./Tooltip"; import Tooltip from "./Tooltip";
import DataTable, { type Column } from "./DataTable"; import DataTable, { type Column } from "./DataTable";
@ -27,13 +28,6 @@ export type ChannelsView = "subscribed" | "discovery";
export type ChannelStatusFilter = "all" | "needs_full" | "fully_synced" | "hidden"; 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 }[] = [ const STATUS_FILTERS: { id: ChannelStatusFilter; labelKey: string }[] = [
{ id: "all", labelKey: "channels.filters.all" }, { id: "all", labelKey: "channels.filters.all" },
{ id: "needs_full", labelKey: "channels.filters.needsFull" }, { id: "needs_full", labelKey: "channels.filters.needsFull" },
@ -286,7 +280,7 @@ export default function Channels({
nowrap: true, nowrap: true,
sortable: true, sortable: true,
sortValue: (c) => c.total_duration_seconds, 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", key: "types",
@ -616,19 +610,7 @@ function TagsCell({
const { t } = useTranslation(); const { t } = useTranslation();
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const ref = useRef<HTMLDivElement | null>(null); const ref = useRef<HTMLDivElement | null>(null);
useEffect(() => { useDismiss(open, () => setOpen(false), ref);
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]);
if (userTags.length === 0) return null; if (userTags.length === 0) return null;
// Show only the tags actually attached to this channel; the “+” opens a per-channel // 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 // 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 { useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; 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 { api, type ConfigItem } from "../lib/api";
import { notify } from "../lib/notifications"; import { notify } from "../lib/notifications";
import Tooltip from "./Tooltip"; import Tooltip from "./Tooltip";
import Tabs, { usePersistedTab } from "./Tabs"; 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 // 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 // backend — adding a key there makes it appear here automatically). Edits are drafted and
@ -150,40 +152,21 @@ export default function ConfigPanel() {
</div> </div>
)} )}
{(dirty || saveState !== "idle") && ( <DraftSaveBar
<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))]"> variant="floating"
<span className="text-sm text-muted"> dirty={dirty}
{saveState === "saved" ? ( state={saveState}
<span className="text-emerald-500 inline-flex items-center gap-1.5"> onSave={save}
<Check className="w-4 h-4" /> onDiscard={discard}
{t("config.saved")} labels={{
</span> saved: t("config.saved"),
) : saveState === "error" ? ( failed: t("config.saveFailed"),
<span className="text-red-500">{t("config.saveFailed")}</span> unsaved: t("config.unsaved"),
) : ( discard: t("config.discard"),
t("config.unsaved") save: t("config.save"),
)} saving: t("config.saving"),
</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>
)}
</div> </div>
); );
} }
@ -241,7 +224,7 @@ function Field({
<div className="flex flex-col items-end gap-1.5 shrink-0"> <div className="flex flex-col items-end gap-1.5 shrink-0">
{isBool ? ( {isBool ? (
<Toggle checked={value === "true"} onChange={(v) => onChange(v ? "true" : "false")} /> <Switch checked={value === "true"} onChange={(v) => onChange(v ? "true" : "false")} />
) : ( ) : (
<input <input
type={item.secret ? "password" : isNum ? "number" : "text"} type={item.secret ? "password" : isNum ? "number" : "text"}
@ -273,19 +256,3 @@ function Field({
</div> </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 { useEffect, useRef, useState, type ReactNode } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { ArrowDown, ArrowUp, ChevronLeft, ChevronRight, ListFilter, X } from "lucide-react"; 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, // 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 // 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); setPage(0);
}, [resetFiltersToken]); }, [resetFiltersToken]);
useEffect(() => { useDismiss(!!openFilter, () => setOpenFilter(null), popRef);
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]);
const filtered = rows.filter((row) => const filtered = rows.filter((row) =>
columns.every((col) => { 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()). // the module (open() pushes an entry; back() = history.back()).
const { view, open, back } = useHistorySubview<View>("list"); 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 ( return (
<PageThread <PageThread
meId={meId} meId={meId}

View file

@ -23,6 +23,7 @@ import {
import { api, type Me } from "../lib/api"; import { api, type Me } from "../lib/api";
import { useLiveQuery } from "../lib/useLiveQuery"; import { useLiveQuery } from "../lib/useLiveQuery";
import { getUnreadCount, subscribe } from "../lib/notifications"; import { getUnreadCount, subscribe } from "../lib/notifications";
import { LS } from "../lib/storage";
import type { Page } from "../lib/urlState"; import type { Page } from "../lib/urlState";
import { type LangCode } from "../i18n"; import { type LangCode } from "../i18n";
import AvatarImg from "./Avatar"; import AvatarImg from "./Avatar";
@ -48,7 +49,7 @@ export default function NavSidebar({
}) { }) {
const { t } = useTranslation(); const { t } = useTranslation();
const [collapsed, setCollapsed] = useState<boolean>( const [collapsed, setCollapsed] = useState<boolean>(
() => localStorage.getItem("siftlode.navCollapsed") === "1" () => localStorage.getItem(LS.navCollapsed) === "1"
); );
const [acctOpen, setAcctOpen] = useState(false); const [acctOpen, setAcctOpen] = useState(false);
const acctBtnRef = useRef<HTMLButtonElement | null>(null); const acctBtnRef = useRef<HTMLButtonElement | null>(null);
@ -90,7 +91,7 @@ export default function NavSidebar({
function toggleCollapsed() { function toggleCollapsed() {
setCollapsed((c) => { setCollapsed((c) => {
const next = !c; const next = !c;
localStorage.setItem("siftlode.navCollapsed", next ? "1" : "0"); localStorage.setItem(LS.navCollapsed, next ? "1" : "0");
return next; return next;
}); });
} }
@ -111,6 +112,14 @@ export default function NavSidebar({
async function switchTo(id: number) { async function switchTo(id: number) {
try { try {
await api.switchAccount(id); 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 location.reload(); // cleanest way to reload all per-user state for the new account
} catch { } catch {
/* a revoked/removed account — leave the popover open */ /* a revoked/removed account — leave the popover open */

View file

@ -19,7 +19,7 @@ import {
type VideoHiddenMeta, type VideoHiddenMeta,
type VideoWatchedMeta, type VideoWatchedMeta,
} from "../lib/notifications"; } from "../lib/notifications";
import { channelYouTubeUrl } from "../lib/format"; import { channelYouTubeUrl, relativeFromMs, relativeTime } from "../lib/format";
import type { Page } from "../lib/urlState"; import type { Page } from "../lib/urlState";
import { LEVEL_STYLE } from "./Toaster"; 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. // there's one indicator and one place to look. Transient toasts still pop via the Toaster.
const POLL_MS = 8000; 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({ export default function NotificationsPanel({
filters, filters,
setFilters, setFilters,
@ -253,7 +237,7 @@ function NotificationRow({
<div className="flex items-baseline gap-2"> <div className="flex items-baseline gap-2">
<span className="font-medium text-sm">{title}</span> <span className="font-medium text-sm">{title}</span>
<span className="text-[11px] text-muted shrink-0"> <span className="text-[11px] text-muted shrink-0">
{relativeTime(n.created_at, t)} {relativeTime(n.created_at)}
</span> </span>
</div> </div>
{body && <div className="text-sm text-muted mt-0.5">{body}</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>} {n.title && <div className="text-sm font-semibold">{n.title}</div>}
<div className="text-sm break-words">{n.message}</div> <div className="text-sm break-words">{n.message}</div>
<div className="flex items-center gap-2 mt-0.5"> <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 && ( {needsAction && (
<span className="text-[10px] uppercase tracking-wide font-semibold text-accent"> <span className="text-[10px] uppercase tracking-wide font-semibold text-accent">
{t("notifications.actionNeeded")} {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 { useTranslation } from "react-i18next";
import type { TFunction } from "i18next";
import { createPortal } from "react-dom"; import { createPortal } from "react-dom";
import { useQuery, useQueryClient } from "@tanstack/react-query"; import { useQuery, useQueryClient } from "@tanstack/react-query";
import { AlertTriangle, ArrowLeft, Check, CheckCheck, ExternalLink, SkipBack, SkipForward, X } from "lucide-react"; 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 AddToPlaylist from "./AddToPlaylist";
import { api, type Video } from "../lib/api"; import { api, type Video } from "../lib/api";
import { channelYouTubeUrl, formatDuration, formatViews, relativeTime } from "../lib/format"; import { channelYouTubeUrl, formatDuration, formatViews, relativeTime } from "../lib/format";
import { renderDescription } from "../lib/descriptionLinks";
import { useBackToClose } from "../lib/history"; 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 // 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 // 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 // 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 { api, type Playlist, type Video } from "../lib/api";
import { formatDuration } from "../lib/format"; import { formatDuration } from "../lib/format";
import { notify } from "../lib/notifications"; import { notify } from "../lib/notifications";
import { LS, readMerged, writeJSON } from "../lib/storage";
import { useUndoable } from "../lib/useUndoable"; import { useUndoable } from "../lib/useUndoable";
import PlayerModal from "./PlayerModal"; import PlayerModal from "./PlayerModal";
import UndoToolbar from "./UndoToolbar"; import UndoToolbar from "./UndoToolbar";
@ -187,26 +188,18 @@ export default function Playlists({ canWrite }: { canWrite: boolean }) {
const confirm = useConfirm(); const confirm = useConfirm();
// Persist the selected playlist so F5 keeps it (the de-URL refactor dropped it from the URL). // Persist the selected playlist so F5 keeps it (the de-URL refactor dropped it from the URL).
const [selectedId, setSelectedId] = useState<number | null>(() => { const [selectedId, setSelectedId] = useState<number | null>(() => {
const s = localStorage.getItem("siftlode.playlist"); const s = localStorage.getItem(LS.playlist);
return s ? Number(s) : null; return s ? Number(s) : null;
}); });
useEffect(() => { useEffect(() => {
if (selectedId != null) localStorage.setItem("siftlode.playlist", String(selectedId)); if (selectedId != null) localStorage.setItem(LS.playlist, String(selectedId));
}, [selectedId]); }, [selectedId]);
const selectedRef = useRef<HTMLButtonElement | null>(null); const selectedRef = useRef<HTMLButtonElement | null>(null);
const scrolledRef = useRef(false); const scrolledRef = useRef(false);
// How the left playlist rail is ordered (persisted). // How the left playlist rail is ordered (persisted).
const [plSort, setPlSort] = useState<PlSort>(() => { const [plSort, setPlSort] = useState<PlSort>(() => readMerged(LS.plSort, PL_SORT_DEFAULT));
try {
const s = localStorage.getItem("siftlode.plSort");
if (s) return { ...PL_SORT_DEFAULT, ...JSON.parse(s) };
} catch {
/* ignore */
}
return PL_SORT_DEFAULT;
});
useEffect(() => { useEffect(() => {
localStorage.setItem("siftlode.plSort", JSON.stringify(plSort)); writeJSON(LS.plSort, plSort);
}, [plSort]); }, [plSort]);
const [newName, setNewName] = useState(""); const [newName, setNewName] = useState("");
const [renaming, setRenaming] = useState(false); const [renaming, setRenaming] = useState(false);

View file

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

View file

@ -1,12 +1,15 @@
import { useState } from "react"; import { useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useQueryClient } from "@tanstack/react-query"; 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 { SCHEMES, type Scheme, type ThemePrefs } from "../lib/theme";
import { api, type Me } from "../lib/api"; import { api, type Me } from "../lib/api";
import { LS, usePersistedState } from "../lib/storage";
import Avatar from "./Avatar"; import Avatar from "./Avatar";
import { notify, type NotifSettings } from "../lib/notifications"; import { notify, type NotifSettings } from "../lib/notifications";
import Tooltip from "./Tooltip"; import Tooltip from "./Tooltip";
import { Section, SettingRow, Switch } from "./ui/form";
import { DraftSaveBar } from "./ui/DraftSaveBar";
import { useConfirm } from "./ConfirmProvider"; import { useConfirm } from "./ConfirmProvider";
// The Settings page edits server-persisted preferences as a draft: changes apply locally // 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 }, { 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 // 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 // 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. // shown by the Header; the tab rail stays as in-page sub-navigation.
@ -59,11 +54,11 @@ export default function SettingsPanel({
onOpenWizard: () => void; onOpenWizard: () => void;
}) { }) {
const { t } = useTranslation(); const { t } = useTranslation();
const [tab, setTabState] = useState<TabId>(loadSettingsTab); const [tabRaw, setTab] = usePersistedState(LS.settingsTab, "appearance");
const setTab = (id: TabId) => { // Clamp at render so a stale/removed tab id falls back to Appearance.
setTabState(id); const tab: TabId = TABS.some((tabItem) => tabItem.id === tabRaw)
localStorage.setItem(SETTINGS_TAB_KEY, id); ? (tabRaw as TabId)
}; : "appearance";
return ( return (
<div className="p-4 max-w-3xl w-full mx-auto"> <div className="p-4 max-w-3xl w-full mx-auto">
@ -110,87 +105,22 @@ export default function SettingsPanel({
function PrefsSaveBar({ prefs }: { prefs: PrefsController }) { function PrefsSaveBar({ prefs }: { prefs: PrefsController }) {
const { t } = useTranslation(); 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 ( return (
<div className="flex items-center justify-between gap-3 border-t border-border/60 px-4 py-3 bg-card/40"> <DraftSaveBar
<span className="text-sm text-muted"> variant="inline"
{prefs.saveState === "saved" dirty={prefs.dirty}
? <span className="text-emerald-500 inline-flex items-center gap-1.5"><Check className="w-4 h-4" />{t("settings.save.saved")}</span> state={prefs.saveState}
: prefs.saveState === "error" onSave={prefs.save}
? <span className="text-red-500">{t("settings.save.failed")}</span> onDiscard={prefs.discard}
: t("settings.save.unsaved")} labels={{
</span> saved: t("settings.save.saved"),
<div className="flex items-center gap-2"> failed: t("settings.save.failed"),
<button unsaved: t("settings.save.unsaved"),
onClick={prefs.discard} discard: t("settings.save.discard"),
disabled={saving || !prefs.dirty} save: t("settings.save.save"),
className="glass-card glass-hover flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-sm disabled:opacity-40 transition" saving: t("settings.save.saving"),
> }}
<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>
); );
} }
@ -219,27 +149,27 @@ function Appearance({ prefs }: { prefs: PrefsController }) {
</Section> </Section>
<Section title={t("settings.appearance.display")}> <Section title={t("settings.appearance.display")}>
<Row label={t("settings.appearance.darkMode")}> <SettingRow label={t("settings.appearance.darkMode")}>
<Switch <Switch
checked={theme.mode === "dark"} checked={theme.mode === "dark"}
onChange={(v) => setTheme({ ...theme, mode: v ? "dark" : "light" })} onChange={(v) => setTheme({ ...theme, mode: v ? "dark" : "light" })}
/> />
</Row> </SettingRow>
<Row label={t("settings.appearance.listView")} hint={t("settings.appearance.listViewHint")}> <SettingRow label={t("settings.appearance.listView")} hint={t("settings.appearance.listViewHint")}>
<Switch checked={view === "list"} onChange={(v) => setView(v ? "list" : "grid")} /> <Switch checked={view === "list"} onChange={(v) => setView(v ? "list" : "grid")} />
</Row> </SettingRow>
<Row <SettingRow
label={t("settings.appearance.performanceMode")} label={t("settings.appearance.performanceMode")}
hint={t("settings.appearance.performanceModeHint")} hint={t("settings.appearance.performanceModeHint")}
> >
<Switch checked={perf} onChange={setPerf} /> <Switch checked={perf} onChange={setPerf} />
</Row> </SettingRow>
<Row <SettingRow
label={t("settings.appearance.showHints")} label={t("settings.appearance.showHints")}
hint={t("settings.appearance.showHintsHint")} hint={t("settings.appearance.showHintsHint")}
> >
<Switch checked={hints} onChange={setHints} /> <Switch checked={hints} onChange={setHints} />
</Row> </SettingRow>
</Section> </Section>
<Section title={t("settings.appearance.textSize")}> <Section title={t("settings.appearance.textSize")}>
@ -268,18 +198,18 @@ function Notifications({ prefs }: { prefs: PrefsController }) {
return ( return (
<Section title={t("settings.notifications.title")}> <Section title={t("settings.notifications.title")}>
<Row <SettingRow
label={t("settings.notifications.sound")} label={t("settings.notifications.sound")}
hint={t("settings.notifications.soundHint")} hint={t("settings.notifications.soundHint")}
> >
<Switch checked={cfg.sound} onChange={(v) => update({ sound: v })} /> <Switch checked={cfg.sound} onChange={(v) => update({ sound: v })} />
</Row> </SettingRow>
<Row <SettingRow
label={t("settings.notifications.autoDismiss")} label={t("settings.notifications.autoDismiss")}
hint={t("settings.notifications.autoDismissHint")} hint={t("settings.notifications.autoDismissHint")}
> >
<Switch checked={autoOn} onChange={(v) => update({ durationMs: v ? 6000 : 0 })} /> <Switch checked={autoOn} onChange={(v) => update({ durationMs: v ? 6000 : 0 })} />
</Row> </SettingRow>
{autoOn && ( {autoOn && (
<div className="py-1.5 text-sm"> <div className="py-1.5 text-sm">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">

View file

@ -36,6 +36,7 @@ import {
} from "../lib/sidebarLayout"; } from "../lib/sidebarLayout";
import { shareUrl } from "../lib/urlState"; import { shareUrl } from "../lib/urlState";
import { notify } from "../lib/notifications"; import { notify } from "../lib/notifications";
import { Switch } from "./ui/form";
import TagManager from "./TagManager"; import TagManager from "./TagManager";
// Filter ids; display labels resolved at render time via t("sidebar.sort.<id>") etc. // Filter ids; display labels resolved at render time via t("sidebar.sort.<id>") etc.
@ -622,19 +623,7 @@ function Toggle({
return ( return (
<label className="flex items-center justify-between py-1 cursor-pointer text-sm"> <label className="flex items-center justify-between py-1 cursor-pointer text-sm">
<span>{label}</span> <span>{label}</span>
<button <Switch checked={checked} onChange={onChange} />
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>
</label> </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 { api, type AdminQuotaRow, type Me } from "../lib/api";
import { formatEta, quotaActionLabel } from "../lib/format"; import { formatEta, quotaActionLabel } from "../lib/format";
import { notify } from "../lib/notifications"; import { notify } from "../lib/notifications";
import { LS, usePersistedState } from "../lib/storage";
import Tooltip from "./Tooltip"; import Tooltip from "./Tooltip";
import { Section, SettingRow } from "./ui/form";
const RANGES = [7, 30, 90] as const; const RANGES = [7, 30, 90] as const;
const STATS_TAB_KEY = "siftlode.statsTab";
type StatsTab = "overview" | "system"; type StatsTab = "overview" | "system";
// Usage & stats page. "Overview" is per-user (sync status + your own API usage + actions); // 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 }) { export default function Stats({ me }: { me: Me }) {
const { t } = useTranslation(); const { t } = useTranslation();
const isAdmin = me.role === "admin"; const isAdmin = me.role === "admin";
const [tab, setTabState] = useState<StatsTab>(() => { const [tabRaw, setTab] = usePersistedState(LS.statsTab, "overview");
const v = localStorage.getItem(STATS_TAB_KEY); // Clamp at render: only admins may land on the System tab (covers a stale stored value).
return v === "system" && isAdmin ? "system" : "overview"; const tab: StatsTab = tabRaw === "system" && isAdmin ? "system" : "overview";
});
const setTab = (id: StatsTab) => {
setTabState(id);
localStorage.setItem(STATS_TAB_KEY, id);
};
return ( return (
<div className="p-4 max-w-4xl mx-auto"> <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. // Per-user view: sync status + your own API usage + (for real accounts) the manual sync actions.
function Overview({ me }: { me: Me }) { function Overview({ me }: { me: Me }) {
@ -112,29 +83,29 @@ function Overview({ me }: { me: Me }) {
<Section title={t("stats.sync.myStatus")}> <Section title={t("stats.sync.myStatus")}>
{s ? ( {s ? (
<div className="space-y-1.5 text-sm"> <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} {s.channels_total}
</Row> </SettingRow>
<Row label={t("stats.sync.recentSynced")} hint={t("stats.sync.recentSyncedHint")}> <SettingRow label={t("stats.sync.recentSynced")} hint={t("stats.sync.recentSyncedHint")}>
{`${s.channels_recent_synced}/${s.channels_total}`} {`${s.channels_recent_synced}/${s.channels_total}`}
</Row> </SettingRow>
<Row label={t("stats.sync.fullHistory")} hint={t("stats.sync.fullHistoryHint")}> <SettingRow label={t("stats.sync.fullHistory")} hint={t("stats.sync.fullHistoryHint")}>
{`${s.channels_deep_done}/${s.channels_deep_requested}`} {`${s.channels_deep_done}/${s.channels_deep_requested}`}
</Row> </SettingRow>
{s.deep_pending_count > 0 && ( {s.deep_pending_count > 0 && (
<Row <SettingRow
label={t("stats.sync.fullHistoryEta")} label={t("stats.sync.fullHistoryEta")}
hint={t("stats.sync.fullHistoryEtaHint", { count: s.deep_pending_count })} hint={t("stats.sync.fullHistoryEtaHint", { count: s.deep_pending_count })}
> >
{formatEta(s.deep_eta_seconds)} {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()} {s.my_videos.toLocaleString()}
</Row> </SettingRow>
<Row label={t("stats.sync.quotaLeft")} hint={t("stats.sync.quotaLeftHint")}> <SettingRow label={t("stats.sync.quotaLeft")} hint={t("stats.sync.quotaLeftHint")}>
{s.quota_remaining_today.toLocaleString()} {s.quota_remaining_today.toLocaleString()}
</Row> </SettingRow>
</div> </div>
) : ( ) : (
<div className="text-muted text-sm">{t("stats.sync.loading")}</div> <div className="text-muted text-sm">{t("stats.sync.loading")}</div>
@ -145,12 +116,12 @@ function Overview({ me }: { me: Me }) {
{usage.data ? ( {usage.data ? (
<> <>
<div className="space-y-1.5 text-sm"> <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()} {usage.data.today.toLocaleString()}
</Row> </SettingRow>
<Row label={t("stats.sync.last7d")}>{usage.data.last_7d.toLocaleString()}</Row> <SettingRow label={t("stats.sync.last7d")}>{usage.data.last_7d.toLocaleString()}</SettingRow>
<Row label={t("stats.sync.last30d")}>{usage.data.last_30d.toLocaleString()}</Row> <SettingRow label={t("stats.sync.last30d")}>{usage.data.last_30d.toLocaleString()}</SettingRow>
<Row label={t("stats.sync.allTime")}>{usage.data.all_time.toLocaleString()}</Row> <SettingRow label={t("stats.sync.allTime")}>{usage.data.all_time.toLocaleString()}</SettingRow>
</div> </div>
{Object.keys(usage.data.by_action).length > 0 && ( {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"> <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…). // 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, // 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 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 /** Persisted active-tab state the canonical helper now lives in lib/storage as
* render) so this can be called before an async-loaded tab list is known, keeping hook order stable. */ * `usePersistedState`; re-exported here under its original name for existing call sites. */
export function usePersistedTab(storageKey: string, fallback = ""): [string, (id: string) => void] { export const usePersistedTab = usePersistedState;
const [tab, setTabState] = useState<string>(() => localStorage.getItem(storageKey) ?? fallback);
const setTab = (id: string) => {
setTabState(id);
localStorage.setItem(storageKey, id);
};
return [tab, setTab];
}
export default function Tabs({ export default function Tabs({
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 i18n from "i18next";
import { initReactI18next } from "react-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). // Endonyms (shown as-is regardless of the active language — the convention for language pickers).
export const LANGUAGES = [ export const LANGUAGES = [
@ -9,7 +10,7 @@ export const LANGUAGES = [
] as const; ] as const;
export type LangCode = (typeof LANGUAGES)[number]["code"]; export type LangCode = (typeof LANGUAGES)[number]["code"];
const SUPPORTED = LANGUAGES.map((l) => l.code) as readonly string[]; 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 // 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. // `translation` namespace, so components use t("area.key") and adding an area needs no wiring.

View file

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

View file

@ -6,5 +6,14 @@
"daysAgo": "vor {{count}} T.", "daysAgo": "vor {{count}} T.",
"weeksAgo": "vor {{count}} Wo.", "weeksAgo": "vor {{count}} Wo.",
"monthsAgo": "vor {{count}} Mon.", "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", "openInManager": "Channel manager",
"openOnYouTube": "Open on YouTube", "openOnYouTube": "Open on YouTube",
"unhidden": "Unhidden “{{title}}”", "unhidden": "Unhidden “{{title}}”",
"unwatched": "Unwatched “{{title}}”", "unwatched": "Unwatched “{{title}}”"
"time": {
"justNow": "just now",
"minutes": "{{count}}m ago",
"hours": "{{count}}h ago",
"days": "{{count}}d ago"
}
} }

View file

@ -6,5 +6,14 @@
"daysAgo": "{{count}}d ago", "daysAgo": "{{count}}d ago",
"weeksAgo": "{{count}} wk ago", "weeksAgo": "{{count}} wk ago",
"monthsAgo": "{{count}} mo 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ő", "openInManager": "Csatornakezelő",
"openOnYouTube": "Megnyitás a YouTube-on", "openOnYouTube": "Megnyitás a YouTube-on",
"unhidden": "Megjelenítve: „{{title}}”", "unhidden": "Megjelenítve: „{{title}}”",
"unwatched": "Megtekintés visszavonva: „{{title}}”", "unwatched": "Megtekintés visszavonva: „{{title}}”"
"time": {
"justNow": "az imént",
"minutes": "{{count}} perce",
"hours": "{{count}} órája",
"days": "{{count}} napja"
}
} }

View file

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

View file

@ -2,8 +2,14 @@ import i18n from "../i18n";
export function relativeTime(iso: string | null): string { export function relativeTime(iso: string | null): string {
if (!iso) return ""; if (!iso) return "";
const then = new Date(iso).getTime(); return relativeFromMs(new Date(iso).getTime());
const secs = Math.max(0, (Date.now() - then) / 1000); }
/** 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][] = [ const units: [number, string][] = [
[60, "time.secondsAgo"], [60, "time.secondsAgo"],
[3600, "time.minutesAgo"], [3600, "time.minutesAgo"],
@ -51,17 +57,21 @@ export function quotaActionLabel(action: string): string {
return i18n.t(`quotaActions.${action}`, { defaultValue: action }); 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 { export function formatEta(seconds: number): string {
if (seconds <= 0) return "done"; if (seconds <= 0) return i18n.t("time.eta.done");
const hours = seconds / 3600; const hours = seconds / 3600;
if (hours < 1) return "< 1 hour"; if (hours < 1) return i18n.t("time.eta.lessThanHour");
if (hours < 24) { if (hours < 24) return i18n.t("time.eta.hours", { count: Math.round(hours) });
const h = Math.round(hours); return i18n.t("time.eta.days", { count: Math.round(hours / 24) });
return `~${h} hour${h === 1 ? "" : "s"}`;
} }
const d = Math.round(hours / 24);
return `~${d} day${d === 1 ? "" : "s"}`; /** 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 { export function formatViews(n: number | null): string {

View file

@ -1,36 +1,27 @@
// App-wide "hints" toggle: when on, Tooltip components reveal short explanatory // 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 // 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). // users can turn it off in Settings. Persisted to localStorage (+ server preferences).
const KEY = "siftlode.hints"; import { createStore } from "./store";
import { LS } from "./storage";
let enabled = load();
let listeners: Array<() => void> = [];
function load(): boolean { function load(): boolean {
try { try {
return localStorage.getItem(KEY) !== "0"; // default ON return localStorage.getItem(LS.hints) !== "0"; // default ON
} catch { } catch {
return true; return true;
} }
} }
export function hintsEnabled(): boolean { const store = createStore<boolean>(load());
return enabled;
} export const hintsEnabled = store.get;
export const subscribeHints = store.subscribe;
export function setHintsEnabled(v: boolean): void { export function setHintsEnabled(v: boolean): void {
enabled = v; store.set(v);
try { try {
localStorage.setItem(KEY, v ? "1" : "0"); localStorage.setItem(LS.hints, v ? "1" : "0");
} catch { } catch {
/* ignore */ /* 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 // the Notification Center shows (info events, actions awaiting interaction, and app
// errors). History is persisted to localStorage so it survives reloads; action // errors). History is persisted to localStorage so it survives reloads; action
// callbacks are live-only and dropped on reload. // callbacks are live-only and dropped on reload.
import { LS, readJSON, readMerged, writeJSON } from "./storage";
export type NotifLevel = "info" | "success" | "warning" | "error" | "fatal"; 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) transient?: boolean; // live status: sticky toast, not persisted (see Notification.transient)
} }
const HISTORY_KEY = "siftlode.notifications"; const HISTORY_KEY = LS.notifHistory;
const SETTINGS_KEY = "siftlode.notifSettings"; const SETTINGS_KEY = LS.notifSettings;
const MAX_HISTORY = 100; const MAX_HISTORY = 100;
const ERROR_TTL = 15000; const ERROR_TTL = 15000;
@ -86,11 +87,7 @@ const DEFAULT_SETTINGS: NotifSettings = { sound: false, durationMs: 6000 };
let config: NotifSettings = loadSettings(); let config: NotifSettings = loadSettings();
function loadSettings(): NotifSettings { function loadSettings(): NotifSettings {
try { return readMerged(SETTINGS_KEY, DEFAULT_SETTINGS);
return { ...DEFAULT_SETTINGS, ...JSON.parse(localStorage.getItem(SETTINGS_KEY) || "{}") };
} catch {
return DEFAULT_SETTINGS;
}
} }
export function getNotifSettings(): NotifSettings { export function getNotifSettings(): NotifSettings {
@ -99,11 +96,7 @@ export function getNotifSettings(): NotifSettings {
export function configureNotifications(p: Partial<NotifSettings>): void { export function configureNotifications(p: Partial<NotifSettings>): void {
config = { ...config, ...p }; config = { ...config, ...p };
try { writeJSON(SETTINGS_KEY, config);
localStorage.setItem(SETTINGS_KEY, JSON.stringify(config));
} catch {
/* ignore */
}
} }
let audioCtx: AudioContext | null = null; let audioCtx: AudioContext | null = null;
@ -137,7 +130,7 @@ let cachedUnread = 0;
function load(): Notification[] { function load(): Notification[] {
try { try {
const raw = JSON.parse(localStorage.getItem(HISTORY_KEY) || "[]"); const raw = readJSON<unknown>(HISTORY_KEY, []);
if (!Array.isArray(raw)) return []; if (!Array.isArray(raw)) return [];
// Restored entries are history only. Mark them dismissed so they don't // Restored entries are history only. Mark them dismissed so they don't
// resurrect as active toasts on reload — their auto-dismiss timers aren'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, // 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. // 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); const slim = items.filter((n) => !n.transient).map(({ action: _action, ...n }) => n);
localStorage.setItem(HISTORY_KEY, JSON.stringify(slim)); writeJSON(HISTORY_KEY, slim);
} catch { } catch {
/* ignore quota / serialization errors */ /* ignore quota / serialization errors */
} }

View file

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

View file

@ -14,6 +14,19 @@ export interface ReleaseEntry {
} }
export const RELEASE_NOTES: 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", version: "0.16.2",
date: "2026-06-26", 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 // `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. // 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 type WidgetId = "date" | "language" | "topic" | "tags";
export const ALL_WIDGETS: WidgetId[] = ["date", "language", "topic", "tags"]; export const ALL_WIDGETS: WidgetId[] = ["date", "language", "topic", "tags"];
@ -27,8 +29,6 @@ export const DEFAULT_LAYOUT: SidebarLayout = {
hidden: {}, hidden: {},
}; };
const KEY = "siftlode.sidebarLayout";
// Tolerate stale/partial data: keep only known widgets and append any that are missing // 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. // (e.g. a widget added in a later version) so nothing silently disappears.
export function normalizeLayout(raw: unknown): SidebarLayout { export function normalizeLayout(raw: unknown): SidebarLayout {
@ -45,13 +45,9 @@ export function normalizeLayout(raw: unknown): SidebarLayout {
} }
export function loadLayout(): SidebarLayout { export function loadLayout(): SidebarLayout {
try { return normalizeLayout(readJSON<unknown>(LS.sidebarLayout, {}));
return normalizeLayout(JSON.parse(localStorage.getItem(KEY) || "{}"));
} catch {
return DEFAULT_LAYOUT;
}
} }
export function saveLayoutLocal(l: SidebarLayout): void { 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 Mode = "dark" | "light";
export type Scheme = "midnight" | "forest" | "slate" | "youtube"; 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)); el.style.setProperty("--font-scale", String(t.fontScale));
} }
const KEY = "siftlode.theme";
export function loadLocalTheme(): ThemePrefs { export function loadLocalTheme(): ThemePrefs {
try { return readMerged(LS.theme, DEFAULT_THEME);
return { ...DEFAULT_THEME, ...JSON.parse(localStorage.getItem(KEY) || "{}") };
} catch {
return DEFAULT_THEME;
}
} }
export function saveLocalTheme(t: ThemePrefs): void { 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). // 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. // 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_VERSION = import.meta.env.VITE_APP_VERSION || "dev";
export const FRONTEND_SHA = import.meta.env.VITE_GIT_SHA || "unknown"; export const FRONTEND_SHA = import.meta.env.VITE_GIT_SHA || "unknown";
export const FRONTEND_BUILD_DATE = import.meta.env.VITE_BUILD_DATE || ""; 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). // 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;