diff --git a/VERSION b/VERSION index 201a22c..c5523bd 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.16.2 +0.17.0 diff --git a/backend/app/auth.py b/backend/app/auth.py index 1ac8bee..f71b55a 100644 --- a/backend/app/auth.py +++ b/backend/app/auth.py @@ -1,6 +1,4 @@ -import hashlib import logging -import re import secrets from datetime import datetime, timedelta, timezone @@ -8,7 +6,7 @@ import httpx from authlib.integrations.starlette_client import OAuth, OAuthError from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Request from fastapi.responses import JSONResponse, RedirectResponse -from sqlalchemy import delete, select +from sqlalchemy import delete, func, select from sqlalchemy.orm import Session from app import email as email_mod @@ -17,7 +15,8 @@ from app.config import settings from app.db import get_db from app.models import AuthToken, DemoWhitelist, Invite, OAuthToken, User from app.ratelimit import RateLimiter -from app.security import decrypt, encrypt, hash_password, verify_password +from app.security import decrypt, encrypt, hash_password, hash_token, verify_password +from app.utils import valid_email # Email+password auth tuning. PASSWORD_MIN_LEN = 10 @@ -43,8 +42,6 @@ def _notify_suspended(background: BackgroundTasks, email: str) -> None: if _suspend_email_limiter.allow(email): background.add_task(email_mod.send_account_suspended, email, operator_contact()) -_EMAIL_RE = re.compile(r"^[^@\s]+@[^@\s]+\.[^@\s]+$") - # The single shared demo account's stable identity. The row is created lazily on first # demo login (see get_or_create_demo_user); these are just its sentinel keys. DEMO_GOOGLE_SUB = "__demo__" @@ -184,7 +181,7 @@ def upsert_pending_invite(db: Session, email: str) -> Invite | None: """Idempotently record an access request. Returns the Invite if a *new* pending row was created (so the caller can notify admins), else None for already-known emails.""" email = (email or "").lower() - if not _EMAIL_RE.match(email): + if not valid_email(email): return None inv = db.execute(select(Invite).where(Invite.email == email)).scalar_one_or_none() if inv is not None: @@ -443,7 +440,7 @@ async def request_access( """Public: ask for access. Idempotent — repeat requests for the same email don't duplicate or re-spam admins (upsert returns None for an already-pending row).""" email = (payload.get("email") or "").strip().lower() - if not _EMAIL_RE.match(email): + if not valid_email(email): raise HTTPException(status_code=400, detail="Enter a valid email address.") if is_allowed(db, email): return {"status": "approved"} # already allowed — just sign in @@ -465,7 +462,7 @@ def demo_login(payload: dict, request: Request, db: Session = Depends(get_db)) - if not _demo_limiter.allow(_client_ip(request)): return {"authenticated": False} email = (payload.get("email") or "").strip().lower() - if _EMAIL_RE.match(email) and is_demo_allowed(db, email): + if valid_email(email) and is_demo_allowed(db, email): demo = get_or_create_demo_user(db) request.session["user_id"] = demo.id log.info("Demo login (key=%s, demo_id=%s)", email, demo.id) @@ -487,10 +484,6 @@ def _app_base() -> str: return settings.app_base -def _hash_token(raw: str) -> str: - return hashlib.sha256(raw.encode()).hexdigest() - - def _issue_token(db: Session, user: User, kind: str, ttl: timedelta) -> str: """Create a single-use token of `kind`; only its hash is stored. Returns the raw token (goes only into the emailed link).""" @@ -499,7 +492,7 @@ def _issue_token(db: Session, user: User, kind: str, ttl: timedelta) -> str: AuthToken( user_id=user.id, kind=kind, - token_hash=_hash_token(raw), + token_hash=hash_token(raw), expires_at=datetime.now(timezone.utc) + ttl, ) ) @@ -513,7 +506,7 @@ def _consume_token(db: Session, raw: str | None, kind: str) -> User | None: return None row = db.execute( select(AuthToken).where( - AuthToken.token_hash == _hash_token(raw), AuthToken.kind == kind + AuthToken.token_hash == hash_token(raw), AuthToken.kind == kind ) ).scalar_one_or_none() if row is None or row.used_at is not None or row.expires_at < datetime.now(timezone.utc): @@ -537,7 +530,7 @@ def register( email = (payload.get("email") or "").strip().lower() password = payload.get("password") or "" # Input validation is safe to reveal (independent of whether the email exists). - if not _EMAIL_RE.match(email): + if not valid_email(email): raise HTTPException(status_code=400, detail="Enter a valid email address.") if len(password) < PASSWORD_MIN_LEN: raise HTTPException( @@ -633,7 +626,7 @@ def password_reset_request( if not _reset_limiter.allow(_client_ip(request)): return {"status": "ok"} email = (payload.get("email") or "").strip().lower() - if _EMAIL_RE.match(email): + if valid_email(email): user = db.execute(select(User).where(User.email == email)).scalar_one_or_none() if user is not None and user.password_hash and not user.is_demo: raw = _issue_token(db, user, "reset", RESET_TTL) @@ -694,6 +687,23 @@ def require_human(user: User = Depends(current_user)) -> User: return user +def admin_user(user: User = Depends(current_user)) -> User: + """Dependency: require the signed-in user to be an admin. Single source of truth for the + admin gate — every admin route/action depends on this rather than re-checking the role.""" + if user.role != "admin": + raise HTTPException(status_code=403, detail="Admin only") + return user + + +def count_admins(db: Session, *, active_only: bool = False) -> int: + """How many admins exist (optionally only un-suspended ones). Used by the 'can't remove / + suspend / delete the last admin' guards so they don't each re-spell the count query.""" + q = select(func.count()).select_from(User).where(User.role == "admin") + if active_only: + q = q.where(User.is_suspended.is_(False)) + return db.scalar(q) or 0 + + @router.get("/link") async def link_google( request: Request, diff --git a/backend/app/models.py b/backend/app/models.py index 86226b3..4ebf14d 100644 --- a/backend/app/models.py +++ b/backend/app/models.py @@ -19,7 +19,24 @@ from sqlalchemy.orm import Mapped, mapped_column, relationship from app.db import Base -class User(Base): +class TimestampMixin: + """Adds a server-defaulted `created_at`. Models that index created_at or order it + differently (QuotaEvent, Notification, Message) keep their own column on purpose.""" + + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), server_default=func.now() + ) + + +class UpdatedAtMixin: + """Adds an `updated_at` that server-defaults on insert and bumps on every UPDATE.""" + + updated_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), server_default=func.now(), onupdate=func.now() + ) + + +class User(Base, TimestampMixin): __tablename__ = "users" id: Mapped[int] = mapped_column(primary_key=True) @@ -57,9 +74,6 @@ class User(Base): ) # Free-form UI preferences (theme, color scheme, font scale, default filters…). preferences: Mapped[dict | None] = mapped_column(JSON) - created_at: Mapped[datetime] = mapped_column( - DateTime(timezone=True), server_default=func.now() - ) token: Mapped["OAuthToken | None"] = relationship( back_populates="user", uselist=False, cascade="all, delete-orphan" @@ -69,7 +83,7 @@ class User(Base): ) -class OAuthToken(Base): +class OAuthToken(Base, UpdatedAtMixin): __tablename__ = "oauth_tokens" id: Mapped[int] = mapped_column(primary_key=True) @@ -81,14 +95,11 @@ class OAuthToken(Base): access_token: Mapped[str | None] = mapped_column(String) expiry: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) scopes: Mapped[str | None] = mapped_column(String) - updated_at: Mapped[datetime] = mapped_column( - DateTime(timezone=True), server_default=func.now(), onupdate=func.now() - ) user: Mapped["User"] = relationship(back_populates="token") -class AuthToken(Base): +class AuthToken(Base, TimestampMixin): """Single-use, expiring token for email verification or password reset. Only the SHA-256 HASH of the token is stored, so a DB leak can't be used to verify/reset; the raw token lives only in the emailed link. Consumed (used_at set) on first valid use.""" @@ -103,9 +114,6 @@ class AuthToken(Base): token_hash: Mapped[str] = mapped_column(String(64), unique=True, index=True) expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True)) used_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) - created_at: Mapped[datetime] = mapped_column( - DateTime(timezone=True), server_default=func.now() - ) class Invite(Base): @@ -128,7 +136,7 @@ class Invite(Base): decided_by: Mapped[str | None] = mapped_column(String(320)) # admin email -class DemoWhitelist(Base): +class DemoWhitelist(Base, TimestampMixin): """Emails that may enter the shared demo account from the login page (no Google OAuth). A hidden, admin-curated list of "keys to the same door" — every entry logs into the one shared demo user. Distinct from Invite (which gates real Google sign-in).""" @@ -139,12 +147,9 @@ class DemoWhitelist(Base): email: Mapped[str] = mapped_column(String(320), unique=True, index=True) note: Mapped[str | None] = mapped_column(Text) added_by: Mapped[str | None] = mapped_column(String(320)) # admin email - created_at: Mapped[datetime] = mapped_column( - DateTime(timezone=True), server_default=func.now() - ) -class Channel(Base): +class Channel(Base, TimestampMixin): """A YouTube channel. Shared across all users (one channel's videos are the same for everyone), so its expensive metadata is fetched and stored only once.""" @@ -170,15 +175,12 @@ class Channel(Base): backfill_done: Mapped[bool] = mapped_column( Boolean, default=False, server_default="false" ) - created_at: Mapped[datetime] = mapped_column( - DateTime(timezone=True), server_default=func.now() - ) videos: Mapped[list["Video"]] = relationship(back_populates="channel") subscriptions: Mapped[list["Subscription"]] = relationship(back_populates="channel") -class Subscription(Base): +class Subscription(Base, TimestampMixin): """Per-user link to a channel, with the user's own priority/visibility overrides.""" __tablename__ = "subscriptions" @@ -202,15 +204,12 @@ class Subscription(Base): Boolean, default=False, server_default="false" ) subscribed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) - created_at: Mapped[datetime] = mapped_column( - DateTime(timezone=True), server_default=func.now() - ) user: Mapped["User"] = relationship(back_populates="subscriptions") channel: Mapped["Channel"] = relationship(back_populates="subscriptions") -class Video(Base): +class Video(Base, TimestampMixin): """A YouTube video. Shared across users; per-user state lives elsewhere.""" __tablename__ = "videos" @@ -260,14 +259,11 @@ class Video(Base): DateTime(timezone=True), index=True ) unavailable_reason: Mapped[str | None] = mapped_column(String(24)) - created_at: Mapped[datetime] = mapped_column( - DateTime(timezone=True), server_default=func.now() - ) channel: Mapped["Channel"] = relationship(back_populates="videos") -class Tag(Base): +class Tag(Base, TimestampMixin): """A label. System tags (user_id NULL) are computed automatically and shared by all users; user tags (user_id set) are personal. category groups them in the UI.""" @@ -283,9 +279,6 @@ class Tag(Base): category: Mapped[str] = mapped_column( String(16), default="other", server_default="other" ) - created_at: Mapped[datetime] = mapped_column( - DateTime(timezone=True), server_default=func.now() - ) class ChannelTag(Base): @@ -312,7 +305,7 @@ class ChannelTag(Base): confidence: Mapped[float | None] = mapped_column(Float) -class VideoState(Base): +class VideoState(Base, UpdatedAtMixin): """Per-user watch state for a video. Rows exist only for non-default states; the absence of a row means 'new/unseen'.""" @@ -326,7 +319,7 @@ class VideoState(Base): video_id: Mapped[str] = mapped_column( ForeignKey("videos.id", ondelete="CASCADE"), index=True ) - # new | watched | saved | hidden + # new | watched | hidden ("saved" is now Watch-later playlist membership, not a status) status: Mapped[str] = mapped_column(String(12), default="new", server_default="new") watched_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) # Resume position (seconds into the video) for the in-app player. Orthogonal to @@ -337,9 +330,6 @@ class VideoState(Base): Integer, default=0, server_default="0", nullable=False ) progress_updated_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) - updated_at: Mapped[datetime] = mapped_column( - DateTime(timezone=True), server_default=func.now(), onupdate=func.now() - ) class ApiQuotaUsage(Base): @@ -422,7 +412,7 @@ class SystemConfig(Base): ) -class Playlist(Base): +class Playlist(Base, TimestampMixin, UpdatedAtMixin): """A per-user named, ordered collection of videos from the shared catalog. `kind`: 'user' (normal) or 'watch_later' (built-in, undeletable, one per user). @@ -450,12 +440,6 @@ class Playlist(Base): # baseline `dirty` is derived against. NULL = no baseline yet (treated as dirty). synced_fingerprint: Mapped[str | None] = mapped_column(String) position: Mapped[int] = mapped_column(Integer, default=0, server_default="0") - created_at: Mapped[datetime] = mapped_column( - DateTime(timezone=True), server_default=func.now() - ) - updated_at: Mapped[datetime] = mapped_column( - DateTime(timezone=True), server_default=func.now(), onupdate=func.now() - ) items: Mapped[list["PlaylistItem"]] = relationship( back_populates="playlist", @@ -560,7 +544,7 @@ class Message(Base): ) -class MessageKey(Base): +class MessageKey(Base, TimestampMixin): """A user's end-to-end-encryption key material for direct messaging (phase 2). One row per user. `public_key` (base64 SPKI, ECDH P-256) is shared with others so they can @@ -581,6 +565,3 @@ class MessageKey(Base): salt: Mapped[str] = mapped_column(String(64)) wrap_iv: Mapped[str] = mapped_column(String(32)) key_check: Mapped[str] = mapped_column(Text) - created_at: Mapped[datetime] = mapped_column( - DateTime(timezone=True), server_default=func.now() - ) diff --git a/backend/app/quota.py b/backend/app/quota.py index be699a1..1a45011 100644 --- a/backend/app/quota.py +++ b/backend/app/quota.py @@ -66,6 +66,26 @@ def attribute(actor_id: int | None, action: str): _action.reset(t2) +class Measure: + """Result handle from `measured()` — read .used / .remaining after the block exits.""" + + used = 0 + remaining = 0 + + +@contextlib.contextmanager +def measured(db: Session, actor_id: int | None, action: str): + """Like `attribute()`, but also reports how much quota the block spent. Yields a `Measure` + whose `.used` (units charged inside the block) and `.remaining` (today's remaining after) + are populated on exit — so manual sync routes don't each re-spell the before/after diff.""" + m = Measure() + before = units_used_today(db) + with attribute(actor_id, action): + yield m + m.used = units_used_today(db) - before + m.remaining = remaining_today(db) + + def pacific_today(): return datetime.now(_PACIFIC).date() diff --git a/backend/app/routes/admin.py b/backend/app/routes/admin.py index 1f7d3d8..722ac3c 100644 --- a/backend/app/routes/admin.py +++ b/backend/app/routes/admin.py @@ -1,6 +1,5 @@ """Admin-only onboarding: review access requests (Invites), approve/deny, manual add. Also the demo-account admin surface: the demo email whitelist + a manual state reset.""" -import re from datetime import datetime, timezone from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException @@ -8,7 +7,13 @@ from sqlalchemy import delete, func, select from sqlalchemy.orm import Session from app import email as email_mod -from app.auth import current_user, get_or_create_demo_user, operator_contact, purge_user +from app.auth import ( + admin_user, + count_admins, + get_or_create_demo_user, + operator_contact, + purge_user, +) from app.db import get_db from app.models import ( DemoWhitelist, @@ -20,17 +25,10 @@ from app.models import ( Video, VideoState, ) +from app.utils import valid_email router = APIRouter(prefix="/api/admin", tags=["admin"]) -_EMAIL_RE = re.compile(r"^[^@\s]+@[^@\s]+\.[^@\s]+$") - - -def admin_user(user: User = Depends(current_user)) -> User: - if user.role != "admin": - raise HTTPException(status_code=403, detail="Admin only") - return user - def _serialize(inv: Invite) -> dict: return { @@ -110,7 +108,7 @@ def add_invite( ) -> dict: """Manually whitelist an email (approved straight away). Convenience for the admin.""" email = (payload.get("email") or "").strip().lower() - if "@" not in email: + if not valid_email(email): raise HTTPException(status_code=400, detail="Enter a valid email address.") inv = db.execute(select(Invite).where(Invite.email == email)).scalar_one_or_none() if inv is None: @@ -168,10 +166,8 @@ def set_user_role( raise HTTPException(status_code=400, detail="The demo account's role can't be changed.") if target.id == admin.id: raise HTTPException(status_code=400, detail="You can't change your own role.") - if target.role == "admin" and role == "user": - admin_count = db.scalar(select(func.count()).select_from(User).where(User.role == "admin")) - if (admin_count or 0) <= 1: - raise HTTPException(status_code=400, detail="Can't remove the last admin.") + if target.role == "admin" and role == "user" and count_admins(db) <= 1: + raise HTTPException(status_code=400, detail="Can't remove the last admin.") changed = target.role != role target.role = role db.commit() @@ -201,14 +197,13 @@ def set_user_suspended( raise HTTPException(status_code=400, detail="The demo account can't be suspended.") if target.id == admin.id: raise HTTPException(status_code=400, detail="You can't suspend your own account.") - if suspended and target.role == "admin" and not target.is_suspended: - active_admins = db.scalar( - select(func.count()) - .select_from(User) - .where(User.role == "admin", User.is_suspended.is_(False)) - ) - if (active_admins or 0) <= 1: - raise HTTPException(status_code=400, detail="Can't suspend the last active admin.") + if ( + suspended + and target.role == "admin" + and not target.is_suspended + and count_admins(db, active_only=True) <= 1 + ): + raise HTTPException(status_code=400, detail="Can't suspend the last active admin.") was_suspended = target.is_suspended target.is_suspended = suspended db.commit() @@ -238,12 +233,8 @@ def admin_delete_user( raise HTTPException( status_code=400, detail="Delete your own account from Settings → Account." ) - if target.role == "admin": - admin_count = db.scalar( - select(func.count()).select_from(User).where(User.role == "admin") - ) - if (admin_count or 0) <= 1: - raise HTTPException(status_code=400, detail="Can't delete the last admin.") + if target.role == "admin" and count_admins(db) <= 1: + raise HTTPException(status_code=400, detail="Can't delete the last admin.") purge_user(db, target, background) return {"deleted": user_id} @@ -281,7 +272,7 @@ def add_demo_whitelist( """Add an email that may enter the shared demo account from the login page (no Google sign-in). Idempotent — re-adding an existing email just returns it.""" email = (payload.get("email") or "").strip().lower() - if not _EMAIL_RE.match(email): + if not valid_email(email): raise HTTPException(status_code=400, detail="Enter a valid email address.") row = db.execute( select(DemoWhitelist).where(DemoWhitelist.email == email) diff --git a/backend/app/routes/feed.py b/backend/app/routes/feed.py index e2f331a..7e43f08 100644 --- a/backend/app/routes/feed.py +++ b/backend/app/routes/feed.py @@ -62,6 +62,29 @@ def _saved_expr(user: User): ) +def feed_columns(user: User, state) -> list: + """The shared feed-row projection (joined Channel fields + per-user watch state + + `saved`), consumed by both the feed query and the playlist-detail query so a new column + is added in exactly one place. `state` is the caller's aliased VideoState (outer-joined).""" + return [ + Video.id, + Video.title, + Video.channel_id, + Channel.title.label("channel_title"), + Channel.thumbnail_url.label("channel_thumbnail"), + Channel.handle.label("channel_handle"), + Video.published_at, + Video.thumbnail_url, + Video.duration_seconds, + Video.view_count, + Video.is_short, + Video.live_status, + func.coalesce(state.status, "new").label("status"), + func.coalesce(state.position_seconds, 0).label("position_seconds"), + _saved_expr(user), + ] + + def _serialize(row) -> dict: return { "id": row.id, @@ -115,23 +138,9 @@ def _filtered_query( status_expr = func.coalesce(state.status, "new") position_expr = func.coalesce(state.position_seconds, 0) - query = select( - Video.id, - Video.title, - Video.channel_id, - Channel.title.label("channel_title"), - Channel.thumbnail_url.label("channel_thumbnail"), - Channel.handle.label("channel_handle"), - Video.published_at, - Video.thumbnail_url, - Video.duration_seconds, - Video.view_count, - Video.is_short, - Video.live_status, - status_expr.label("status"), - position_expr.label("position_seconds"), - _saved_expr(user), - ).join(Channel, Channel.id == Video.channel_id) + query = select(*feed_columns(user, state)).join( + Channel, Channel.id == Video.channel_id + ) if scope == "all": # Whole shared catalog; subscription is optional (only for priority sort). diff --git a/backend/app/routes/me.py b/backend/app/routes/me.py index 26498b4..dca46a9 100644 --- a/backend/app/routes/me.py +++ b/backend/app/routes/me.py @@ -3,6 +3,7 @@ from sqlalchemy import func, select from sqlalchemy.orm import Session from app.auth import ( + count_admins, current_user, google_enabled, has_read_scope, @@ -111,13 +112,11 @@ def delete_my_account( (that would lock everyone out of the admin surfaces).""" if user.is_demo: raise HTTPException(status_code=403, detail="The shared demo account can't be deleted.") - if user.role == "admin": - admins = db.scalar(select(func.count()).select_from(User).where(User.role == "admin")) or 0 - if admins <= 1: - raise HTTPException( - status_code=400, - detail="You're the only admin — promote another admin before deleting your account.", - ) + if user.role == "admin" and count_admins(db) <= 1: + raise HTTPException( + status_code=400, + detail="You're the only admin — promote another admin before deleting your account.", + ) user_id = user.id # Full erasure (cascades) + access-request cleanup + Google-grant revocation; shared with the # admin delete path. Capture the id first — `user` is detached once purge_user deletes the row. diff --git a/backend/app/routes/messages.py b/backend/app/routes/messages.py index 59998a4..9718e1d 100644 --- a/backend/app/routes/messages.py +++ b/backend/app/routes/messages.py @@ -103,6 +103,7 @@ def _serialize_msg(m: Message) -> dict: def _messageable() -> list: + """SQL clauses for "a real, active, non-suspended human" — for directory/recipient queries.""" return [ User.is_demo.is_(False), User.is_active.is_(True), @@ -110,6 +111,11 @@ def _messageable() -> list: ] +def is_messageable_user(u: User | None) -> bool: + """Python mirror of `_messageable()` for an already-loaded row (WS auth, send recipient).""" + return bool(u and not u.is_demo and u.is_active and not u.is_suspended) + + def ensure_welcome(db: Session, user: User) -> None: """Create the one-time system welcome message for a (human) user the first time it's needed. Idempotent: guarded on whether any system message already exists for them.""" @@ -315,7 +321,7 @@ def send_message( if payload.recipient_id == user.id: raise HTTPException(status_code=400, detail="You can't message yourself.") recipient = db.get(User, payload.recipient_id) - if recipient is None or recipient.is_demo or not recipient.is_active or recipient.is_suspended: + if not is_messageable_user(recipient): raise HTTPException(status_code=404, detail="Recipient not available.") if db.get(MessageKey, recipient.id) is None: raise HTTPException(status_code=404, detail="That member hasn't set up messaging yet.") @@ -363,8 +369,7 @@ async def messages_ws(ws: WebSocket) -> None: return db = SessionLocal() try: - u = db.get(User, uid) - ok = bool(u and not u.is_demo and not u.is_suspended and u.is_active) + ok = is_messageable_user(db.get(User, uid)) finally: db.close() if not ok: diff --git a/backend/app/routes/playlists.py b/backend/app/routes/playlists.py index 89228cb..a3ebf8a 100644 --- a/backend/app/routes/playlists.py +++ b/backend/app/routes/playlists.py @@ -9,7 +9,7 @@ from app import quota from app.auth import current_user, has_read_scope, has_write_scope from app.db import get_db from app.models import Channel, Playlist, PlaylistItem, User, Video, VideoState -from app.routes.feed import _saved_expr, _serialize +from app.routes.feed import _serialize, feed_columns from app.sync.playlists import ( plan_push, push_playlist, @@ -29,6 +29,51 @@ def _own_playlist(db: Session, user: User, playlist_id: int) -> Playlist: return pl +def _next_playlist_position(db: Session, user_id: int) -> int: + """Append position for a new playlist in this user's rail.""" + return ( + db.scalar( + select(func.coalesce(func.max(Playlist.position), -1)).where( + Playlist.user_id == user_id + ) + ) + + 1 + ) + + +def _next_item_position(db: Session, playlist_id: int) -> int: + """Append position for a new item at the end of a playlist.""" + return ( + db.scalar( + select(func.coalesce(func.max(PlaylistItem.position), -1)).where( + PlaylistItem.playlist_id == playlist_id + ) + ) + + 1 + ) + + +def _add_item(db: Session, pl: Playlist, video_id: str, *, mark_dirty: bool) -> bool: + """Append a video to a playlist if not already present (idempotent). Returns whether it + was added. `mark_dirty` recomputes the YouTube-sync dirty flag (skip for Watch later).""" + exists = db.scalar( + select(PlaylistItem).where( + PlaylistItem.playlist_id == pl.id, PlaylistItem.video_id == video_id + ) + ) + if exists is not None: + return False + db.add( + PlaylistItem( + playlist_id=pl.id, video_id=video_id, position=_next_item_position(db, pl.id) + ) + ) + if mark_dirty: + recompute_dirty(db, pl) + db.commit() + return True + + def _summary( db: Session, pl: Playlist, @@ -142,15 +187,7 @@ def create_playlist( name = (payload.get("name") or "").strip() if not name: raise HTTPException(status_code=400, detail="name is required") - next_pos = ( - db.scalar( - select(func.coalesce(func.max(Playlist.position), -1)).where( - Playlist.user_id == user.id - ) - ) - + 1 - ) - pl = Playlist(user_id=user.id, name=name, position=next_pos) + pl = Playlist(user_id=user.id, name=name, position=_next_playlist_position(db, user.id)) db.add(pl) db.commit() return _summary(db, pl, 0) @@ -164,16 +201,11 @@ def _watch_later(db: Session, user: User) -> Playlist: ) ) if pl is None: - next_pos = ( - db.scalar( - select(func.coalesce(func.max(Playlist.position), -1)).where( - Playlist.user_id == user.id - ) - ) - + 1 - ) pl = Playlist( - user_id=user.id, name="Watch later", kind="watch_later", position=next_pos + user_id=user.id, + name="Watch later", + kind="watch_later", + position=_next_playlist_position(db, user.id), ) db.add(pl) db.commit() @@ -191,22 +223,7 @@ def add_watch_later( if not video_id or db.get(Video, video_id) is None: raise HTTPException(status_code=404, detail="Unknown video") pl = _watch_later(db, user) - exists = db.scalar( - select(PlaylistItem).where( - PlaylistItem.playlist_id == pl.id, PlaylistItem.video_id == video_id - ) - ) - if exists is None: - next_pos = ( - db.scalar( - select(func.coalesce(func.max(PlaylistItem.position), -1)).where( - PlaylistItem.playlist_id == pl.id - ) - ) - + 1 - ) - db.add(PlaylistItem(playlist_id=pl.id, video_id=video_id, position=next_pos)) - db.commit() + _add_item(db, pl, video_id, mark_dirty=False) return {"saved": True, "playlist_id": pl.id} @@ -344,23 +361,7 @@ def get_playlist( pl = _own_playlist(db, user, playlist_id) state = aliased(VideoState) rows = db.execute( - select( - Video.id, - Video.title, - Video.channel_id, - Channel.title.label("channel_title"), - Channel.thumbnail_url.label("channel_thumbnail"), - Channel.handle.label("channel_handle"), - Video.published_at, - Video.thumbnail_url, - Video.duration_seconds, - Video.view_count, - Video.is_short, - Video.live_status, - func.coalesce(state.status, "new").label("status"), - func.coalesce(state.position_seconds, 0).label("position_seconds"), - _saved_expr(user), - ) + select(*feed_columns(user, state)) .join(PlaylistItem, PlaylistItem.video_id == Video.id) .join(Channel, Channel.id == Video.channel_id) .outerjoin(state, and_(state.video_id == Video.id, state.user_id == user.id)) @@ -439,25 +440,7 @@ def add_item( video_id = payload.get("video_id") if not video_id or db.get(Video, video_id) is None: raise HTTPException(status_code=404, detail="Unknown video") - exists = db.scalar( - select(PlaylistItem).where( - PlaylistItem.playlist_id == pl.id, PlaylistItem.video_id == video_id - ) - ) - if exists is None: - next_pos = ( - db.scalar( - select(func.coalesce(func.max(PlaylistItem.position), -1)).where( - PlaylistItem.playlist_id == pl.id - ) - ) - + 1 - ) - db.add( - PlaylistItem(playlist_id=pl.id, video_id=video_id, position=next_pos) - ) - recompute_dirty(db, pl) - db.commit() + _add_item(db, pl, video_id, mark_dirty=True) return {"playlist_id": pl.id, "video_id": video_id} diff --git a/backend/app/routes/sync.py b/backend/app/routes/sync.py index e6fdcc2..0ee9f4e 100644 --- a/backend/app/routes/sync.py +++ b/backend/app/routes/sync.py @@ -3,7 +3,7 @@ from sqlalchemy import and_, case, func, select, update from sqlalchemy.orm import Session from app import quota, state -from app.auth import current_user, has_read_scope, require_human +from app.auth import admin_user, current_user, has_read_scope, require_human from app.db import get_db from app.models import Channel, Subscription, User, Video from app.sync.runner import ( @@ -40,11 +40,10 @@ def sync_subscriptions( status_code=403, detail="Connect your YouTube account (read access) to import subscriptions.", ) - before = quota.units_used_today(db) - with quota.attribute(user.id, quota.QuotaAction.SUBSCRIPTIONS_PULL): + with quota.measured(db, user.id, quota.QuotaAction.SUBSCRIPTIONS_PULL) as m: result = import_subscriptions(db, user) - result["quota_used_estimate"] = quota.units_used_today(db) - before - result["quota_remaining_today"] = quota.remaining_today(db) + result["quota_used_estimate"] = m.used + result["quota_remaining_today"] = m.remaining return result @@ -62,10 +61,9 @@ def sync_backfill( db: Session = Depends(get_db), max_channels: int = 25, ) -> dict: - before = quota.units_used_today(db) - with quota.attribute(user.id, quota.QuotaAction.VIDEOS_BACKFILL_RECENT): + with quota.measured(db, user.id, quota.QuotaAction.VIDEOS_BACKFILL_RECENT) as m: result = run_recent_backfill(db, _user_channels(db, user), max_channels=max_channels) - result["quota_used_estimate"] = quota.units_used_today(db) - before + result["quota_used_estimate"] = m.used return result @@ -73,13 +71,9 @@ def sync_backfill( def sync_enrich( user: User = Depends(require_human), db: Session = Depends(get_db) ) -> dict: - before = quota.units_used_today(db) - with quota.attribute(user.id, quota.QuotaAction.VIDEOS_ENRICH): + with quota.measured(db, user.id, quota.QuotaAction.VIDEOS_ENRICH) as m: enriched = run_enrich(db) - return { - "enriched": enriched, - "quota_used_estimate": quota.units_used_today(db) - before, - } + return {"enriched": enriched, "quota_used_estimate": m.used} @router.get("/status") @@ -194,20 +188,12 @@ def deep_all( @router.post("/pause") -def pause_sync( - user: User = Depends(current_user), db: Session = Depends(get_db) -) -> dict: - if user.role != "admin": - raise HTTPException(status_code=403, detail="Admin only") +def pause_sync(_: User = Depends(admin_user), db: Session = Depends(get_db)) -> dict: state.set_sync_paused(db, True) return {"paused": True} @router.post("/resume") -def resume_sync( - user: User = Depends(current_user), db: Session = Depends(get_db) -) -> dict: - if user.role != "admin": - raise HTTPException(status_code=403, detail="Admin only") +def resume_sync(_: User = Depends(admin_user), db: Session = Depends(get_db)) -> dict: state.set_sync_paused(db, False) return {"paused": False} diff --git a/backend/app/routes/tags.py b/backend/app/routes/tags.py index 96aac97..1ba7449 100644 --- a/backend/app/routes/tags.py +++ b/backend/app/routes/tags.py @@ -3,7 +3,7 @@ from sqlalchemy import func, or_, select from sqlalchemy.exc import IntegrityError from sqlalchemy.orm import Session -from app.auth import current_user +from app.auth import admin_user, current_user from app.db import get_db from app.models import ChannelTag, Tag, User from app.sync.autotag import run_autotag_all @@ -103,10 +103,8 @@ def delete_tag( @router.post("/recompute") def recompute( - user: User = Depends(current_user), + _: User = Depends(admin_user), db: Session = Depends(get_db), only_missing: bool = False, ) -> dict: - if user.role != "admin": - raise HTTPException(status_code=403, detail="Admin only") return run_autotag_all(db, only_missing=only_missing) diff --git a/backend/app/security.py b/backend/app/security.py index 4693582..fadbbaf 100644 --- a/backend/app/security.py +++ b/backend/app/security.py @@ -1,3 +1,5 @@ +import hashlib + from argon2 import PasswordHasher from cryptography.fernet import Fernet @@ -7,6 +9,12 @@ from app.config import settings _ph = PasswordHasher() +def hash_token(raw: str) -> str: + """SHA-256 hex digest for storing single-use tokens (email-link tokens, the setup token) + by hash rather than in cleartext. Not a password hash — these are high-entropy randoms.""" + return hashlib.sha256(raw.encode()).hexdigest() + + def hash_password(password: str) -> str: return _ph.hash(password) diff --git a/backend/app/state.py b/backend/app/state.py index c82f3dc..5466903 100644 --- a/backend/app/state.py +++ b/backend/app/state.py @@ -1,5 +1,4 @@ """Global admin-controlled app state (e.g. pausing background sync, first-run setup).""" -import hashlib import logging import secrets @@ -7,6 +6,7 @@ from sqlalchemy.orm import Session from app.config import settings from app.models import AppState +from app.security import hash_token log = logging.getLogger("siftlode.state") @@ -73,16 +73,12 @@ def mark_configured(db: Session) -> None: log.info("Instance marked configured; setup wizard disabled.") -def _hash_token(raw: str) -> str: - return hashlib.sha256(raw.encode()).hexdigest() - - def rotate_setup_token(db: Session) -> str: """Generate a fresh one-time setup token, persist only its hash, and return the raw token (logged at startup so the admin can open the wizard).""" raw = secrets.token_urlsafe(24) row = _row(db) - row.setup_token_hash = _hash_token(raw) + row.setup_token_hash = hash_token(raw) db.add(row) db.commit() return raw @@ -93,7 +89,7 @@ def check_setup_token(db: Session, raw: str | None) -> bool: if not raw: return False stored = _row(db).setup_token_hash - return stored is not None and secrets.compare_digest(stored, _hash_token(raw)) + return stored is not None and secrets.compare_digest(stored, hash_token(raw)) def get_maintenance_batch(db: Session) -> int: diff --git a/backend/app/sync/maintenance.py b/backend/app/sync/maintenance.py index 80c0129..1d35003 100644 --- a/backend/app/sync/maintenance.py +++ b/backend/app/sync/maintenance.py @@ -20,18 +20,19 @@ Quota: phase 1 costs ~(flagged / 50) units, phase 2 ~(batch / 50). Both respect backfill reserve as the other jobs so interactive syncs never starve. """ import logging -from datetime import datetime, timedelta, timezone +from datetime import timedelta -from sqlalchemy import or_, select +from sqlalchemy import select from sqlalchemy.orm import Session from app import progress, quota, sysconfig from app.config import settings -from app.models import Playlist, PlaylistItem, Video, VideoState +from app.models import Playlist, PlaylistItem, Video from app.notifications import create_notification from app.state import get_maintenance_batch from app.sync.runner import get_service_user from app.sync.videos import apply_video_details, parse_dt +from app.utils import now_utc as _now from app.youtube.client import YouTubeClient log = logging.getLogger("siftlode.maintenance") @@ -45,10 +46,6 @@ GRACE_DAYS_BY_REASON = { DEFAULT_GRACE_DAYS = settings.maintenance_grace_days -def _now() -> datetime: - return datetime.now(timezone.utc) - - def _grace_days(reason: str | None) -> int: return GRACE_DAYS_BY_REASON.get(reason or "", DEFAULT_GRACE_DAYS) @@ -91,14 +88,9 @@ def _collect_user_impact(db: Session, videos: list[Video]) -> dict[int, list[dic impact: dict[int, set[str]] = {} if not ids: return {} - # Saved (VideoState.status == "saved"). - for user_id, video_id in db.execute( - select(VideoState.user_id, VideoState.video_id).where( - VideoState.video_id.in_(ids), VideoState.status == "saved" - ) - ): - impact.setdefault(user_id, set()).add(video_id) - # In any of the user's playlists (covers watch-later, which is a playlist). + # Anyone who had it in a playlist — which now also covers "saved": Watch later became a + # built-in playlist, so "saved" is no longer a VideoState status (the old status=="saved" + # scan matched nothing and under-counted impacted users). for user_id, video_id in db.execute( select(Playlist.user_id, PlaylistItem.video_id) .join(PlaylistItem, PlaylistItem.playlist_id == Playlist.id) diff --git a/backend/app/sync/playlists.py b/backend/app/sync/playlists.py index 4dcbaac..547dc55 100644 --- a/backend/app/sync/playlists.py +++ b/backend/app/sync/playlists.py @@ -94,6 +94,34 @@ def _ensure_videos(db: Session, yt: YouTubeClient, video_ids: list[str]) -> None db.commit() +def _replace_items_from_youtube( + db: Session, yt: YouTubeClient, pl: Playlist, ids: list[str] +) -> list[str]: + """Mirror a YouTube playlist's ordered video ids into `pl`'s local items (authoritative + replace): ensure the videos exist in the catalog, keep only those present in their YT + order (de-duplicated), swap the items out, and reset the synced fingerprint + dirty flag. + `pl.name` must already be set to the desired name. The caller commits. Returns the final + ordered ids.""" + _ensure_videos(db, yt, ids) + present = ( + set(db.execute(select(Video.id).where(Video.id.in_(ids))).scalars().all()) + if ids + else set() + ) + ordered: list[str] = [] + seen: set[str] = set() + for v in ids: + if v in present and v not in seen: + seen.add(v) + ordered.append(v) + db.execute(delete(PlaylistItem).where(PlaylistItem.playlist_id == pl.id)) + for pos, vid in enumerate(ordered): + db.add(PlaylistItem(playlist_id=pl.id, video_id=vid, position=pos)) + pl.synced_fingerprint = fingerprint(pl.name, ordered) + pl.dirty = False + return ordered + + def sync_user_playlists(db: Session, user: User) -> dict: """Mirror the user's YouTube playlists into local source='youtube' playlists. Leaves the user's local playlists and the built-in Watch later untouched.""" @@ -152,25 +180,9 @@ def sync_user_playlists(db: Session, user: User) -> dict: db.flush() else: pl.name = p.get("title") or pl.name - ids = list(yt.iter_my_playlist_video_ids(ytid)) - _ensure_videos(db, yt, ids) - present = ( - set(db.execute(select(Video.id).where(Video.id.in_(ids))).scalars().all()) - if ids - else set() - ) - ordered: list[str] = [] - seen: set[str] = set() - for v in ids: - if v in present and v not in seen: - seen.add(v) - ordered.append(v) # Mirror is authoritative: replace the items to match YouTube's order. - db.execute(delete(PlaylistItem).where(PlaylistItem.playlist_id == pl.id)) - for pos, vid in enumerate(ordered): - db.add(PlaylistItem(playlist_id=pl.id, video_id=vid, position=pos)) - pl.synced_fingerprint = fingerprint(pl.name, ordered) - pl.dirty = False + ids = list(yt.iter_my_playlist_video_ids(ytid)) + _replace_items_from_youtube(db, yt, pl, ids) db.commit() synced += 1 return {"synced": synced} @@ -349,25 +361,9 @@ def repull_playlist(db: Session, user: User, pl: Playlist) -> dict: snippet = yt.get_playlist_snippet(pl.yt_playlist_id) if snippet is None: raise YouTubeError("Playlist no longer exists on YouTube") - ids = list(yt.iter_my_playlist_video_ids(pl.yt_playlist_id)) - _ensure_videos(db, yt, ids) - present = ( - set(db.execute(select(Video.id).where(Video.id.in_(ids))).scalars().all()) - if ids - else set() - ) - ordered: list[str] = [] - seen: set[str] = set() - for v in ids: - if v in present and v not in seen: - seen.add(v) - ordered.append(v) pl.name = snippet.get("title") or pl.name - db.execute(delete(PlaylistItem).where(PlaylistItem.playlist_id == pl.id)) - for pos, vid in enumerate(ordered): - db.add(PlaylistItem(playlist_id=pl.id, video_id=vid, position=pos)) - pl.synced_fingerprint = fingerprint(pl.name, ordered) - pl.dirty = False + ids = list(yt.iter_my_playlist_video_ids(pl.yt_playlist_id)) + ordered = _replace_items_from_youtube(db, yt, pl, ids) db.commit() return {"items": len(ordered)} diff --git a/backend/app/utils.py b/backend/app/utils.py new file mode 100644 index 0000000..70feb39 --- /dev/null +++ b/backend/app/utils.py @@ -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) diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 2176d6a..3e3ee64 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -19,6 +19,7 @@ import { } from "./lib/sidebarLayout"; import { configureNotifications, getNotifSettings, notify, removeByMetaKind, type NotifSettings } from "./lib/notifications"; import { hintsEnabled, setHintsEnabled } from "./lib/hints"; +import { LS, readMerged, usePersistedState } from "./lib/storage"; import { useConfirm } from "./components/ConfirmProvider"; import type { PrefsController } from "./components/SettingsPanel"; import Welcome from "./components/Welcome"; @@ -59,9 +60,9 @@ const DEFAULT_FILTERS: FeedFilters = { show: "unwatched", }; -const FILTERS_KEY = "siftlode.filters"; -const PAGE_KEY = "siftlode.page"; -const PERF_KEY = "siftlode.perfMode"; +const FILTERS_KEY = LS.filters; +const PAGE_KEY = LS.page; +const PERF_KEY = LS.perfMode; // The preferences edited on the Settings page. They apply locally for instant preview but // persist to the server only on an explicit Save — so App holds both the live "draft" (the @@ -84,11 +85,7 @@ function loadInitialPage(): Page { } function loadStoredFilters(): FeedFilters { - try { - return { ...DEFAULT_FILTERS, ...JSON.parse(localStorage.getItem(FILTERS_KEY) || "{}") }; - } catch { - return DEFAULT_FILTERS; - } + return readMerged(FILTERS_KEY, DEFAULT_FILTERS); } // URL wins over localStorage so a pasted link reproduces the exact view. @@ -126,18 +123,13 @@ export default function App() { const [sidebarLayout, setSidebarLayoutState] = useState(loadLayout); const [page, setPageState] = useState(loadInitialPage); const [wizardOpen, setWizardOpen] = useState(false); - const CHANNEL_FILTER_KEY = "siftlode.channelFilter"; - const [channelFilter, setChannelFilterState] = useState(() => { - const v = localStorage.getItem(CHANNEL_FILTER_KEY); - return v === "needs_full" || v === "fully_synced" || v === "hidden" || v === "all" - ? v - : "all"; - }); - // Persist the channel status chip so a reload (F5) keeps it. - const setChannelFilter = (f: ChannelStatusFilter) => { - setChannelFilterState(f); - localStorage.setItem(CHANNEL_FILTER_KEY, f); - }; + // Channel status chip, persisted so a reload (F5) keeps it; clamp a stale value at render. + const [channelFilterRaw, setChannelFilter] = usePersistedState(LS.channelFilter, "all"); + const channelFilter: ChannelStatusFilter = ( + ["needs_full", "fully_synced", "hidden", "all"] as const + ).includes(channelFilterRaw as ChannelStatusFilter) + ? (channelFilterRaw as ChannelStatusFilter) + : "all"; const [aboutOpen, setAboutOpen] = useState(false); const [notesOpen, setNotesOpen] = useState(false); const [notesHighlight, setNotesHighlight] = useState(undefined); @@ -145,14 +137,9 @@ export default function App() { // persisted so navigation intents that target the subscriptions table — the header's // "without full history" link, focus-channel — can force it back to "subscribed" instead // of dumping the user on whatever tab they last left open. - const CHANNELS_VIEW_KEY = "siftlode.channelsView"; - const [channelsView, setChannelsViewState] = useState(() => - localStorage.getItem(CHANNELS_VIEW_KEY) === "discovery" ? "discovery" : "subscribed" - ); - const setChannelsView = (v: ChannelsView) => { - setChannelsViewState(v); - localStorage.setItem(CHANNELS_VIEW_KEY, v); - }; + const [channelsViewRaw, setChannelsView] = usePersistedState(LS.channelsView, "subscribed"); + const channelsView: ChannelsView = + channelsViewRaw === "discovery" ? "discovery" : "subscribed"; // Bumped to tell the channel manager to drop a stale column filter when we send the user // there to see a specific set (the header's "without full history" link). const [channelsFilterReset, setChannelsFilterReset] = useState(0); @@ -318,7 +305,7 @@ export default function App() { if (result === "ok") { notify({ level: "success", message: t("settings.account.googleLink.linked") }); void meQuery.refetch(); - localStorage.setItem("siftlode.settingsTab", "account"); + localStorage.setItem(LS.settingsTab, "account"); setPage("settings"); } else { const key = result === "conflict" || result === "mismatch" ? result : "error"; diff --git a/frontend/src/components/AddToPlaylist.tsx b/frontend/src/components/AddToPlaylist.tsx index 5260163..a3b174e 100644 --- a/frontend/src/components/AddToPlaylist.tsx +++ b/frontend/src/components/AddToPlaylist.tsx @@ -4,6 +4,7 @@ import { useTranslation } from "react-i18next"; import { useQuery, useQueryClient } from "@tanstack/react-query"; import { Check, ListPlus, Plus, Youtube } from "lucide-react"; import { api, type Playlist } from "../lib/api"; +import { useDismiss } from "../lib/useDismiss"; // A small popover (portaled to body, so the feed grid can't clip it) for toggling a // video's membership in the user's playlists, with inline "new playlist" creation. @@ -59,25 +60,13 @@ export default function AddToPlaylist({ setOpen((o) => !o); } + useDismiss(open, () => setOpen(false), [panelRef, triggerRef]); + // Keep the panel anchored to the trigger as the page resizes/scrolls while open. useEffect(() => { if (!open) return; - function onDoc(e: MouseEvent) { - if ( - !panelRef.current?.contains(e.target as Node) && - !triggerRef.current?.contains(e.target as Node) - ) - setOpen(false); - } - function onKey(e: KeyboardEvent) { - if (e.key === "Escape") setOpen(false); - } - document.addEventListener("mousedown", onDoc); - document.addEventListener("keydown", onKey); window.addEventListener("resize", place); window.addEventListener("scroll", place, true); return () => { - document.removeEventListener("mousedown", onDoc); - document.removeEventListener("keydown", onKey); window.removeEventListener("resize", place); window.removeEventListener("scroll", place, true); }; diff --git a/frontend/src/components/AdminUsers.tsx b/frontend/src/components/AdminUsers.tsx index e90b8e5..356b99d 100644 --- a/frontend/src/components/AdminUsers.tsx +++ b/frontend/src/components/AdminUsers.tsx @@ -4,7 +4,9 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { Ban, Check, RotateCcw, Shield, Trash2, UserPlus, X } from "lucide-react"; import { api, type AdminUserRow, type Invite, type Me } from "../lib/api"; import { notify } from "../lib/notifications"; +import { LS } from "../lib/storage"; import Tooltip from "./Tooltip"; +import { Section } from "./ui/form"; import { useConfirm } from "./ConfirmProvider"; import Tabs, { usePersistedTab } from "./Tabs"; @@ -14,7 +16,7 @@ import Tabs, { usePersistedTab } from "./Tabs"; // The persisted-tab storage key + the access-requests tab id, exported so a notification's // "Review" link can pre-select that tab before navigating here (usePersistedTab reads the key // on mount, and this page mounts fresh on navigation). -export const ADMIN_USERS_TAB_KEY = "siftlode.adminUsersTab"; +export const ADMIN_USERS_TAB_KEY = LS.adminUsersTab; export const ADMIN_USERS_ACCESS_TAB = "access"; export function focusAccessRequestsTab(): void { @@ -40,15 +42,6 @@ export default function AdminUsers({ me }: { me: Me }) { ); } -function Section({ title, children }: { title: string; children: React.ReactNode }) { - return ( -
-
{title}
- {children} -
- ); -} - function Badge({ tone, children }: { tone: "accent" | "muted" | "warning"; children: React.ReactNode }) { const cls = tone === "accent" @@ -137,7 +130,7 @@ function UsersRoles({ me }: { me: Me }) { const rows = q.data ?? []; return ( -
+

{t("users.roles.intro")}

{rows.length === 0 ? (

{t("users.roles.empty")}

@@ -265,7 +258,7 @@ function AdminInvites() { const decided = list.filter((i) => i.status !== "pending"); return ( -
+
{pending.length === 0 ? (

{t("settings.invites.noPending")}

) : ( @@ -359,7 +352,7 @@ function AdminDemo() { const rows = list.data ?? []; return ( -
+

{t("settings.demo.intro")}

{rows.length > 0 && ( diff --git a/frontend/src/components/Channels.tsx b/frontend/src/components/Channels.tsx index ddcdfa4..21cce00 100644 --- a/frontend/src/components/Channels.tsx +++ b/frontend/src/components/Channels.tsx @@ -1,4 +1,4 @@ -import { useEffect, useRef, useState } from "react"; +import { useRef, useState } from "react"; import { Trans, useTranslation } from "react-i18next"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { @@ -14,7 +14,8 @@ import { UserMinus, } from "lucide-react"; import { api, HttpError, type ManagedChannel, type Tag } from "../lib/api"; -import { formatEta, formatViews, relativeTime } from "../lib/format"; +import { useDismiss } from "../lib/useDismiss"; +import { formatEta, formatTotalHours, formatViews, relativeTime } from "../lib/format"; import { notify } from "../lib/notifications"; import Tooltip from "./Tooltip"; import DataTable, { type Column } from "./DataTable"; @@ -27,13 +28,6 @@ export type ChannelsView = "subscribed" | "discovery"; export type ChannelStatusFilter = "all" | "needs_full" | "fully_synced" | "hidden"; -// Compact total-duration label for a whole channel (hours-scale, so H:MM:SS would be huge). -function fmtTotalDuration(sec: number): string { - if (!sec) return "—"; - const h = Math.round(sec / 3600); - return h >= 1 ? `${h.toLocaleString()} h` : `${Math.max(1, Math.round(sec / 60))} m`; -} - const STATUS_FILTERS: { id: ChannelStatusFilter; labelKey: string }[] = [ { id: "all", labelKey: "channels.filters.all" }, { id: "needs_full", labelKey: "channels.filters.needsFull" }, @@ -286,7 +280,7 @@ export default function Channels({ nowrap: true, sortable: true, sortValue: (c) => c.total_duration_seconds, - render: (c) => {fmtTotalDuration(c.total_duration_seconds)}, + render: (c) => {formatTotalHours(c.total_duration_seconds)}, }, { key: "types", @@ -616,19 +610,7 @@ function TagsCell({ const { t } = useTranslation(); const [open, setOpen] = useState(false); const ref = useRef(null); - useEffect(() => { - if (!open) return; - const onDown = (e: MouseEvent) => { - if (!ref.current?.contains(e.target as Node)) setOpen(false); - }; - const onKey = (e: KeyboardEvent) => e.key === "Escape" && setOpen(false); - document.addEventListener("mousedown", onDown); - document.addEventListener("keydown", onKey); - return () => { - document.removeEventListener("mousedown", onDown); - document.removeEventListener("keydown", onKey); - }; - }, [open]); + useDismiss(open, () => setOpen(false), ref); if (userTags.length === 0) return null; // Show only the tags actually attached to this channel; the “+” opens a per-channel // picker to attach/detach (kept the convenient "kirakás" without listing every tag diff --git a/frontend/src/components/ConfigPanel.tsx b/frontend/src/components/ConfigPanel.tsx index 1da0e7a..fad57be 100644 --- a/frontend/src/components/ConfigPanel.tsx +++ b/frontend/src/components/ConfigPanel.tsx @@ -1,11 +1,13 @@ import { useEffect, useMemo, useState } from "react"; import { useTranslation } from "react-i18next"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; -import { Check, RotateCcw, Save, Send } from "lucide-react"; +import { RotateCcw, Send } from "lucide-react"; import { api, type ConfigItem } from "../lib/api"; import { notify } from "../lib/notifications"; import Tooltip from "./Tooltip"; import Tabs, { usePersistedTab } from "./Tabs"; +import { Switch } from "./ui/form"; +import { DraftSaveBar } from "./ui/DraftSaveBar"; // Admin Configuration page: edit DB-overridable operational settings (registry-driven from the // backend — adding a key there makes it appear here automatically). Edits are drafted and @@ -150,40 +152,21 @@ export default function ConfigPanel() { )} - {(dirty || saveState !== "idle") && ( -
- - {saveState === "saved" ? ( - - - {t("config.saved")} - - ) : saveState === "error" ? ( - {t("config.saveFailed")} - ) : ( - t("config.unsaved") - )} - -
- - -
-
- )} + ); } @@ -241,7 +224,7 @@ function Field({
{isBool ? ( - onChange(v ? "true" : "false")} /> + onChange(v ? "true" : "false")} /> ) : (
); -} - -function Toggle({ checked, onChange }: { checked: boolean; onChange: (v: boolean) => void }) { - return ( - - ); -} +} \ No newline at end of file diff --git a/frontend/src/components/DataTable.tsx b/frontend/src/components/DataTable.tsx index cad982d..8b9404c 100644 --- a/frontend/src/components/DataTable.tsx +++ b/frontend/src/components/DataTable.tsx @@ -1,6 +1,7 @@ import { useEffect, useRef, useState, type ReactNode } from "react"; import { useTranslation } from "react-i18next"; import { ArrowDown, ArrowUp, ChevronLeft, ChevronRight, ListFilter, X } from "lucide-react"; +import { useDismiss } from "../lib/useDismiss"; // A reusable, client-side data table: per-column sort + filter (in the header) + pagination, // with its sort/filter/page state optionally persisted to localStorage so a reload (F5) keeps @@ -121,21 +122,7 @@ export default function DataTable({ setPage(0); }, [resetFiltersToken]); - useEffect(() => { - if (!openFilter) return; - function onDown(e: MouseEvent) { - if (!popRef.current?.contains(e.target as Node)) setOpenFilter(null); - } - function onKey(e: KeyboardEvent) { - if (e.key === "Escape") setOpenFilter(null); - } - document.addEventListener("mousedown", onDown); - document.addEventListener("keydown", onKey); - return () => { - document.removeEventListener("mousedown", onDown); - document.removeEventListener("keydown", onKey); - }; - }, [openFilter]); + useDismiss(!!openFilter, () => setOpenFilter(null), popRef); const filtered = rows.filter((row) => columns.every((col) => { diff --git a/frontend/src/components/Messages.tsx b/frontend/src/components/Messages.tsx index 0e514be..1ebdc06 100644 --- a/frontend/src/components/Messages.tsx +++ b/frontend/src/components/Messages.tsx @@ -22,7 +22,9 @@ export default function Messages({ meId }: { meId: number }) { // the module (open() pushes an entry; back() = history.back()). const { view, open, back } = useHistorySubview("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 ( ( - () => localStorage.getItem("siftlode.navCollapsed") === "1" + () => localStorage.getItem(LS.navCollapsed) === "1" ); const [acctOpen, setAcctOpen] = useState(false); const acctBtnRef = useRef(null); @@ -90,7 +91,7 @@ export default function NavSidebar({ function toggleCollapsed() { setCollapsed((c) => { const next = !c; - localStorage.setItem("siftlode.navCollapsed", next ? "1" : "0"); + localStorage.setItem(LS.navCollapsed, next ? "1" : "0"); return next; }); } @@ -111,6 +112,14 @@ export default function NavSidebar({ async function switchTo(id: number) { try { await api.switchAccount(id); + // Drop the previous account's in-module sub-view/overlay before the reload (which would + // otherwise preserve history.state across the swap). Otherwise the new account lands on a + // stale sub-view — e.g. an open chat thread whose partnerId belongs to the OLD identity + // (often the new account's own id → an empty self-thread). Start at the module root instead. + const st = { ...(window.history.state || {}) }; + delete st._sub; + delete st._ov; + window.history.replaceState(st, ""); location.reload(); // cleanest way to reload all per-user state for the new account } catch { /* a revoked/removed account — leave the popover open */ diff --git a/frontend/src/components/NotificationsPanel.tsx b/frontend/src/components/NotificationsPanel.tsx index 044074d..8844da3 100644 --- a/frontend/src/components/NotificationsPanel.tsx +++ b/frontend/src/components/NotificationsPanel.tsx @@ -19,7 +19,7 @@ import { type VideoHiddenMeta, type VideoWatchedMeta, } from "../lib/notifications"; -import { channelYouTubeUrl } from "../lib/format"; +import { channelYouTubeUrl, relativeFromMs, relativeTime } from "../lib/format"; import type { Page } from "../lib/urlState"; import { LEVEL_STYLE } from "./Toaster"; @@ -29,22 +29,6 @@ import { LEVEL_STYLE } from "./Toaster"; // there's one indicator and one place to look. Transient toasts still pop via the Toaster. const POLL_MS = 8000; -function relativeTime(iso: string | null, t: (k: string, o?: any) => string): string { - if (!iso) return ""; - return relativeFromMs(new Date(iso).getTime(), t); -} - -function relativeFromMs(ms: number, t: (k: string, o?: any) => string): string { - const secs = Math.max(0, Math.round((Date.now() - ms) / 1000)); - // Reuse the bell's relative-time strings (notifications.time.*) — same format, no dupes. - if (secs < 60) return t("notifications.time.justNow"); - const mins = Math.round(secs / 60); - if (mins < 60) return t("notifications.time.minutes", { count: mins }); - const hours = Math.round(mins / 60); - if (hours < 24) return t("notifications.time.hours", { count: hours }); - return t("notifications.time.days", { count: Math.round(hours / 24) }); -} - export default function NotificationsPanel({ filters, setFilters, @@ -253,7 +237,7 @@ function NotificationRow({
{title} - {relativeTime(n.created_at, t)} + {relativeTime(n.created_at)}
{body &&
{body}
} @@ -338,7 +322,7 @@ function ClientActivityRow({ {n.title &&
{n.title}
}
{n.message}
- {relativeFromMs(n.ts, t)} + {relativeFromMs(n.ts)} {needsAction && ( {t("notifications.actionNeeded")} diff --git a/frontend/src/components/PlayerModal.tsx b/frontend/src/components/PlayerModal.tsx index fcbc7bb..88a1483 100644 --- a/frontend/src/components/PlayerModal.tsx +++ b/frontend/src/components/PlayerModal.tsx @@ -1,6 +1,5 @@ -import { useEffect, useRef, useState, type ReactNode } from "react"; +import { useEffect, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; -import type { TFunction } from "i18next"; import { createPortal } from "react-dom"; import { useQuery, useQueryClient } from "@tanstack/react-query"; import { AlertTriangle, ArrowLeft, Check, CheckCheck, ExternalLink, SkipBack, SkipForward, X } from "lucide-react"; @@ -8,152 +7,9 @@ import Avatar from "./Avatar"; import AddToPlaylist from "./AddToPlaylist"; import { api, type Video } from "../lib/api"; import { channelYouTubeUrl, formatDuration, formatViews, relativeTime } from "../lib/format"; +import { renderDescription } from "../lib/descriptionLinks"; import { useBackToClose } from "../lib/history"; -// Turn a description into clickable nodes: -// - bare timestamps (mm:ss / hh:mm:ss) → seek the inline player -// - a YouTube link to the *current* video → seek (to its t= if any) -// - a YouTube link to *another* video → load it in the inline player -// - emails → mailto:, hashtags → YouTube's hashtag page, other URLs → new tab -const URL_RE = /(https?:\/\/[^\s<>]+)/g; -// One pass over plain text for the three inline token kinds (email | hashtag | timestamp). -const INLINE_RE = - /([A-Za-z0-9._%+-]+@[A-Za-z0-9-]+\.[A-Za-z0-9.-]+)|(#[\p{L}\p{N}_]+)|(\b(?:\d{1,2}:)?\d{1,2}:\d{2}\b)/gu; - -function tsToSeconds(ts: string): number { - const parts = ts.split(":").map((n) => parseInt(n, 10)); - return parts.length === 3 - ? parts[0] * 3600 + parts[1] * 60 + parts[2] - : parts[0] * 60 + parts[1]; -} - -// Parse a YouTube `t`/`start` param: "90", "90s", or "1h2m3s". -function parseStart(t: string | null): number | null { - if (!t) return null; - if (/^\d+$/.test(t)) return parseInt(t, 10) || null; - const m = t.match(/^(?:(\d+)h)?(?:(\d+)m)?(?:(\d+)s)?$/); - if (!m) return null; - const total = - parseInt(m[1] || "0", 10) * 3600 + - parseInt(m[2] || "0", 10) * 60 + - parseInt(m[3] || "0", 10); - return total || null; -} - -// Extract a video id (+ optional start) from a YouTube URL, else null. -function parseYouTube(url: string): { id: string; start: number | null } | null { - let u: URL; - try { - u = new URL(url); - } catch { - return null; - } - const host = u.hostname.replace(/^www\./, ""); - let id: string | null = null; - if (host === "youtu.be") { - id = u.pathname.slice(1) || null; - } else if (host === "youtube.com" || host === "m.youtube.com" || host === "music.youtube.com") { - if (u.pathname === "/watch") id = u.searchParams.get("v"); - else { - const m = u.pathname.match(/^\/(?:shorts|embed|live)\/([^/?#]+)/); - if (m) id = m[1]; - } - } - if (!id) return null; - return { id, start: parseStart(u.searchParams.get("t") || u.searchParams.get("start")) }; -} - -function renderDescription( - text: string, - opts: { - currentId: string; - onSeek: (seconds: number) => void; - onLoadVideo: (id: string, start: number | null) => void; - t: TFunction; - } -): ReactNode[] { - const { t } = opts; - const out: ReactNode[] = []; - let key = 0; - const linkCls = "text-accent hover:underline break-all"; - // Tidy YouTube descriptions: drop trailing spaces and remove blank lines entirely - // (they're just noise in the popover), keeping single line breaks. - const clean = text.replace(/[ \t]+\n/g, "\n").replace(/\n{2,}/g, "\n").trim(); - for (const chunk of clean.split(URL_RE)) { - if (/^https?:\/\//.test(chunk)) { - const href = chunk.replace(/[.,;:!?)\]]+$/, ""); - const yt = parseYouTube(href); - if (yt) { - const sameVideo = yt.id === opts.currentId; - out.push( - - ); - } else { - out.push( - - {href} - - ); - } - 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({chunk.slice(last, m.index)}); - if (m[1]) { - // Email — trim trailing punctuation the domain rule may have swallowed. - const email = m[1].replace(/[.,;:]+$/, ""); - out.push( - - {email} - - ); - if (m[1].length > email.length) out.push({m[1].slice(email.length)}); - } else if (m[2]) { - // Hashtag → YouTube's hashtag feed (mirrors native YouTube behavior). - const tag = m[2].slice(1); - out.push( - - {m[2]} - - ); - } else { - const ts = m[3]; - out.push( - - ); - } - last = m.index + m[0].length; - } - if (last < chunk.length) out.push({chunk.slice(last)}); - } - return out; -} - // Experiment (branch experiment/inline-player): play the video in-app via the // YouTube IFrame Player API (not a bare embed) so we can read playback position // and resume where the user left off. The modal closes via the in-card Close diff --git a/frontend/src/components/Playlists.tsx b/frontend/src/components/Playlists.tsx index 8c49f64..dbdba96 100644 --- a/frontend/src/components/Playlists.tsx +++ b/frontend/src/components/Playlists.tsx @@ -37,6 +37,7 @@ import { import { api, type Playlist, type Video } from "../lib/api"; import { formatDuration } from "../lib/format"; import { notify } from "../lib/notifications"; +import { LS, readMerged, writeJSON } from "../lib/storage"; import { useUndoable } from "../lib/useUndoable"; import PlayerModal from "./PlayerModal"; import UndoToolbar from "./UndoToolbar"; @@ -187,26 +188,18 @@ export default function Playlists({ canWrite }: { canWrite: boolean }) { const confirm = useConfirm(); // Persist the selected playlist so F5 keeps it (the de-URL refactor dropped it from the URL). const [selectedId, setSelectedId] = useState(() => { - const s = localStorage.getItem("siftlode.playlist"); + const s = localStorage.getItem(LS.playlist); return s ? Number(s) : null; }); useEffect(() => { - if (selectedId != null) localStorage.setItem("siftlode.playlist", String(selectedId)); + if (selectedId != null) localStorage.setItem(LS.playlist, String(selectedId)); }, [selectedId]); const selectedRef = useRef(null); const scrolledRef = useRef(false); // How the left playlist rail is ordered (persisted). - const [plSort, setPlSort] = useState(() => { - try { - const s = localStorage.getItem("siftlode.plSort"); - if (s) return { ...PL_SORT_DEFAULT, ...JSON.parse(s) }; - } catch { - /* ignore */ - } - return PL_SORT_DEFAULT; - }); + const [plSort, setPlSort] = useState(() => readMerged(LS.plSort, PL_SORT_DEFAULT)); useEffect(() => { - localStorage.setItem("siftlode.plSort", JSON.stringify(plSort)); + writeJSON(LS.plSort, plSort); }, [plSort]); const [newName, setNewName] = useState(""); const [renaming, setRenaming] = useState(false); diff --git a/frontend/src/components/Scheduler.tsx b/frontend/src/components/Scheduler.tsx index 5a2556d..394434a 100644 --- a/frontend/src/components/Scheduler.tsx +++ b/frontend/src/components/Scheduler.tsx @@ -1,5 +1,6 @@ import { useEffect, useState } from "react"; import { useTranslation } from "react-i18next"; +import i18n from "../i18n"; import { useMutation, useQueryClient } from "@tanstack/react-query"; import { Activity, @@ -31,7 +32,7 @@ function secsUntil(iso: string | null): number | null { } function fmtCountdown(s: number): string { - if (s <= 0) return "now"; + if (s <= 0) return i18n.t("time.eta.now"); if (s < 60) return `${s}s`; const m = Math.floor(s / 60); if (m < 60) return `${m}m ${s % 60}s`; diff --git a/frontend/src/components/SettingsPanel.tsx b/frontend/src/components/SettingsPanel.tsx index 156225f..4d13622 100644 --- a/frontend/src/components/SettingsPanel.tsx +++ b/frontend/src/components/SettingsPanel.tsx @@ -1,12 +1,15 @@ import { useState } from "react"; import { useTranslation } from "react-i18next"; import { useQueryClient } from "@tanstack/react-query"; -import { Bell, Check, Monitor, RotateCcw, Save, Trash2, User } from "lucide-react"; +import { Bell, Monitor, Trash2, User } from "lucide-react"; import { SCHEMES, type Scheme, type ThemePrefs } from "../lib/theme"; import { api, type Me } from "../lib/api"; +import { LS, usePersistedState } from "../lib/storage"; import Avatar from "./Avatar"; import { notify, type NotifSettings } from "../lib/notifications"; import Tooltip from "./Tooltip"; +import { Section, SettingRow, Switch } from "./ui/form"; +import { DraftSaveBar } from "./ui/DraftSaveBar"; import { useConfirm } from "./ConfirmProvider"; // The Settings page edits server-persisted preferences as a draft: changes apply locally @@ -38,14 +41,6 @@ const TABS: { id: TabId; icon: typeof Monitor }[] = [ { id: "account", icon: User }, ]; -// Persist the active tab so a reload (F5) keeps the user where they were instead of -// snapping back to "appearance". -const SETTINGS_TAB_KEY = "siftlode.settingsTab"; -function loadSettingsTab(): TabId { - const v = localStorage.getItem(SETTINGS_TAB_KEY); - return TABS.some((tabItem) => tabItem.id === v) ? (v as TabId) : "appearance"; -} - // Settings as a page (Design B): rendered in the main content area, not an overlay. It keeps // the frosted `.glass` surface so it still refracts the ambient backdrop. The page title is // shown by the Header; the tab rail stays as in-page sub-navigation. @@ -59,11 +54,11 @@ export default function SettingsPanel({ onOpenWizard: () => void; }) { const { t } = useTranslation(); - const [tab, setTabState] = useState(loadSettingsTab); - const setTab = (id: TabId) => { - setTabState(id); - localStorage.setItem(SETTINGS_TAB_KEY, id); - }; + const [tabRaw, setTab] = usePersistedState(LS.settingsTab, "appearance"); + // Clamp at render so a stale/removed tab id falls back to Appearance. + const tab: TabId = TABS.some((tabItem) => tabItem.id === tabRaw) + ? (tabRaw as TabId) + : "appearance"; return (
@@ -110,87 +105,22 @@ export default function SettingsPanel({ function PrefsSaveBar({ prefs }: { prefs: PrefsController }) { const { t } = useTranslation(); - // Stay mounted while a transient "saved"/"error" message is fading, even after dirty clears. - if (!prefs.dirty && prefs.saveState === "idle") return null; - const saving = prefs.saveState === "saving"; return ( -
- - {prefs.saveState === "saved" - ? {t("settings.save.saved")} - : prefs.saveState === "error" - ? {t("settings.save.failed")} - : t("settings.save.unsaved")} - -
- - -
-
- ); -} - -function Section({ title, children }: { title: string; children: React.ReactNode }) { - return ( -
-
{title}
- {children} -
- ); -} - -function Row({ - label, - hint, - children, -}: { - label: string; - hint?: string; - children: React.ReactNode; -}) { - return ( -
- - - {label} - - - {children} -
- ); -} - -function Switch({ checked, onChange }: { checked: boolean; onChange: (v: boolean) => void }) { - return ( - + ); } @@ -219,27 +149,27 @@ function Appearance({ prefs }: { prefs: PrefsController }) {
- + setTheme({ ...theme, mode: v ? "dark" : "light" })} /> - - + + setView(v ? "list" : "grid")} /> - - + - - + - +
@@ -268,18 +198,18 @@ function Notifications({ prefs }: { prefs: PrefsController }) { return (
- update({ sound: v })} /> - - + update({ durationMs: v ? 6000 : 0 })} /> - + {autoOn && (
diff --git a/frontend/src/components/Sidebar.tsx b/frontend/src/components/Sidebar.tsx index df82d27..ecf64f0 100644 --- a/frontend/src/components/Sidebar.tsx +++ b/frontend/src/components/Sidebar.tsx @@ -36,6 +36,7 @@ import { } from "../lib/sidebarLayout"; import { shareUrl } from "../lib/urlState"; import { notify } from "../lib/notifications"; +import { Switch } from "./ui/form"; import TagManager from "./TagManager"; // Filter ids; display labels resolved at render time via t("sidebar.sort.") etc. @@ -622,19 +623,7 @@ function Toggle({ return ( ); } diff --git a/frontend/src/components/Stats.tsx b/frontend/src/components/Stats.tsx index 97a6109..f1b6db6 100644 --- a/frontend/src/components/Stats.tsx +++ b/frontend/src/components/Stats.tsx @@ -5,10 +5,11 @@ import { History, Pause, Play, RefreshCw } from "lucide-react"; import { api, type AdminQuotaRow, type Me } from "../lib/api"; import { formatEta, quotaActionLabel } from "../lib/format"; import { notify } from "../lib/notifications"; +import { LS, usePersistedState } from "../lib/storage"; import Tooltip from "./Tooltip"; +import { Section, SettingRow } from "./ui/form"; const RANGES = [7, 30, 90] as const; -const STATS_TAB_KEY = "siftlode.statsTab"; type StatsTab = "overview" | "system"; // Usage & stats page. "Overview" is per-user (sync status + your own API usage + actions); @@ -17,14 +18,9 @@ type StatsTab = "overview" | "system"; export default function Stats({ me }: { me: Me }) { const { t } = useTranslation(); const isAdmin = me.role === "admin"; - const [tab, setTabState] = useState(() => { - const v = localStorage.getItem(STATS_TAB_KEY); - return v === "system" && isAdmin ? "system" : "overview"; - }); - const setTab = (id: StatsTab) => { - setTabState(id); - localStorage.setItem(STATS_TAB_KEY, id); - }; + const [tabRaw, setTab] = usePersistedState(LS.statsTab, "overview"); + // Clamp at render: only admins may land on the System tab (covers a stale stored value). + const tab: StatsTab = tabRaw === "system" && isAdmin ? "system" : "overview"; return (
@@ -52,31 +48,6 @@ export default function Stats({ me }: { me: Me }) { ); } -function Section({ title, children }: { title: string; children: React.ReactNode }) { - return ( -
-
{title}
- {children} -
- ); -} - -function Row({ label, hint, children }: { label: string; hint?: string; children: React.ReactNode }) { - return ( -
- - - {label} - - - {children} -
- ); -} // Per-user view: sync status + your own API usage + (for real accounts) the manual sync actions. function Overview({ me }: { me: Me }) { @@ -112,29 +83,29 @@ function Overview({ me }: { me: Me }) {
{s ? (
- + {s.channels_total} - - + + {`${s.channels_recent_synced}/${s.channels_total}`} - - + + {`${s.channels_deep_done}/${s.channels_deep_requested}`} - + {s.deep_pending_count > 0 && ( - {formatEta(s.deep_eta_seconds)} - + )} - + {s.my_videos.toLocaleString()} - - + + {s.quota_remaining_today.toLocaleString()} - +
) : (
{t("stats.sync.loading")}
@@ -145,12 +116,12 @@ function Overview({ me }: { me: Me }) { {usage.data ? ( <>
- + {usage.data.today.toLocaleString()} - - {usage.data.last_7d.toLocaleString()} - {usage.data.last_30d.toLocaleString()} - {usage.data.all_time.toLocaleString()} + + {usage.data.last_7d.toLocaleString()} + {usage.data.last_30d.toLocaleString()} + {usage.data.all_time.toLocaleString()}
{Object.keys(usage.data.by_action).length > 0 && (
diff --git a/frontend/src/components/Tabs.tsx b/frontend/src/components/Tabs.tsx index 7871c3d..42b1387 100644 --- a/frontend/src/components/Tabs.tsx +++ b/frontend/src/components/Tabs.tsx @@ -1,4 +1,4 @@ -import { useState } from "react"; +import { usePersistedState } from "../lib/storage"; // Reusable horizontal pill tab-rail for in-page sub-navigation (Configuration, Users & roles…). // The active tab is persisted to localStorage so a reload (F5) keeps the user where they were, @@ -10,16 +10,9 @@ export interface TabDef { badge?: number; // optional count pill (e.g. pending requests); hidden when 0/undefined } -/** Persisted active-tab state. Validation is deferred to the caller (it clamps to a valid id at - * render) so this can be called before an async-loaded tab list is known, keeping hook order stable. */ -export function usePersistedTab(storageKey: string, fallback = ""): [string, (id: string) => void] { - const [tab, setTabState] = useState(() => localStorage.getItem(storageKey) ?? fallback); - const setTab = (id: string) => { - setTabState(id); - localStorage.setItem(storageKey, id); - }; - return [tab, setTab]; -} +/** Persisted active-tab state — the canonical helper now lives in lib/storage as + * `usePersistedState`; re-exported here under its original name for existing call sites. */ +export const usePersistedTab = usePersistedState; export default function Tabs({ tabs, diff --git a/frontend/src/components/ui/DraftSaveBar.tsx b/frontend/src/components/ui/DraftSaveBar.tsx new file mode 100644 index 0000000..5e629af --- /dev/null +++ b/frontend/src/components/ui/DraftSaveBar.tsx @@ -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 ( +
+ + {state === "saved" ? ( + + + {labels.saved} + + ) : state === "error" ? ( + {labels.failed} + ) : ( + labels.unsaved + )} + +
+ + +
+
+ ); +} diff --git a/frontend/src/components/ui/form.tsx b/frontend/src/components/ui/form.tsx new file mode 100644 index 0000000..c99fcd3 --- /dev/null +++ b/frontend/src/components/ui/form.tsx @@ -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 ( + + ); +} + +/** 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 ( +
+
+ {title} +
+ {children} +
+ ); +} + +/** 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 ( + + + {children} + + + ); +} + +/** 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 ( +
+ {label} + {children} +
+ ); +} diff --git a/frontend/src/i18n/index.ts b/frontend/src/i18n/index.ts index 50397b6..6cb3abc 100644 --- a/frontend/src/i18n/index.ts +++ b/frontend/src/i18n/index.ts @@ -1,5 +1,6 @@ import i18n from "i18next"; import { initReactI18next } from "react-i18next"; +import { LS } from "../lib/storage"; // Endonyms (shown as-is regardless of the active language — the convention for language pickers). export const LANGUAGES = [ @@ -9,7 +10,7 @@ export const LANGUAGES = [ ] as const; export type LangCode = (typeof LANGUAGES)[number]["code"]; const SUPPORTED = LANGUAGES.map((l) => l.code) as readonly string[]; -export const LANG_KEY = "siftlode.lang"; +export const LANG_KEY = LS.lang; // Auto-load every locale file (locales//.json) and merge each under one // `translation` namespace, so components use t("area.key") and adding an area needs no wiring. diff --git a/frontend/src/i18n/locales/de/notifications.json b/frontend/src/i18n/locales/de/notifications.json index ec7f745..5c7fdb3 100644 --- a/frontend/src/i18n/locales/de/notifications.json +++ b/frontend/src/i18n/locales/de/notifications.json @@ -11,11 +11,5 @@ "openInManager": "Kanalverwaltung", "openOnYouTube": "Auf YouTube öffnen", "unhidden": "Eingeblendet „{{title}}”", - "unwatched": "Als nicht angesehen markiert „{{title}}”", - "time": { - "justNow": "gerade eben", - "minutes": "vor {{count}} Min.", - "hours": "vor {{count}} Std.", - "days": "vor {{count}} T." - } + "unwatched": "Als nicht angesehen markiert „{{title}}”" } diff --git a/frontend/src/i18n/locales/de/time.json b/frontend/src/i18n/locales/de/time.json index 1f1424b..80a00f7 100644 --- a/frontend/src/i18n/locales/de/time.json +++ b/frontend/src/i18n/locales/de/time.json @@ -6,5 +6,14 @@ "daysAgo": "vor {{count}} T.", "weeksAgo": "vor {{count}} Wo.", "monthsAgo": "vor {{count}} Mon.", - "yearsAgo": "vor {{count}} J." + "yearsAgo": "vor {{count}} J.", + "eta": { + "now": "jetzt", + "done": "fertig", + "lessThanHour": "< 1 Stunde", + "hours_one": "~{{count}} Stunde", + "hours_other": "~{{count}} Stunden", + "days_one": "~{{count}} Tag", + "days_other": "~{{count}} Tage" + } } diff --git a/frontend/src/i18n/locales/en/notifications.json b/frontend/src/i18n/locales/en/notifications.json index 08a23dd..6a59040 100644 --- a/frontend/src/i18n/locales/en/notifications.json +++ b/frontend/src/i18n/locales/en/notifications.json @@ -11,11 +11,5 @@ "openInManager": "Channel manager", "openOnYouTube": "Open on YouTube", "unhidden": "Unhidden “{{title}}”", - "unwatched": "Unwatched “{{title}}”", - "time": { - "justNow": "just now", - "minutes": "{{count}}m ago", - "hours": "{{count}}h ago", - "days": "{{count}}d ago" - } + "unwatched": "Unwatched “{{title}}”" } diff --git a/frontend/src/i18n/locales/en/time.json b/frontend/src/i18n/locales/en/time.json index c08ee22..f6d16ee 100644 --- a/frontend/src/i18n/locales/en/time.json +++ b/frontend/src/i18n/locales/en/time.json @@ -6,5 +6,14 @@ "daysAgo": "{{count}}d ago", "weeksAgo": "{{count}} wk ago", "monthsAgo": "{{count}} mo ago", - "yearsAgo": "{{count}} yr ago" + "yearsAgo": "{{count}} yr ago", + "eta": { + "now": "now", + "done": "done", + "lessThanHour": "< 1 hour", + "hours_one": "~{{count}} hour", + "hours_other": "~{{count}} hours", + "days_one": "~{{count}} day", + "days_other": "~{{count}} days" + } } diff --git a/frontend/src/i18n/locales/hu/notifications.json b/frontend/src/i18n/locales/hu/notifications.json index e305c4b..5c37dbf 100644 --- a/frontend/src/i18n/locales/hu/notifications.json +++ b/frontend/src/i18n/locales/hu/notifications.json @@ -11,11 +11,5 @@ "openInManager": "Csatornakezelő", "openOnYouTube": "Megnyitás a YouTube-on", "unhidden": "Megjelenítve: „{{title}}”", - "unwatched": "Megtekintés visszavonva: „{{title}}”", - "time": { - "justNow": "az imént", - "minutes": "{{count}} perce", - "hours": "{{count}} órája", - "days": "{{count}} napja" - } + "unwatched": "Megtekintés visszavonva: „{{title}}”" } diff --git a/frontend/src/i18n/locales/hu/time.json b/frontend/src/i18n/locales/hu/time.json index f3413b9..71ce423 100644 --- a/frontend/src/i18n/locales/hu/time.json +++ b/frontend/src/i18n/locales/hu/time.json @@ -6,5 +6,14 @@ "daysAgo": "{{count}} napja", "weeksAgo": "{{count}} hete", "monthsAgo": "{{count}} hónapja", - "yearsAgo": "{{count}} éve" + "yearsAgo": "{{count}} éve", + "eta": { + "now": "most", + "done": "kész", + "lessThanHour": "< 1 óra", + "hours_one": "~{{count}} óra", + "hours_other": "~{{count}} óra", + "days_one": "~{{count}} nap", + "days_other": "~{{count}} nap" + } } diff --git a/frontend/src/lib/descriptionLinks.tsx b/frontend/src/lib/descriptionLinks.tsx new file mode 100644 index 0000000..dfc25b4 --- /dev/null +++ b/frontend/src/lib/descriptionLinks.tsx @@ -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( + + ); + } else { + out.push( + + {href} + + ); + } + 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({chunk.slice(last, m.index)}); + if (m[1]) { + // Email — trim trailing punctuation the domain rule may have swallowed. + const email = m[1].replace(/[.,;:]+$/, ""); + out.push( + + {email} + + ); + if (m[1].length > email.length) out.push({m[1].slice(email.length)}); + } else if (m[2]) { + // Hashtag → YouTube's hashtag feed (mirrors native YouTube behavior). + const tag = m[2].slice(1); + out.push( + + {m[2]} + + ); + } else { + const ts = m[3]; + out.push( + + ); + } + last = m.index + m[0].length; + } + if (last < chunk.length) out.push({chunk.slice(last)}); + } + return out; +} diff --git a/frontend/src/lib/errorDialog.ts b/frontend/src/lib/errorDialog.ts index 63c3c66..70f5a4c 100644 --- a/frontend/src/lib/errorDialog.ts +++ b/frontend/src/lib/errorDialog.ts @@ -1,4 +1,5 @@ import i18n from "../i18n"; +import { createStore } from "./store"; // A single, self-explanatory error dialog (modal) the user must acknowledge — used when the // server can't carry out an action (a definitive 4xx/5xx response). Distinct from toasts @@ -9,30 +10,18 @@ export interface AppError { message: string; } -let current: AppError | null = null; -let listeners: Array<() => void> = []; -const emit = () => listeners.forEach((l) => l()); +const store = createStore(null); export function reportError(message?: string, title?: string): void { - current = { + store.set({ title: title || i18n.t("errors.title"), message: message || i18n.t("errors.generic"), - }; - emit(); + }); } export function dismissError(): void { - current = null; - emit(); + store.set(null); } -export function subscribeError(listener: () => void): () => void { - listeners.push(listener); - return () => { - listeners = listeners.filter((l) => l !== listener); - }; -} - -export function getError(): AppError | null { - return current; -} +export const subscribeError = store.subscribe; +export const getError = store.get; diff --git a/frontend/src/lib/format.ts b/frontend/src/lib/format.ts index d7ebe17..e0bb0d4 100644 --- a/frontend/src/lib/format.ts +++ b/frontend/src/lib/format.ts @@ -2,8 +2,14 @@ import i18n from "../i18n"; export function relativeTime(iso: string | null): string { if (!iso) return ""; - const then = new Date(iso).getTime(); - const secs = Math.max(0, (Date.now() - then) / 1000); + return relativeFromMs(new Date(iso).getTime()); +} + +/** Relative-time label from an epoch-millis timestamp (the ms-based half of relativeTime, for + * client-side notifications whose timestamps are already numbers). One implementation, one + * set of `time.*` strings — shared instead of re-implemented per component. */ +export function relativeFromMs(ms: number): string { + const secs = Math.max(0, (Date.now() - ms) / 1000); const units: [number, string][] = [ [60, "time.secondsAgo"], [3600, "time.minutesAgo"], @@ -51,17 +57,21 @@ export function quotaActionLabel(action: string): string { return i18n.t(`quotaActions.${action}`, { defaultValue: action }); } -/** Coarse, human ETA for a remaining duration in seconds ("~3 hours", "~2 days"). */ +/** Coarse, human ETA for a remaining duration in seconds ("~3 hours", "~2 days"). Translated + * via the `time.eta.*` keys (was hardcoded English, violating the trilingual rule). */ export function formatEta(seconds: number): string { - if (seconds <= 0) return "done"; + if (seconds <= 0) return i18n.t("time.eta.done"); const hours = seconds / 3600; - if (hours < 1) return "< 1 hour"; - if (hours < 24) { - const h = Math.round(hours); - return `~${h} hour${h === 1 ? "" : "s"}`; - } - const d = Math.round(hours / 24); - return `~${d} day${d === 1 ? "" : "s"}`; + if (hours < 1) return i18n.t("time.eta.lessThanHour"); + if (hours < 24) return i18n.t("time.eta.hours", { count: Math.round(hours) }); + return i18n.t("time.eta.days", { count: Math.round(hours / 24) }); +} + +/** Compact whole-channel total length ("12 h", "45 m"), hours-scale so H:MM:SS isn't huge. */ +export function formatTotalHours(sec: number): string { + if (!sec) return "—"; + const h = Math.round(sec / 3600); + return h >= 1 ? `${h.toLocaleString()} h` : `${Math.max(1, Math.round(sec / 60))} m`; } export function formatViews(n: number | null): string { diff --git a/frontend/src/lib/hints.ts b/frontend/src/lib/hints.ts index cb3b274..b4ecaae 100644 --- a/frontend/src/lib/hints.ts +++ b/frontend/src/lib/hints.ts @@ -1,36 +1,27 @@ // App-wide "hints" toggle: when on, Tooltip components reveal short explanatory // captions on hover so the UI is somewhat self-documenting for new users. Experienced // users can turn it off in Settings. Persisted to localStorage (+ server preferences). -const KEY = "siftlode.hints"; - -let enabled = load(); -let listeners: Array<() => void> = []; +import { createStore } from "./store"; +import { LS } from "./storage"; function load(): boolean { try { - return localStorage.getItem(KEY) !== "0"; // default ON + return localStorage.getItem(LS.hints) !== "0"; // default ON } catch { return true; } } -export function hintsEnabled(): boolean { - return enabled; -} +const store = createStore(load()); + +export const hintsEnabled = store.get; +export const subscribeHints = store.subscribe; export function setHintsEnabled(v: boolean): void { - enabled = v; + store.set(v); try { - localStorage.setItem(KEY, v ? "1" : "0"); + localStorage.setItem(LS.hints, v ? "1" : "0"); } catch { /* ignore */ } - listeners.forEach((l) => l()); -} - -export function subscribeHints(listener: () => void): () => void { - listeners.push(listener); - return () => { - listeners = listeners.filter((l) => l !== listener); - }; } diff --git a/frontend/src/lib/notifications.ts b/frontend/src/lib/notifications.ts index 9e6ff0c..80aa84b 100644 --- a/frontend/src/lib/notifications.ts +++ b/frontend/src/lib/notifications.ts @@ -2,6 +2,7 @@ // the Notification Center shows (info events, actions awaiting interaction, and app // errors). History is persisted to localStorage so it survives reloads; action // callbacks are live-only and dropped on reload. +import { LS, readJSON, readMerged, writeJSON } from "./storage"; export type NotifLevel = "info" | "success" | "warning" | "error" | "fatal"; @@ -71,8 +72,8 @@ export interface NotifyInput { transient?: boolean; // live status: sticky toast, not persisted (see Notification.transient) } -const HISTORY_KEY = "siftlode.notifications"; -const SETTINGS_KEY = "siftlode.notifSettings"; +const HISTORY_KEY = LS.notifHistory; +const SETTINGS_KEY = LS.notifSettings; const MAX_HISTORY = 100; const ERROR_TTL = 15000; @@ -86,11 +87,7 @@ const DEFAULT_SETTINGS: NotifSettings = { sound: false, durationMs: 6000 }; let config: NotifSettings = loadSettings(); function loadSettings(): NotifSettings { - try { - return { ...DEFAULT_SETTINGS, ...JSON.parse(localStorage.getItem(SETTINGS_KEY) || "{}") }; - } catch { - return DEFAULT_SETTINGS; - } + return readMerged(SETTINGS_KEY, DEFAULT_SETTINGS); } export function getNotifSettings(): NotifSettings { @@ -99,11 +96,7 @@ export function getNotifSettings(): NotifSettings { export function configureNotifications(p: Partial): void { config = { ...config, ...p }; - try { - localStorage.setItem(SETTINGS_KEY, JSON.stringify(config)); - } catch { - /* ignore */ - } + writeJSON(SETTINGS_KEY, config); } let audioCtx: AudioContext | null = null; @@ -137,7 +130,7 @@ let cachedUnread = 0; function load(): Notification[] { try { - const raw = JSON.parse(localStorage.getItem(HISTORY_KEY) || "[]"); + const raw = readJSON(HISTORY_KEY, []); if (!Array.isArray(raw)) return []; // Restored entries are history only. Mark them dismissed so they don't // resurrect as active toasts on reload — their auto-dismiss timers aren't @@ -154,7 +147,7 @@ function persist() { // Transient status notices are live-session only — never write them to history, // so a reload can't leave one stranded with no producer left to clear it. const slim = items.filter((n) => !n.transient).map(({ action: _action, ...n }) => n); - localStorage.setItem(HISTORY_KEY, JSON.stringify(slim)); + writeJSON(HISTORY_KEY, slim); } catch { /* ignore quota / serialization errors */ } diff --git a/frontend/src/lib/onboarding.ts b/frontend/src/lib/onboarding.ts index 496e915..26822c3 100644 --- a/frontend/src/lib/onboarding.ts +++ b/frontend/src/lib/onboarding.ts @@ -12,8 +12,10 @@ // suppresses the auto-popup on future logins (they // can still reopen it from Settings → Account). -export const ONBOARD_ACTIVE = "siftlode.onboarding.active"; -export const ONBOARD_DISMISSED = "siftlode.onboarding.dismissed"; +import { LS } from "./storage"; + +export const ONBOARD_ACTIVE = "siftlode.onboarding.active"; // sessionStorage (not in LS, which is localStorage) +export const ONBOARD_DISMISSED = LS.onboardingDismissed; export function shouldAutoOpenOnboarding(me: { can_read: boolean; diff --git a/frontend/src/lib/releaseNotes.ts b/frontend/src/lib/releaseNotes.ts index 2809643..fe84933 100644 --- a/frontend/src/lib/releaseNotes.ts +++ b/frontend/src/lib/releaseNotes.ts @@ -14,6 +14,19 @@ export interface ReleaseEntry { } export const RELEASE_NOTES: ReleaseEntry[] = [ + { + version: "0.17.0", + date: "2026-06-29", + summary: "Messaging and notification fixes, plus a large under-the-hood cleanup.", + fixes: [ + "Switching between signed-in accounts no longer drops you on a blank chat: after you switch, a message you just received shows up in the right conversation instead of an empty screen.", + "You're now reliably notified when a video you'd saved or added to a playlist is removed because it's no longer available on YouTube — this notice could previously be missed.", + "“Time remaining” estimates (e.g. on sync progress) and a few small labels are now translated in Hungarian and German like the rest of the app.", + ], + chores: [ + "A large internal code cleanup: removed duplicated logic across the backend and frontend and factored out shared building blocks (form controls, the save/discard bar, popovers, browser-storage helpers, time/date formatters). No change to how the app behaves — groundwork that keeps future features consistent and quicker to build.", + ], + }, { version: "0.16.2", date: "2026-06-26", diff --git a/frontend/src/lib/sidebarLayout.ts b/frontend/src/lib/sidebarLayout.ts index fe98f9c..0b7508f 100644 --- a/frontend/src/lib/sidebarLayout.ts +++ b/frontend/src/lib/sidebarLayout.ts @@ -4,6 +4,8 @@ // `show`, `sort` and `content` moved to the feed toolbar (above the cards); they are no // longer sidebar widgets. normalizeLayout drops them from any persisted layout automatically. +import { LS, readJSON, writeJSON } from "./storage"; + export type WidgetId = "date" | "language" | "topic" | "tags"; export const ALL_WIDGETS: WidgetId[] = ["date", "language", "topic", "tags"]; @@ -27,8 +29,6 @@ export const DEFAULT_LAYOUT: SidebarLayout = { hidden: {}, }; -const KEY = "siftlode.sidebarLayout"; - // Tolerate stale/partial data: keep only known widgets and append any that are missing // (e.g. a widget added in a later version) so nothing silently disappears. export function normalizeLayout(raw: unknown): SidebarLayout { @@ -45,13 +45,9 @@ export function normalizeLayout(raw: unknown): SidebarLayout { } export function loadLayout(): SidebarLayout { - try { - return normalizeLayout(JSON.parse(localStorage.getItem(KEY) || "{}")); - } catch { - return DEFAULT_LAYOUT; - } + return normalizeLayout(readJSON(LS.sidebarLayout, {})); } export function saveLayoutLocal(l: SidebarLayout): void { - localStorage.setItem(KEY, JSON.stringify(l)); + writeJSON(LS.sidebarLayout, l); } diff --git a/frontend/src/lib/storage.ts b/frontend/src/lib/storage.ts new file mode 100644 index 0000000..80648d4 --- /dev/null +++ b/frontend/src/lib/storage.ts @@ -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(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(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(() => localStorage.getItem(key) ?? fallback); + const set = (next: string) => { + setValue(next); + try { + localStorage.setItem(key, next); + } catch { + /* ignore */ + } + }; + return [value, set]; +} diff --git a/frontend/src/lib/store.ts b/frontend/src/lib/store.ts new file mode 100644 index 0000000..8f68e9a --- /dev/null +++ b/frontend/src/lib/store.ts @@ -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 { + 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(initial: T): Store { + 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), + }; +} diff --git a/frontend/src/lib/theme.ts b/frontend/src/lib/theme.ts index ccfdfca..e841652 100644 --- a/frontend/src/lib/theme.ts +++ b/frontend/src/lib/theme.ts @@ -1,3 +1,5 @@ +import { LS, readMerged, writeJSON } from "./storage"; + export type Mode = "dark" | "light"; export type Scheme = "midnight" | "forest" | "slate" | "youtube"; @@ -27,16 +29,10 @@ export function applyTheme(t: ThemePrefs): void { el.style.setProperty("--font-scale", String(t.fontScale)); } -const KEY = "siftlode.theme"; - export function loadLocalTheme(): ThemePrefs { - try { - return { ...DEFAULT_THEME, ...JSON.parse(localStorage.getItem(KEY) || "{}") }; - } catch { - return DEFAULT_THEME; - } + return readMerged(LS.theme, DEFAULT_THEME); } export function saveLocalTheme(t: ThemePrefs): void { - localStorage.setItem(KEY, JSON.stringify(t)); + writeJSON(LS.theme, t); } diff --git a/frontend/src/lib/useDismiss.ts b/frontend/src/lib/useDismiss.ts new file mode 100644 index 0000000..58c7fbe --- /dev/null +++ b/frontend/src/lib/useDismiss.ts @@ -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 | RefObject[], +): 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]); +} diff --git a/frontend/src/lib/version.ts b/frontend/src/lib/version.ts index caa07a4..e7ee557 100644 --- a/frontend/src/lib/version.ts +++ b/frontend/src/lib/version.ts @@ -1,8 +1,10 @@ // Build info baked into the SPA at build time (Vite inlines VITE_*-prefixed env). // Injected via Docker build-args (see Dockerfile); falls back to "dev" for un-stamped builds. +import { LS } from "./storage"; + export const FRONTEND_VERSION = import.meta.env.VITE_APP_VERSION || "dev"; export const FRONTEND_SHA = import.meta.env.VITE_GIT_SHA || "unknown"; export const FRONTEND_BUILD_DATE = import.meta.env.VITE_BUILD_DATE || ""; // Key for the "last version the user has seen release notes for" (drives the banner). -export const SEEN_VERSION_KEY = "siftlode.seenVersion"; +export const SEEN_VERSION_KEY = LS.seenVersion;