Merge: promote dev to prod
This commit is contained in:
commit
f1c98dd708
54 changed files with 972 additions and 894 deletions
2
VERSION
2
VERSION
|
|
@ -1 +1 @@
|
|||
0.16.2
|
||||
0.17.0
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
||||
|
|
|
|||
|
|
@ -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,9 +166,7 @@ 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:
|
||||
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
|
||||
|
|
@ -201,13 +197,12 @@ 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:
|
||||
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
|
||||
|
|
@ -238,11 +233,7 @@ 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:
|
||||
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)
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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,9 +112,7 @@ 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:
|
||||
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.",
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -94,6 +94,34 @@ def _ensure_videos(db: Session, yt: YouTubeClient, video_ids: list[str]) -> None
|
|||
db.commit()
|
||||
|
||||
|
||||
def _replace_items_from_youtube(
|
||||
db: Session, yt: YouTubeClient, pl: Playlist, ids: list[str]
|
||||
) -> list[str]:
|
||||
"""Mirror a YouTube playlist's ordered video ids into `pl`'s local items (authoritative
|
||||
replace): ensure the videos exist in the catalog, keep only those present in their YT
|
||||
order (de-duplicated), swap the items out, and reset the synced fingerprint + dirty flag.
|
||||
`pl.name` must already be set to the desired name. The caller commits. Returns the final
|
||||
ordered ids."""
|
||||
_ensure_videos(db, yt, ids)
|
||||
present = (
|
||||
set(db.execute(select(Video.id).where(Video.id.in_(ids))).scalars().all())
|
||||
if ids
|
||||
else set()
|
||||
)
|
||||
ordered: list[str] = []
|
||||
seen: set[str] = set()
|
||||
for v in ids:
|
||||
if v in present and v not in seen:
|
||||
seen.add(v)
|
||||
ordered.append(v)
|
||||
db.execute(delete(PlaylistItem).where(PlaylistItem.playlist_id == pl.id))
|
||||
for pos, vid in enumerate(ordered):
|
||||
db.add(PlaylistItem(playlist_id=pl.id, video_id=vid, position=pos))
|
||||
pl.synced_fingerprint = fingerprint(pl.name, ordered)
|
||||
pl.dirty = False
|
||||
return ordered
|
||||
|
||||
|
||||
def sync_user_playlists(db: Session, user: User) -> dict:
|
||||
"""Mirror the user's YouTube playlists into local source='youtube' playlists. Leaves
|
||||
the user's local playlists and the built-in Watch later untouched."""
|
||||
|
|
@ -152,25 +180,9 @@ def sync_user_playlists(db: Session, user: User) -> dict:
|
|||
db.flush()
|
||||
else:
|
||||
pl.name = p.get("title") or pl.name
|
||||
ids = list(yt.iter_my_playlist_video_ids(ytid))
|
||||
_ensure_videos(db, yt, ids)
|
||||
present = (
|
||||
set(db.execute(select(Video.id).where(Video.id.in_(ids))).scalars().all())
|
||||
if ids
|
||||
else set()
|
||||
)
|
||||
ordered: list[str] = []
|
||||
seen: set[str] = set()
|
||||
for v in ids:
|
||||
if v in present and v not in seen:
|
||||
seen.add(v)
|
||||
ordered.append(v)
|
||||
# Mirror is authoritative: replace the items to match YouTube's order.
|
||||
db.execute(delete(PlaylistItem).where(PlaylistItem.playlist_id == pl.id))
|
||||
for pos, vid in enumerate(ordered):
|
||||
db.add(PlaylistItem(playlist_id=pl.id, video_id=vid, position=pos))
|
||||
pl.synced_fingerprint = fingerprint(pl.name, ordered)
|
||||
pl.dirty = False
|
||||
ids = list(yt.iter_my_playlist_video_ids(ytid))
|
||||
_replace_items_from_youtube(db, yt, pl, ids)
|
||||
db.commit()
|
||||
synced += 1
|
||||
return {"synced": synced}
|
||||
|
|
@ -349,25 +361,9 @@ def repull_playlist(db: Session, user: User, pl: Playlist) -> dict:
|
|||
snippet = yt.get_playlist_snippet(pl.yt_playlist_id)
|
||||
if snippet is None:
|
||||
raise YouTubeError("Playlist no longer exists on YouTube")
|
||||
ids = list(yt.iter_my_playlist_video_ids(pl.yt_playlist_id))
|
||||
_ensure_videos(db, yt, ids)
|
||||
present = (
|
||||
set(db.execute(select(Video.id).where(Video.id.in_(ids))).scalars().all())
|
||||
if ids
|
||||
else set()
|
||||
)
|
||||
ordered: list[str] = []
|
||||
seen: set[str] = set()
|
||||
for v in ids:
|
||||
if v in present and v not in seen:
|
||||
seen.add(v)
|
||||
ordered.append(v)
|
||||
pl.name = snippet.get("title") or pl.name
|
||||
db.execute(delete(PlaylistItem).where(PlaylistItem.playlist_id == pl.id))
|
||||
for pos, vid in enumerate(ordered):
|
||||
db.add(PlaylistItem(playlist_id=pl.id, video_id=vid, position=pos))
|
||||
pl.synced_fingerprint = fingerprint(pl.name, ordered)
|
||||
pl.dirty = False
|
||||
ids = list(yt.iter_my_playlist_video_ids(pl.yt_playlist_id))
|
||||
ordered = _replace_items_from_youtube(db, yt, pl, ids)
|
||||
db.commit()
|
||||
return {"items": len(ordered)}
|
||||
|
||||
|
|
|
|||
17
backend/app/utils.py
Normal file
17
backend/app/utils.py
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
"""Small cross-cutting helpers shared across modules (kept dependency-light on purpose)."""
|
||||
import re
|
||||
from datetime import datetime, timezone
|
||||
|
||||
# Single source of truth for "looks like an email" — used by every endpoint that accepts an
|
||||
# address (auth register/reset/demo, admin invites + demo whitelist, the setup wizard). Keep
|
||||
# the strictness in one place so routes don't drift between this and a looser "@"-in check.
|
||||
_EMAIL_RE = re.compile(r"^[^@\s]+@[^@\s]+\.[^@\s]+$")
|
||||
|
||||
|
||||
def valid_email(value: str | None) -> bool:
|
||||
return bool(value and _EMAIL_RE.match(value))
|
||||
|
||||
|
||||
def now_utc() -> datetime:
|
||||
"""Current time as a timezone-aware UTC datetime."""
|
||||
return datetime.now(timezone.utc)
|
||||
|
|
@ -19,6 +19,7 @@ import {
|
|||
} from "./lib/sidebarLayout";
|
||||
import { configureNotifications, getNotifSettings, notify, removeByMetaKind, type NotifSettings } from "./lib/notifications";
|
||||
import { hintsEnabled, setHintsEnabled } from "./lib/hints";
|
||||
import { LS, readMerged, usePersistedState } from "./lib/storage";
|
||||
import { useConfirm } from "./components/ConfirmProvider";
|
||||
import type { PrefsController } from "./components/SettingsPanel";
|
||||
import Welcome from "./components/Welcome";
|
||||
|
|
@ -59,9 +60,9 @@ const DEFAULT_FILTERS: FeedFilters = {
|
|||
show: "unwatched",
|
||||
};
|
||||
|
||||
const FILTERS_KEY = "siftlode.filters";
|
||||
const PAGE_KEY = "siftlode.page";
|
||||
const PERF_KEY = "siftlode.perfMode";
|
||||
const FILTERS_KEY = LS.filters;
|
||||
const PAGE_KEY = LS.page;
|
||||
const PERF_KEY = LS.perfMode;
|
||||
|
||||
// The preferences edited on the Settings page. They apply locally for instant preview but
|
||||
// persist to the server only on an explicit Save — so App holds both the live "draft" (the
|
||||
|
|
@ -84,11 +85,7 @@ function loadInitialPage(): Page {
|
|||
}
|
||||
|
||||
function loadStoredFilters(): FeedFilters {
|
||||
try {
|
||||
return { ...DEFAULT_FILTERS, ...JSON.parse(localStorage.getItem(FILTERS_KEY) || "{}") };
|
||||
} catch {
|
||||
return DEFAULT_FILTERS;
|
||||
}
|
||||
return readMerged(FILTERS_KEY, DEFAULT_FILTERS);
|
||||
}
|
||||
|
||||
// URL wins over localStorage so a pasted link reproduces the exact view.
|
||||
|
|
@ -126,18 +123,13 @@ export default function App() {
|
|||
const [sidebarLayout, setSidebarLayoutState] = useState<SidebarLayout>(loadLayout);
|
||||
const [page, setPageState] = useState<Page>(loadInitialPage);
|
||||
const [wizardOpen, setWizardOpen] = useState(false);
|
||||
const CHANNEL_FILTER_KEY = "siftlode.channelFilter";
|
||||
const [channelFilter, setChannelFilterState] = useState<ChannelStatusFilter>(() => {
|
||||
const v = localStorage.getItem(CHANNEL_FILTER_KEY);
|
||||
return v === "needs_full" || v === "fully_synced" || v === "hidden" || v === "all"
|
||||
? v
|
||||
// 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";
|
||||
});
|
||||
// Persist the channel status chip so a reload (F5) keeps it.
|
||||
const setChannelFilter = (f: ChannelStatusFilter) => {
|
||||
setChannelFilterState(f);
|
||||
localStorage.setItem(CHANNEL_FILTER_KEY, f);
|
||||
};
|
||||
const [aboutOpen, setAboutOpen] = useState(false);
|
||||
const [notesOpen, setNotesOpen] = useState(false);
|
||||
const [notesHighlight, setNotesHighlight] = useState<string | undefined>(undefined);
|
||||
|
|
@ -145,14 +137,9 @@ export default function App() {
|
|||
// persisted so navigation intents that target the subscriptions table — the header's
|
||||
// "without full history" link, focus-channel — can force it back to "subscribed" instead
|
||||
// of dumping the user on whatever tab they last left open.
|
||||
const CHANNELS_VIEW_KEY = "siftlode.channelsView";
|
||||
const [channelsView, setChannelsViewState] = useState<ChannelsView>(() =>
|
||||
localStorage.getItem(CHANNELS_VIEW_KEY) === "discovery" ? "discovery" : "subscribed"
|
||||
);
|
||||
const setChannelsView = (v: ChannelsView) => {
|
||||
setChannelsViewState(v);
|
||||
localStorage.setItem(CHANNELS_VIEW_KEY, v);
|
||||
};
|
||||
const [channelsViewRaw, setChannelsView] = usePersistedState(LS.channelsView, "subscribed");
|
||||
const channelsView: ChannelsView =
|
||||
channelsViewRaw === "discovery" ? "discovery" : "subscribed";
|
||||
// Bumped to tell the channel manager to drop a stale column filter when we send the user
|
||||
// there to see a specific set (the header's "without full history" link).
|
||||
const [channelsFilterReset, setChannelsFilterReset] = useState(0);
|
||||
|
|
@ -318,7 +305,7 @@ export default function App() {
|
|||
if (result === "ok") {
|
||||
notify({ level: "success", message: t("settings.account.googleLink.linked") });
|
||||
void meQuery.refetch();
|
||||
localStorage.setItem("siftlode.settingsTab", "account");
|
||||
localStorage.setItem(LS.settingsTab, "account");
|
||||
setPage("settings");
|
||||
} else {
|
||||
const key = result === "conflict" || result === "mismatch" ? result : "error";
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -4,7 +4,9 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
|||
import { Ban, Check, RotateCcw, Shield, Trash2, UserPlus, X } from "lucide-react";
|
||||
import { api, type AdminUserRow, type Invite, type Me } from "../lib/api";
|
||||
import { notify } from "../lib/notifications";
|
||||
import { LS } from "../lib/storage";
|
||||
import Tooltip from "./Tooltip";
|
||||
import { Section } from "./ui/form";
|
||||
import { useConfirm } from "./ConfirmProvider";
|
||||
import Tabs, { usePersistedTab } from "./Tabs";
|
||||
|
||||
|
|
@ -14,7 +16,7 @@ import Tabs, { usePersistedTab } from "./Tabs";
|
|||
// The persisted-tab storage key + the access-requests tab id, exported so a notification's
|
||||
// "Review" link can pre-select that tab before navigating here (usePersistedTab reads the key
|
||||
// on mount, and this page mounts fresh on navigation).
|
||||
export const ADMIN_USERS_TAB_KEY = "siftlode.adminUsersTab";
|
||||
export const ADMIN_USERS_TAB_KEY = LS.adminUsersTab;
|
||||
export const ADMIN_USERS_ACCESS_TAB = "access";
|
||||
|
||||
export function focusAccessRequestsTab(): void {
|
||||
|
|
@ -40,15 +42,6 @@ export default function AdminUsers({ me }: { me: Me }) {
|
|||
);
|
||||
}
|
||||
|
||||
function Section({ title, children }: { title: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="glass rounded-2xl p-4 mb-4">
|
||||
<div className="text-xs uppercase tracking-wide text-muted mb-3">{title}</div>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Badge({ tone, children }: { tone: "accent" | "muted" | "warning"; children: React.ReactNode }) {
|
||||
const cls =
|
||||
tone === "accent"
|
||||
|
|
@ -137,7 +130,7 @@ function UsersRoles({ me }: { me: Me }) {
|
|||
const rows = q.data ?? [];
|
||||
|
||||
return (
|
||||
<Section title={t("users.roles.title")}>
|
||||
<Section card title={t("users.roles.title")}>
|
||||
<p className="text-xs text-muted leading-relaxed mb-3">{t("users.roles.intro")}</p>
|
||||
{rows.length === 0 ? (
|
||||
<p className="text-sm text-muted">{t("users.roles.empty")}</p>
|
||||
|
|
@ -265,7 +258,7 @@ function AdminInvites() {
|
|||
const decided = list.filter((i) => i.status !== "pending");
|
||||
|
||||
return (
|
||||
<Section title={t("settings.invites.title")}>
|
||||
<Section card title={t("settings.invites.title")}>
|
||||
{pending.length === 0 ? (
|
||||
<p className="text-sm text-muted">{t("settings.invites.noPending")}</p>
|
||||
) : (
|
||||
|
|
@ -359,7 +352,7 @@ function AdminDemo() {
|
|||
const rows = list.data ?? [];
|
||||
|
||||
return (
|
||||
<Section title={t("settings.demo.title")}>
|
||||
<Section card title={t("settings.demo.title")}>
|
||||
<p className="text-xs text-muted leading-relaxed mb-2">{t("settings.demo.intro")}</p>
|
||||
|
||||
{rows.length > 0 && (
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { useEffect, useRef, useState } from "react";
|
||||
import { useRef, useState } from "react";
|
||||
import { Trans, useTranslation } from "react-i18next";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
|
|
@ -14,7 +14,8 @@ import {
|
|||
UserMinus,
|
||||
} from "lucide-react";
|
||||
import { api, HttpError, type ManagedChannel, type Tag } from "../lib/api";
|
||||
import { formatEta, formatViews, relativeTime } from "../lib/format";
|
||||
import { useDismiss } from "../lib/useDismiss";
|
||||
import { formatEta, formatTotalHours, formatViews, relativeTime } from "../lib/format";
|
||||
import { notify } from "../lib/notifications";
|
||||
import Tooltip from "./Tooltip";
|
||||
import DataTable, { type Column } from "./DataTable";
|
||||
|
|
@ -27,13 +28,6 @@ export type ChannelsView = "subscribed" | "discovery";
|
|||
|
||||
export type ChannelStatusFilter = "all" | "needs_full" | "fully_synced" | "hidden";
|
||||
|
||||
// Compact total-duration label for a whole channel (hours-scale, so H:MM:SS would be huge).
|
||||
function fmtTotalDuration(sec: number): string {
|
||||
if (!sec) return "—";
|
||||
const h = Math.round(sec / 3600);
|
||||
return h >= 1 ? `${h.toLocaleString()} h` : `${Math.max(1, Math.round(sec / 60))} m`;
|
||||
}
|
||||
|
||||
const STATUS_FILTERS: { id: ChannelStatusFilter; labelKey: string }[] = [
|
||||
{ id: "all", labelKey: "channels.filters.all" },
|
||||
{ id: "needs_full", labelKey: "channels.filters.needsFull" },
|
||||
|
|
@ -286,7 +280,7 @@ export default function Channels({
|
|||
nowrap: true,
|
||||
sortable: true,
|
||||
sortValue: (c) => c.total_duration_seconds,
|
||||
render: (c) => <span className="text-muted tabular-nums">{fmtTotalDuration(c.total_duration_seconds)}</span>,
|
||||
render: (c) => <span className="text-muted tabular-nums">{formatTotalHours(c.total_duration_seconds)}</span>,
|
||||
},
|
||||
{
|
||||
key: "types",
|
||||
|
|
@ -616,19 +610,7 @@ function TagsCell({
|
|||
const { t } = useTranslation();
|
||||
const [open, setOpen] = useState(false);
|
||||
const ref = useRef<HTMLDivElement | null>(null);
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const onDown = (e: MouseEvent) => {
|
||||
if (!ref.current?.contains(e.target as Node)) setOpen(false);
|
||||
};
|
||||
const onKey = (e: KeyboardEvent) => e.key === "Escape" && setOpen(false);
|
||||
document.addEventListener("mousedown", onDown);
|
||||
document.addEventListener("keydown", onKey);
|
||||
return () => {
|
||||
document.removeEventListener("mousedown", onDown);
|
||||
document.removeEventListener("keydown", onKey);
|
||||
};
|
||||
}, [open]);
|
||||
useDismiss(open, () => setOpen(false), ref);
|
||||
if (userTags.length === 0) return null;
|
||||
// Show only the tags actually attached to this channel; the “+” opens a per-channel
|
||||
// picker to attach/detach (kept the convenient "kirakás" without listing every tag
|
||||
|
|
|
|||
|
|
@ -1,11 +1,13 @@
|
|||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { Check, RotateCcw, Save, Send } from "lucide-react";
|
||||
import { RotateCcw, Send } from "lucide-react";
|
||||
import { api, type ConfigItem } from "../lib/api";
|
||||
import { notify } from "../lib/notifications";
|
||||
import Tooltip from "./Tooltip";
|
||||
import Tabs, { usePersistedTab } from "./Tabs";
|
||||
import { Switch } from "./ui/form";
|
||||
import { DraftSaveBar } from "./ui/DraftSaveBar";
|
||||
|
||||
// Admin Configuration page: edit DB-overridable operational settings (registry-driven from the
|
||||
// backend — adding a key there makes it appear here automatically). Edits are drafted and
|
||||
|
|
@ -150,40 +152,21 @@ export default function ConfigPanel() {
|
|||
</div>
|
||||
)}
|
||||
|
||||
{(dirty || saveState !== "idle") && (
|
||||
<div className="fixed bottom-4 left-1/2 -translate-x-1/2 z-40 glass rounded-2xl shadow-lg flex items-center justify-between gap-4 px-4 py-3 w-[min(48rem,calc(100%-2rem))]">
|
||||
<span className="text-sm text-muted">
|
||||
{saveState === "saved" ? (
|
||||
<span className="text-emerald-500 inline-flex items-center gap-1.5">
|
||||
<Check className="w-4 h-4" />
|
||||
{t("config.saved")}
|
||||
</span>
|
||||
) : saveState === "error" ? (
|
||||
<span className="text-red-500">{t("config.saveFailed")}</span>
|
||||
) : (
|
||||
t("config.unsaved")
|
||||
)}
|
||||
</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={discard}
|
||||
disabled={saveState === "saving" || !dirty}
|
||||
className="glass-card glass-hover flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-sm disabled:opacity-40 transition"
|
||||
>
|
||||
<RotateCcw className="w-4 h-4" />
|
||||
{t("config.discard")}
|
||||
</button>
|
||||
<button
|
||||
onClick={save}
|
||||
disabled={saveState === "saving" || !dirty}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-sm bg-accent text-accent-fg font-medium shadow-md shadow-accent/20 disabled:opacity-40 transition"
|
||||
>
|
||||
<Save className="w-4 h-4" />
|
||||
{saveState === "saving" ? t("config.saving") : t("config.save")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<DraftSaveBar
|
||||
variant="floating"
|
||||
dirty={dirty}
|
||||
state={saveState}
|
||||
onSave={save}
|
||||
onDiscard={discard}
|
||||
labels={{
|
||||
saved: t("config.saved"),
|
||||
failed: t("config.saveFailed"),
|
||||
unsaved: t("config.unsaved"),
|
||||
discard: t("config.discard"),
|
||||
save: t("config.save"),
|
||||
saving: t("config.saving"),
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -241,7 +224,7 @@ function Field({
|
|||
|
||||
<div className="flex flex-col items-end gap-1.5 shrink-0">
|
||||
{isBool ? (
|
||||
<Toggle checked={value === "true"} onChange={(v) => onChange(v ? "true" : "false")} />
|
||||
<Switch checked={value === "true"} onChange={(v) => onChange(v ? "true" : "false")} />
|
||||
) : (
|
||||
<input
|
||||
type={item.secret ? "password" : isNum ? "number" : "text"}
|
||||
|
|
@ -273,19 +256,3 @@ function Field({
|
|||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Toggle({ checked, onChange }: { checked: boolean; onChange: (v: boolean) => void }) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onChange(!checked)}
|
||||
className={`w-9 h-5 rounded-full transition relative shrink-0 ${checked ? "bg-accent" : "bg-border"}`}
|
||||
>
|
||||
<span
|
||||
className={`absolute top-0.5 w-4 h-4 rounded-full bg-white shadow transition-all ${
|
||||
checked ? "left-[18px]" : "left-0.5"
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { useEffect, useRef, useState, type ReactNode } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ArrowDown, ArrowUp, ChevronLeft, ChevronRight, ListFilter, X } from "lucide-react";
|
||||
import { useDismiss } from "../lib/useDismiss";
|
||||
|
||||
// A reusable, client-side data table: per-column sort + filter (in the header) + pagination,
|
||||
// with its sort/filter/page state optionally persisted to localStorage so a reload (F5) keeps
|
||||
|
|
@ -121,21 +122,7 @@ export default function DataTable<T>({
|
|||
setPage(0);
|
||||
}, [resetFiltersToken]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!openFilter) return;
|
||||
function onDown(e: MouseEvent) {
|
||||
if (!popRef.current?.contains(e.target as Node)) setOpenFilter(null);
|
||||
}
|
||||
function onKey(e: KeyboardEvent) {
|
||||
if (e.key === "Escape") setOpenFilter(null);
|
||||
}
|
||||
document.addEventListener("mousedown", onDown);
|
||||
document.addEventListener("keydown", onKey);
|
||||
return () => {
|
||||
document.removeEventListener("mousedown", onDown);
|
||||
document.removeEventListener("keydown", onKey);
|
||||
};
|
||||
}, [openFilter]);
|
||||
useDismiss(!!openFilter, () => setOpenFilter(null), popRef);
|
||||
|
||||
const filtered = rows.filter((row) =>
|
||||
columns.every((col) => {
|
||||
|
|
|
|||
|
|
@ -22,7 +22,9 @@ export default function Messages({ meId }: { meId: number }) {
|
|||
// the module (open() pushes an entry; back() = history.back()).
|
||||
const { view, open, back } = useHistorySubview<View>("list");
|
||||
|
||||
if (typeof view === "object") {
|
||||
// Guard against a stale sub-view whose partnerId is the current user's own id (you can't
|
||||
// message yourself). This can be inherited across an account switch — fall back to the list.
|
||||
if (typeof view === "object" && view.partnerId !== meId) {
|
||||
return (
|
||||
<PageThread
|
||||
meId={meId}
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ import {
|
|||
import { api, type Me } from "../lib/api";
|
||||
import { useLiveQuery } from "../lib/useLiveQuery";
|
||||
import { getUnreadCount, subscribe } from "../lib/notifications";
|
||||
import { LS } from "../lib/storage";
|
||||
import type { Page } from "../lib/urlState";
|
||||
import { type LangCode } from "../i18n";
|
||||
import AvatarImg from "./Avatar";
|
||||
|
|
@ -48,7 +49,7 @@ export default function NavSidebar({
|
|||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [collapsed, setCollapsed] = useState<boolean>(
|
||||
() => localStorage.getItem("siftlode.navCollapsed") === "1"
|
||||
() => localStorage.getItem(LS.navCollapsed) === "1"
|
||||
);
|
||||
const [acctOpen, setAcctOpen] = useState(false);
|
||||
const acctBtnRef = useRef<HTMLButtonElement | null>(null);
|
||||
|
|
@ -90,7 +91,7 @@ export default function NavSidebar({
|
|||
function toggleCollapsed() {
|
||||
setCollapsed((c) => {
|
||||
const next = !c;
|
||||
localStorage.setItem("siftlode.navCollapsed", next ? "1" : "0");
|
||||
localStorage.setItem(LS.navCollapsed, next ? "1" : "0");
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
|
@ -111,6 +112,14 @@ export default function NavSidebar({
|
|||
async function switchTo(id: number) {
|
||||
try {
|
||||
await api.switchAccount(id);
|
||||
// Drop the previous account's in-module sub-view/overlay before the reload (which would
|
||||
// otherwise preserve history.state across the swap). Otherwise the new account lands on a
|
||||
// stale sub-view — e.g. an open chat thread whose partnerId belongs to the OLD identity
|
||||
// (often the new account's own id → an empty self-thread). Start at the module root instead.
|
||||
const st = { ...(window.history.state || {}) };
|
||||
delete st._sub;
|
||||
delete st._ov;
|
||||
window.history.replaceState(st, "");
|
||||
location.reload(); // cleanest way to reload all per-user state for the new account
|
||||
} catch {
|
||||
/* a revoked/removed account — leave the popover open */
|
||||
|
|
|
|||
|
|
@ -19,7 +19,7 @@ import {
|
|||
type VideoHiddenMeta,
|
||||
type VideoWatchedMeta,
|
||||
} from "../lib/notifications";
|
||||
import { channelYouTubeUrl } from "../lib/format";
|
||||
import { channelYouTubeUrl, relativeFromMs, relativeTime } from "../lib/format";
|
||||
import type { Page } from "../lib/urlState";
|
||||
import { LEVEL_STYLE } from "./Toaster";
|
||||
|
||||
|
|
@ -29,22 +29,6 @@ import { LEVEL_STYLE } from "./Toaster";
|
|||
// there's one indicator and one place to look. Transient toasts still pop via the Toaster.
|
||||
const POLL_MS = 8000;
|
||||
|
||||
function relativeTime(iso: string | null, t: (k: string, o?: any) => string): string {
|
||||
if (!iso) return "";
|
||||
return relativeFromMs(new Date(iso).getTime(), t);
|
||||
}
|
||||
|
||||
function relativeFromMs(ms: number, t: (k: string, o?: any) => string): string {
|
||||
const secs = Math.max(0, Math.round((Date.now() - ms) / 1000));
|
||||
// Reuse the bell's relative-time strings (notifications.time.*) — same format, no dupes.
|
||||
if (secs < 60) return t("notifications.time.justNow");
|
||||
const mins = Math.round(secs / 60);
|
||||
if (mins < 60) return t("notifications.time.minutes", { count: mins });
|
||||
const hours = Math.round(mins / 60);
|
||||
if (hours < 24) return t("notifications.time.hours", { count: hours });
|
||||
return t("notifications.time.days", { count: Math.round(hours / 24) });
|
||||
}
|
||||
|
||||
export default function NotificationsPanel({
|
||||
filters,
|
||||
setFilters,
|
||||
|
|
@ -253,7 +237,7 @@ function NotificationRow({
|
|||
<div className="flex items-baseline gap-2">
|
||||
<span className="font-medium text-sm">{title}</span>
|
||||
<span className="text-[11px] text-muted shrink-0">
|
||||
{relativeTime(n.created_at, t)}
|
||||
{relativeTime(n.created_at)}
|
||||
</span>
|
||||
</div>
|
||||
{body && <div className="text-sm text-muted mt-0.5">{body}</div>}
|
||||
|
|
@ -338,7 +322,7 @@ function ClientActivityRow({
|
|||
{n.title && <div className="text-sm font-semibold">{n.title}</div>}
|
||||
<div className="text-sm break-words">{n.message}</div>
|
||||
<div className="flex items-center gap-2 mt-0.5">
|
||||
<span className="text-[11px] text-muted">{relativeFromMs(n.ts, t)}</span>
|
||||
<span className="text-[11px] text-muted">{relativeFromMs(n.ts)}</span>
|
||||
{needsAction && (
|
||||
<span className="text-[10px] uppercase tracking-wide font-semibold text-accent">
|
||||
{t("notifications.actionNeeded")}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
import { useEffect, useRef, useState, type ReactNode } from "react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { TFunction } from "i18next";
|
||||
import { createPortal } from "react-dom";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { AlertTriangle, ArrowLeft, Check, CheckCheck, ExternalLink, SkipBack, SkipForward, X } from "lucide-react";
|
||||
|
|
@ -8,152 +7,9 @@ import Avatar from "./Avatar";
|
|||
import AddToPlaylist from "./AddToPlaylist";
|
||||
import { api, type Video } from "../lib/api";
|
||||
import { channelYouTubeUrl, formatDuration, formatViews, relativeTime } from "../lib/format";
|
||||
import { renderDescription } from "../lib/descriptionLinks";
|
||||
import { useBackToClose } from "../lib/history";
|
||||
|
||||
// Turn a description into clickable nodes:
|
||||
// - bare timestamps (mm:ss / hh:mm:ss) → seek the inline player
|
||||
// - a YouTube link to the *current* video → seek (to its t= if any)
|
||||
// - a YouTube link to *another* video → load it in the inline player
|
||||
// - emails → mailto:, hashtags → YouTube's hashtag page, other URLs → new tab
|
||||
const URL_RE = /(https?:\/\/[^\s<>]+)/g;
|
||||
// One pass over plain text for the three inline token kinds (email | hashtag | timestamp).
|
||||
const INLINE_RE =
|
||||
/([A-Za-z0-9._%+-]+@[A-Za-z0-9-]+\.[A-Za-z0-9.-]+)|(#[\p{L}\p{N}_]+)|(\b(?:\d{1,2}:)?\d{1,2}:\d{2}\b)/gu;
|
||||
|
||||
function tsToSeconds(ts: string): number {
|
||||
const parts = ts.split(":").map((n) => parseInt(n, 10));
|
||||
return parts.length === 3
|
||||
? parts[0] * 3600 + parts[1] * 60 + parts[2]
|
||||
: parts[0] * 60 + parts[1];
|
||||
}
|
||||
|
||||
// Parse a YouTube `t`/`start` param: "90", "90s", or "1h2m3s".
|
||||
function parseStart(t: string | null): number | null {
|
||||
if (!t) return null;
|
||||
if (/^\d+$/.test(t)) return parseInt(t, 10) || null;
|
||||
const m = t.match(/^(?:(\d+)h)?(?:(\d+)m)?(?:(\d+)s)?$/);
|
||||
if (!m) return null;
|
||||
const total =
|
||||
parseInt(m[1] || "0", 10) * 3600 +
|
||||
parseInt(m[2] || "0", 10) * 60 +
|
||||
parseInt(m[3] || "0", 10);
|
||||
return total || null;
|
||||
}
|
||||
|
||||
// Extract a video id (+ optional start) from a YouTube URL, else null.
|
||||
function parseYouTube(url: string): { id: string; start: number | null } | null {
|
||||
let u: URL;
|
||||
try {
|
||||
u = new URL(url);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
const host = u.hostname.replace(/^www\./, "");
|
||||
let id: string | null = null;
|
||||
if (host === "youtu.be") {
|
||||
id = u.pathname.slice(1) || null;
|
||||
} else if (host === "youtube.com" || host === "m.youtube.com" || host === "music.youtube.com") {
|
||||
if (u.pathname === "/watch") id = u.searchParams.get("v");
|
||||
else {
|
||||
const m = u.pathname.match(/^\/(?:shorts|embed|live)\/([^/?#]+)/);
|
||||
if (m) id = m[1];
|
||||
}
|
||||
}
|
||||
if (!id) return null;
|
||||
return { id, start: parseStart(u.searchParams.get("t") || u.searchParams.get("start")) };
|
||||
}
|
||||
|
||||
function renderDescription(
|
||||
text: string,
|
||||
opts: {
|
||||
currentId: string;
|
||||
onSeek: (seconds: number) => void;
|
||||
onLoadVideo: (id: string, start: number | null) => void;
|
||||
t: TFunction;
|
||||
}
|
||||
): ReactNode[] {
|
||||
const { t } = opts;
|
||||
const out: ReactNode[] = [];
|
||||
let key = 0;
|
||||
const linkCls = "text-accent hover:underline break-all";
|
||||
// Tidy YouTube descriptions: drop trailing spaces and remove blank lines entirely
|
||||
// (they're just noise in the popover), keeping single line breaks.
|
||||
const clean = text.replace(/[ \t]+\n/g, "\n").replace(/\n{2,}/g, "\n").trim();
|
||||
for (const chunk of clean.split(URL_RE)) {
|
||||
if (/^https?:\/\//.test(chunk)) {
|
||||
const href = chunk.replace(/[.,;:!?)\]]+$/, "");
|
||||
const yt = parseYouTube(href);
|
||||
if (yt) {
|
||||
const sameVideo = yt.id === opts.currentId;
|
||||
out.push(
|
||||
<button
|
||||
key={key++}
|
||||
onClick={() =>
|
||||
sameVideo ? opts.onSeek(yt.start ?? 0) : opts.onLoadVideo(yt.id, yt.start)
|
||||
}
|
||||
className={linkCls}
|
||||
title={sameVideo ? t("player.jumpToTime") : t("player.playInApp")}
|
||||
>
|
||||
{href}
|
||||
</button>
|
||||
);
|
||||
} else {
|
||||
out.push(
|
||||
<a key={key++} href={href} target="_blank" rel="noreferrer" className={linkCls}>
|
||||
{href}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
// Plain text: pull out emails, hashtags and bare timestamps.
|
||||
INLINE_RE.lastIndex = 0;
|
||||
let last = 0;
|
||||
let m: RegExpExecArray | null;
|
||||
while ((m = INLINE_RE.exec(chunk))) {
|
||||
if (m.index > last) out.push(<span key={key++}>{chunk.slice(last, m.index)}</span>);
|
||||
if (m[1]) {
|
||||
// Email — trim trailing punctuation the domain rule may have swallowed.
|
||||
const email = m[1].replace(/[.,;:]+$/, "");
|
||||
out.push(
|
||||
<a key={key++} href={`mailto:${email}`} className={linkCls}>
|
||||
{email}
|
||||
</a>
|
||||
);
|
||||
if (m[1].length > email.length) out.push(<span key={key++}>{m[1].slice(email.length)}</span>);
|
||||
} else if (m[2]) {
|
||||
// Hashtag → YouTube's hashtag feed (mirrors native YouTube behavior).
|
||||
const tag = m[2].slice(1);
|
||||
out.push(
|
||||
<a
|
||||
key={key++}
|
||||
href={`https://www.youtube.com/hashtag/${encodeURIComponent(tag)}`}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className={linkCls}
|
||||
>
|
||||
{m[2]}
|
||||
</a>
|
||||
);
|
||||
} else {
|
||||
const ts = m[3];
|
||||
out.push(
|
||||
<button
|
||||
key={key++}
|
||||
onClick={() => opts.onSeek(tsToSeconds(ts))}
|
||||
className="text-accent hover:underline"
|
||||
>
|
||||
{ts}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
last = m.index + m[0].length;
|
||||
}
|
||||
if (last < chunk.length) out.push(<span key={key++}>{chunk.slice(last)}</span>);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// Experiment (branch experiment/inline-player): play the video in-app via the
|
||||
// YouTube IFrame Player API (not a bare embed) so we can read playback position
|
||||
// and resume where the user left off. The modal closes via the in-card Close
|
||||
|
|
|
|||
|
|
@ -37,6 +37,7 @@ import {
|
|||
import { api, type Playlist, type Video } from "../lib/api";
|
||||
import { formatDuration } from "../lib/format";
|
||||
import { notify } from "../lib/notifications";
|
||||
import { LS, readMerged, writeJSON } from "../lib/storage";
|
||||
import { useUndoable } from "../lib/useUndoable";
|
||||
import PlayerModal from "./PlayerModal";
|
||||
import UndoToolbar from "./UndoToolbar";
|
||||
|
|
@ -187,26 +188,18 @@ export default function Playlists({ canWrite }: { canWrite: boolean }) {
|
|||
const confirm = useConfirm();
|
||||
// Persist the selected playlist so F5 keeps it (the de-URL refactor dropped it from the URL).
|
||||
const [selectedId, setSelectedId] = useState<number | null>(() => {
|
||||
const s = localStorage.getItem("siftlode.playlist");
|
||||
const s = localStorage.getItem(LS.playlist);
|
||||
return s ? Number(s) : null;
|
||||
});
|
||||
useEffect(() => {
|
||||
if (selectedId != null) localStorage.setItem("siftlode.playlist", String(selectedId));
|
||||
if (selectedId != null) localStorage.setItem(LS.playlist, String(selectedId));
|
||||
}, [selectedId]);
|
||||
const selectedRef = useRef<HTMLButtonElement | null>(null);
|
||||
const scrolledRef = useRef(false);
|
||||
// How the left playlist rail is ordered (persisted).
|
||||
const [plSort, setPlSort] = useState<PlSort>(() => {
|
||||
try {
|
||||
const s = localStorage.getItem("siftlode.plSort");
|
||||
if (s) return { ...PL_SORT_DEFAULT, ...JSON.parse(s) };
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
return PL_SORT_DEFAULT;
|
||||
});
|
||||
const [plSort, setPlSort] = useState<PlSort>(() => readMerged(LS.plSort, PL_SORT_DEFAULT));
|
||||
useEffect(() => {
|
||||
localStorage.setItem("siftlode.plSort", JSON.stringify(plSort));
|
||||
writeJSON(LS.plSort, plSort);
|
||||
}, [plSort]);
|
||||
const [newName, setNewName] = useState("");
|
||||
const [renaming, setRenaming] = useState(false);
|
||||
|
|
|
|||
|
|
@ -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`;
|
||||
|
|
|
|||
|
|
@ -1,12 +1,15 @@
|
|||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { Bell, Check, Monitor, RotateCcw, Save, Trash2, User } from "lucide-react";
|
||||
import { Bell, Monitor, Trash2, User } from "lucide-react";
|
||||
import { SCHEMES, type Scheme, type ThemePrefs } from "../lib/theme";
|
||||
import { api, type Me } from "../lib/api";
|
||||
import { LS, usePersistedState } from "../lib/storage";
|
||||
import Avatar from "./Avatar";
|
||||
import { notify, type NotifSettings } from "../lib/notifications";
|
||||
import Tooltip from "./Tooltip";
|
||||
import { Section, SettingRow, Switch } from "./ui/form";
|
||||
import { DraftSaveBar } from "./ui/DraftSaveBar";
|
||||
import { useConfirm } from "./ConfirmProvider";
|
||||
|
||||
// The Settings page edits server-persisted preferences as a draft: changes apply locally
|
||||
|
|
@ -38,14 +41,6 @@ const TABS: { id: TabId; icon: typeof Monitor }[] = [
|
|||
{ id: "account", icon: User },
|
||||
];
|
||||
|
||||
// Persist the active tab so a reload (F5) keeps the user where they were instead of
|
||||
// snapping back to "appearance".
|
||||
const SETTINGS_TAB_KEY = "siftlode.settingsTab";
|
||||
function loadSettingsTab(): TabId {
|
||||
const v = localStorage.getItem(SETTINGS_TAB_KEY);
|
||||
return TABS.some((tabItem) => tabItem.id === v) ? (v as TabId) : "appearance";
|
||||
}
|
||||
|
||||
// Settings as a page (Design B): rendered in the main content area, not an overlay. It keeps
|
||||
// the frosted `.glass` surface so it still refracts the ambient backdrop. The page title is
|
||||
// shown by the Header; the tab rail stays as in-page sub-navigation.
|
||||
|
|
@ -59,11 +54,11 @@ export default function SettingsPanel({
|
|||
onOpenWizard: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [tab, setTabState] = useState<TabId>(loadSettingsTab);
|
||||
const setTab = (id: TabId) => {
|
||||
setTabState(id);
|
||||
localStorage.setItem(SETTINGS_TAB_KEY, id);
|
||||
};
|
||||
const [tabRaw, setTab] = usePersistedState(LS.settingsTab, "appearance");
|
||||
// Clamp at render so a stale/removed tab id falls back to Appearance.
|
||||
const tab: TabId = TABS.some((tabItem) => tabItem.id === tabRaw)
|
||||
? (tabRaw as TabId)
|
||||
: "appearance";
|
||||
|
||||
return (
|
||||
<div className="p-4 max-w-3xl w-full mx-auto">
|
||||
|
|
@ -110,87 +105,22 @@ export default function SettingsPanel({
|
|||
|
||||
function PrefsSaveBar({ prefs }: { prefs: PrefsController }) {
|
||||
const { t } = useTranslation();
|
||||
// Stay mounted while a transient "saved"/"error" message is fading, even after dirty clears.
|
||||
if (!prefs.dirty && prefs.saveState === "idle") return null;
|
||||
const saving = prefs.saveState === "saving";
|
||||
return (
|
||||
<div className="flex items-center justify-between gap-3 border-t border-border/60 px-4 py-3 bg-card/40">
|
||||
<span className="text-sm text-muted">
|
||||
{prefs.saveState === "saved"
|
||||
? <span className="text-emerald-500 inline-flex items-center gap-1.5"><Check className="w-4 h-4" />{t("settings.save.saved")}</span>
|
||||
: prefs.saveState === "error"
|
||||
? <span className="text-red-500">{t("settings.save.failed")}</span>
|
||||
: t("settings.save.unsaved")}
|
||||
</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={prefs.discard}
|
||||
disabled={saving || !prefs.dirty}
|
||||
className="glass-card glass-hover flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-sm disabled:opacity-40 transition"
|
||||
>
|
||||
<RotateCcw className="w-4 h-4" />
|
||||
{t("settings.save.discard")}
|
||||
</button>
|
||||
<button
|
||||
onClick={prefs.save}
|
||||
disabled={saving || !prefs.dirty}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-sm bg-accent text-accent-fg font-medium shadow-md shadow-accent/20 disabled:opacity-40 transition"
|
||||
>
|
||||
<Save className="w-4 h-4" />
|
||||
{saving ? t("settings.save.saving") : t("settings.save.save")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Section({ title, children }: { title: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="mb-6">
|
||||
<div className="text-xs uppercase tracking-wide text-muted mb-2">{title}</div>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Row({
|
||||
label,
|
||||
hint,
|
||||
children,
|
||||
}: {
|
||||
label: string;
|
||||
hint?: string;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex items-center justify-between gap-3 py-1.5 text-sm">
|
||||
<Tooltip hint={hint ?? ""}>
|
||||
<span
|
||||
className={
|
||||
hint ? "underline decoration-dotted decoration-muted/40 underline-offset-4 cursor-help" : ""
|
||||
}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
</Tooltip>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Switch({ checked, onChange }: { checked: boolean; onChange: (v: boolean) => void }) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onChange(!checked)}
|
||||
className={`w-9 h-5 rounded-full transition relative shrink-0 ${checked ? "bg-accent" : "bg-border"}`}
|
||||
>
|
||||
<span
|
||||
className={`absolute top-0.5 w-4 h-4 rounded-full bg-white shadow transition-all ${
|
||||
checked ? "left-[18px]" : "left-0.5"
|
||||
}`}
|
||||
<DraftSaveBar
|
||||
variant="inline"
|
||||
dirty={prefs.dirty}
|
||||
state={prefs.saveState}
|
||||
onSave={prefs.save}
|
||||
onDiscard={prefs.discard}
|
||||
labels={{
|
||||
saved: t("settings.save.saved"),
|
||||
failed: t("settings.save.failed"),
|
||||
unsaved: t("settings.save.unsaved"),
|
||||
discard: t("settings.save.discard"),
|
||||
save: t("settings.save.save"),
|
||||
saving: t("settings.save.saving"),
|
||||
}}
|
||||
/>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -219,27 +149,27 @@ function Appearance({ prefs }: { prefs: PrefsController }) {
|
|||
</Section>
|
||||
|
||||
<Section title={t("settings.appearance.display")}>
|
||||
<Row label={t("settings.appearance.darkMode")}>
|
||||
<SettingRow label={t("settings.appearance.darkMode")}>
|
||||
<Switch
|
||||
checked={theme.mode === "dark"}
|
||||
onChange={(v) => setTheme({ ...theme, mode: v ? "dark" : "light" })}
|
||||
/>
|
||||
</Row>
|
||||
<Row label={t("settings.appearance.listView")} hint={t("settings.appearance.listViewHint")}>
|
||||
</SettingRow>
|
||||
<SettingRow label={t("settings.appearance.listView")} hint={t("settings.appearance.listViewHint")}>
|
||||
<Switch checked={view === "list"} onChange={(v) => setView(v ? "list" : "grid")} />
|
||||
</Row>
|
||||
<Row
|
||||
</SettingRow>
|
||||
<SettingRow
|
||||
label={t("settings.appearance.performanceMode")}
|
||||
hint={t("settings.appearance.performanceModeHint")}
|
||||
>
|
||||
<Switch checked={perf} onChange={setPerf} />
|
||||
</Row>
|
||||
<Row
|
||||
</SettingRow>
|
||||
<SettingRow
|
||||
label={t("settings.appearance.showHints")}
|
||||
hint={t("settings.appearance.showHintsHint")}
|
||||
>
|
||||
<Switch checked={hints} onChange={setHints} />
|
||||
</Row>
|
||||
</SettingRow>
|
||||
</Section>
|
||||
|
||||
<Section title={t("settings.appearance.textSize")}>
|
||||
|
|
@ -268,18 +198,18 @@ function Notifications({ prefs }: { prefs: PrefsController }) {
|
|||
|
||||
return (
|
||||
<Section title={t("settings.notifications.title")}>
|
||||
<Row
|
||||
<SettingRow
|
||||
label={t("settings.notifications.sound")}
|
||||
hint={t("settings.notifications.soundHint")}
|
||||
>
|
||||
<Switch checked={cfg.sound} onChange={(v) => update({ sound: v })} />
|
||||
</Row>
|
||||
<Row
|
||||
</SettingRow>
|
||||
<SettingRow
|
||||
label={t("settings.notifications.autoDismiss")}
|
||||
hint={t("settings.notifications.autoDismissHint")}
|
||||
>
|
||||
<Switch checked={autoOn} onChange={(v) => update({ durationMs: v ? 6000 : 0 })} />
|
||||
</Row>
|
||||
</SettingRow>
|
||||
{autoOn && (
|
||||
<div className="py-1.5 text-sm">
|
||||
<div className="flex items-center justify-between">
|
||||
|
|
|
|||
|
|
@ -36,6 +36,7 @@ import {
|
|||
} from "../lib/sidebarLayout";
|
||||
import { shareUrl } from "../lib/urlState";
|
||||
import { notify } from "../lib/notifications";
|
||||
import { Switch } from "./ui/form";
|
||||
import TagManager from "./TagManager";
|
||||
|
||||
// Filter ids; display labels resolved at render time via t("sidebar.sort.<id>") etc.
|
||||
|
|
@ -622,19 +623,7 @@ function Toggle({
|
|||
return (
|
||||
<label className="flex items-center justify-between py-1 cursor-pointer text-sm">
|
||||
<span>{label}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onChange(!checked)}
|
||||
className={`w-9 h-5 rounded-full transition relative ${
|
||||
checked ? "bg-accent" : "bg-border"
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className={`absolute top-0.5 w-4 h-4 rounded-full bg-white transition-all ${
|
||||
checked ? "left-[18px]" : "left-0.5"
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
<Switch checked={checked} onChange={onChange} />
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,10 +5,11 @@ import { History, Pause, Play, RefreshCw } from "lucide-react";
|
|||
import { api, type AdminQuotaRow, type Me } from "../lib/api";
|
||||
import { formatEta, quotaActionLabel } from "../lib/format";
|
||||
import { notify } from "../lib/notifications";
|
||||
import { LS, usePersistedState } from "../lib/storage";
|
||||
import Tooltip from "./Tooltip";
|
||||
import { Section, SettingRow } from "./ui/form";
|
||||
|
||||
const RANGES = [7, 30, 90] as const;
|
||||
const STATS_TAB_KEY = "siftlode.statsTab";
|
||||
type StatsTab = "overview" | "system";
|
||||
|
||||
// Usage & stats page. "Overview" is per-user (sync status + your own API usage + actions);
|
||||
|
|
@ -17,14 +18,9 @@ type StatsTab = "overview" | "system";
|
|||
export default function Stats({ me }: { me: Me }) {
|
||||
const { t } = useTranslation();
|
||||
const isAdmin = me.role === "admin";
|
||||
const [tab, setTabState] = useState<StatsTab>(() => {
|
||||
const v = localStorage.getItem(STATS_TAB_KEY);
|
||||
return v === "system" && isAdmin ? "system" : "overview";
|
||||
});
|
||||
const setTab = (id: StatsTab) => {
|
||||
setTabState(id);
|
||||
localStorage.setItem(STATS_TAB_KEY, id);
|
||||
};
|
||||
const [tabRaw, setTab] = usePersistedState(LS.statsTab, "overview");
|
||||
// Clamp at render: only admins may land on the System tab (covers a stale stored value).
|
||||
const tab: StatsTab = tabRaw === "system" && isAdmin ? "system" : "overview";
|
||||
|
||||
return (
|
||||
<div className="p-4 max-w-4xl mx-auto">
|
||||
|
|
@ -52,31 +48,6 @@ export default function Stats({ me }: { me: Me }) {
|
|||
);
|
||||
}
|
||||
|
||||
function Section({ title, children }: { title: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="mb-6">
|
||||
<div className="text-xs uppercase tracking-wide text-muted mb-2">{title}</div>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Row({ label, hint, children }: { label: string; hint?: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="flex items-center justify-between gap-3 py-1.5 text-sm">
|
||||
<Tooltip hint={hint ?? ""}>
|
||||
<span
|
||||
className={
|
||||
hint ? "underline decoration-dotted decoration-muted/40 underline-offset-4 cursor-help" : ""
|
||||
}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
</Tooltip>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Per-user view: sync status + your own API usage + (for real accounts) the manual sync actions.
|
||||
function Overview({ me }: { me: Me }) {
|
||||
|
|
@ -112,29 +83,29 @@ function Overview({ me }: { me: Me }) {
|
|||
<Section title={t("stats.sync.myStatus")}>
|
||||
{s ? (
|
||||
<div className="space-y-1.5 text-sm">
|
||||
<Row label={t("stats.sync.channels")} hint={t("stats.sync.channelsHint")}>
|
||||
<SettingRow label={t("stats.sync.channels")} hint={t("stats.sync.channelsHint")}>
|
||||
{s.channels_total}
|
||||
</Row>
|
||||
<Row label={t("stats.sync.recentSynced")} hint={t("stats.sync.recentSyncedHint")}>
|
||||
</SettingRow>
|
||||
<SettingRow label={t("stats.sync.recentSynced")} hint={t("stats.sync.recentSyncedHint")}>
|
||||
{`${s.channels_recent_synced}/${s.channels_total}`}
|
||||
</Row>
|
||||
<Row label={t("stats.sync.fullHistory")} hint={t("stats.sync.fullHistoryHint")}>
|
||||
</SettingRow>
|
||||
<SettingRow label={t("stats.sync.fullHistory")} hint={t("stats.sync.fullHistoryHint")}>
|
||||
{`${s.channels_deep_done}/${s.channels_deep_requested}`}
|
||||
</Row>
|
||||
</SettingRow>
|
||||
{s.deep_pending_count > 0 && (
|
||||
<Row
|
||||
<SettingRow
|
||||
label={t("stats.sync.fullHistoryEta")}
|
||||
hint={t("stats.sync.fullHistoryEtaHint", { count: s.deep_pending_count })}
|
||||
>
|
||||
{formatEta(s.deep_eta_seconds)}
|
||||
</Row>
|
||||
</SettingRow>
|
||||
)}
|
||||
<Row label={t("stats.sync.myVideos")} hint={t("stats.sync.myVideosHint")}>
|
||||
<SettingRow label={t("stats.sync.myVideos")} hint={t("stats.sync.myVideosHint")}>
|
||||
{s.my_videos.toLocaleString()}
|
||||
</Row>
|
||||
<Row label={t("stats.sync.quotaLeft")} hint={t("stats.sync.quotaLeftHint")}>
|
||||
</SettingRow>
|
||||
<SettingRow label={t("stats.sync.quotaLeft")} hint={t("stats.sync.quotaLeftHint")}>
|
||||
{s.quota_remaining_today.toLocaleString()}
|
||||
</Row>
|
||||
</SettingRow>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-muted text-sm">{t("stats.sync.loading")}</div>
|
||||
|
|
@ -145,12 +116,12 @@ function Overview({ me }: { me: Me }) {
|
|||
{usage.data ? (
|
||||
<>
|
||||
<div className="space-y-1.5 text-sm">
|
||||
<Row label={t("stats.sync.today")} hint={t("stats.sync.todayHint")}>
|
||||
<SettingRow label={t("stats.sync.today")} hint={t("stats.sync.todayHint")}>
|
||||
{usage.data.today.toLocaleString()}
|
||||
</Row>
|
||||
<Row label={t("stats.sync.last7d")}>{usage.data.last_7d.toLocaleString()}</Row>
|
||||
<Row label={t("stats.sync.last30d")}>{usage.data.last_30d.toLocaleString()}</Row>
|
||||
<Row label={t("stats.sync.allTime")}>{usage.data.all_time.toLocaleString()}</Row>
|
||||
</SettingRow>
|
||||
<SettingRow label={t("stats.sync.last7d")}>{usage.data.last_7d.toLocaleString()}</SettingRow>
|
||||
<SettingRow label={t("stats.sync.last30d")}>{usage.data.last_30d.toLocaleString()}</SettingRow>
|
||||
<SettingRow label={t("stats.sync.allTime")}>{usage.data.all_time.toLocaleString()}</SettingRow>
|
||||
</div>
|
||||
{Object.keys(usage.data.by_action).length > 0 && (
|
||||
<div className="mt-2 pt-2 border-t border-border space-y-1 text-xs text-muted">
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { useState } from "react";
|
||||
import { usePersistedState } from "../lib/storage";
|
||||
|
||||
// Reusable horizontal pill tab-rail for in-page sub-navigation (Configuration, Users & roles…).
|
||||
// The active tab is persisted to localStorage so a reload (F5) keeps the user where they were,
|
||||
|
|
@ -10,16 +10,9 @@ export interface TabDef {
|
|||
badge?: number; // optional count pill (e.g. pending requests); hidden when 0/undefined
|
||||
}
|
||||
|
||||
/** Persisted active-tab state. Validation is deferred to the caller (it clamps to a valid id at
|
||||
* render) so this can be called before an async-loaded tab list is known, keeping hook order stable. */
|
||||
export function usePersistedTab(storageKey: string, fallback = ""): [string, (id: string) => void] {
|
||||
const [tab, setTabState] = useState<string>(() => localStorage.getItem(storageKey) ?? fallback);
|
||||
const setTab = (id: string) => {
|
||||
setTabState(id);
|
||||
localStorage.setItem(storageKey, id);
|
||||
};
|
||||
return [tab, setTab];
|
||||
}
|
||||
/** Persisted active-tab state — the canonical helper now lives in lib/storage as
|
||||
* `usePersistedState`; re-exported here under its original name for existing call sites. */
|
||||
export const usePersistedTab = usePersistedState;
|
||||
|
||||
export default function Tabs({
|
||||
tabs,
|
||||
|
|
|
|||
75
frontend/src/components/ui/DraftSaveBar.tsx
Normal file
75
frontend/src/components/ui/DraftSaveBar.tsx
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
import { Check, RotateCcw, Save } from "lucide-react";
|
||||
|
||||
// The Save / Discard bar shown while an edited draft has unsaved changes (Settings prefs,
|
||||
// admin Configuration). Two visual variants share one state machine + markup:
|
||||
// - "inline": a bar pinned to the bottom of a card (Settings panel)
|
||||
// - "floating": a centered floating pill over the page (Configuration page)
|
||||
// Renders nothing while clean and idle; stays mounted through the fading saved/error message.
|
||||
|
||||
export type SaveState = "idle" | "saving" | "saved" | "error";
|
||||
|
||||
export interface DraftSaveBarLabels {
|
||||
saved: string;
|
||||
failed: string;
|
||||
unsaved: string;
|
||||
discard: string;
|
||||
save: string;
|
||||
saving: string;
|
||||
}
|
||||
|
||||
export function DraftSaveBar({
|
||||
dirty,
|
||||
state,
|
||||
onSave,
|
||||
onDiscard,
|
||||
labels,
|
||||
variant = "inline",
|
||||
}: {
|
||||
dirty: boolean;
|
||||
state: SaveState;
|
||||
onSave: () => void;
|
||||
onDiscard: () => void;
|
||||
labels: DraftSaveBarLabels;
|
||||
variant?: "inline" | "floating";
|
||||
}) {
|
||||
if (!dirty && state === "idle") return null;
|
||||
const saving = state === "saving";
|
||||
const wrap =
|
||||
variant === "floating"
|
||||
? "fixed bottom-4 left-1/2 -translate-x-1/2 z-40 glass rounded-2xl shadow-lg flex items-center justify-between gap-4 px-4 py-3 w-[min(48rem,calc(100%-2rem))]"
|
||||
: "flex items-center justify-between gap-3 border-t border-border/60 px-4 py-3 bg-card/40";
|
||||
return (
|
||||
<div className={wrap}>
|
||||
<span className="text-sm text-muted">
|
||||
{state === "saved" ? (
|
||||
<span className="text-emerald-500 inline-flex items-center gap-1.5">
|
||||
<Check className="w-4 h-4" />
|
||||
{labels.saved}
|
||||
</span>
|
||||
) : state === "error" ? (
|
||||
<span className="text-red-500">{labels.failed}</span>
|
||||
) : (
|
||||
labels.unsaved
|
||||
)}
|
||||
</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={onDiscard}
|
||||
disabled={saving || !dirty}
|
||||
className="glass-card glass-hover flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-sm disabled:opacity-40 transition"
|
||||
>
|
||||
<RotateCcw className="w-4 h-4" />
|
||||
{labels.discard}
|
||||
</button>
|
||||
<button
|
||||
onClick={onSave}
|
||||
disabled={saving || !dirty}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-sm bg-accent text-accent-fg font-medium shadow-md shadow-accent/20 disabled:opacity-40 transition"
|
||||
>
|
||||
<Save className="w-4 h-4" />
|
||||
{saving ? labels.saving : labels.save}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
85
frontend/src/components/ui/form.tsx
Normal file
85
frontend/src/components/ui/form.tsx
Normal file
|
|
@ -0,0 +1,85 @@
|
|||
import type { ReactNode } from "react";
|
||||
import Tooltip from "../Tooltip";
|
||||
|
||||
// Shared form/settings primitives, factored out of the many panels that each re-declared
|
||||
// them (Settings, Config, Stats, admin pages, Setup wizard). Visual contract unchanged.
|
||||
|
||||
/** Pill on/off switch — the bare control; compose it with your own label/row. */
|
||||
export function Switch({
|
||||
checked,
|
||||
onChange,
|
||||
}: {
|
||||
checked: boolean;
|
||||
onChange: (v: boolean) => void;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onChange(!checked)}
|
||||
className={`w-9 h-5 rounded-full transition relative shrink-0 ${checked ? "bg-accent" : "bg-border"}`}
|
||||
>
|
||||
<span
|
||||
className={`absolute top-0.5 w-4 h-4 rounded-full bg-white shadow transition-all ${
|
||||
checked ? "left-[18px]" : "left-0.5"
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
/** A titled settings block. Default = a plain block with an uppercase caption; `card` wraps it
|
||||
* in a glass card (the admin pages' style). */
|
||||
export function Section({
|
||||
title,
|
||||
card,
|
||||
children,
|
||||
}: {
|
||||
title: string;
|
||||
card?: boolean;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className={card ? "glass rounded-2xl p-4 mb-4" : "mb-6"}>
|
||||
<div className={`text-xs uppercase tracking-wide text-muted ${card ? "mb-3" : "mb-2"}`}>
|
||||
{title}
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** A label that reveals an explanatory caption on hover (dotted underline) when `hint` is set.
|
||||
* No-op styling when there's no hint, so it's safe to use unconditionally. */
|
||||
export function HintLabel({ hint, children }: { hint?: string; children: ReactNode }) {
|
||||
return (
|
||||
<Tooltip hint={hint ?? ""}>
|
||||
<span
|
||||
className={
|
||||
hint
|
||||
? "underline decoration-dotted decoration-muted/40 underline-offset-4 cursor-help"
|
||||
: ""
|
||||
}
|
||||
>
|
||||
{children}
|
||||
</span>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
/** A settings row: a (hint-able) label on the left, a control on the right. */
|
||||
export function SettingRow({
|
||||
label,
|
||||
hint,
|
||||
children,
|
||||
}: {
|
||||
label: string;
|
||||
hint?: string;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex items-center justify-between gap-3 py-1.5 text-sm">
|
||||
<HintLabel hint={hint}>{label}</HintLabel>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,5 +1,6 @@
|
|||
import i18n from "i18next";
|
||||
import { initReactI18next } from "react-i18next";
|
||||
import { LS } from "../lib/storage";
|
||||
|
||||
// Endonyms (shown as-is regardless of the active language — the convention for language pickers).
|
||||
export const LANGUAGES = [
|
||||
|
|
@ -9,7 +10,7 @@ export const LANGUAGES = [
|
|||
] as const;
|
||||
export type LangCode = (typeof LANGUAGES)[number]["code"];
|
||||
const SUPPORTED = LANGUAGES.map((l) => l.code) as readonly string[];
|
||||
export const LANG_KEY = "siftlode.lang";
|
||||
export const LANG_KEY = LS.lang;
|
||||
|
||||
// Auto-load every locale file (locales/<lang>/<area>.json) and merge each <area> under one
|
||||
// `translation` namespace, so components use t("area.key") and adding an area needs no wiring.
|
||||
|
|
|
|||
|
|
@ -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}}”"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}}”"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}}”"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
147
frontend/src/lib/descriptionLinks.tsx
Normal file
147
frontend/src/lib/descriptionLinks.tsx
Normal file
|
|
@ -0,0 +1,147 @@
|
|||
import type { ReactNode } from "react";
|
||||
import type { TFunction } from "i18next";
|
||||
|
||||
// Turn a video description into clickable nodes:
|
||||
// - bare timestamps (mm:ss / hh:mm:ss) → seek the inline player
|
||||
// - a YouTube link to the *current* video → seek (to its t= if any)
|
||||
// - a YouTube link to *another* video → load it in the inline player
|
||||
// - emails → mailto:, hashtags → YouTube's hashtag page, other URLs → new tab
|
||||
// Pure logic (no React state), factored out of PlayerModal so it's testable on its own.
|
||||
const URL_RE = /(https?:\/\/[^\s<>]+)/g;
|
||||
// One pass over plain text for the three inline token kinds (email | hashtag | timestamp).
|
||||
const INLINE_RE =
|
||||
/([A-Za-z0-9._%+-]+@[A-Za-z0-9-]+\.[A-Za-z0-9.-]+)|(#[\p{L}\p{N}_]+)|(\b(?:\d{1,2}:)?\d{1,2}:\d{2}\b)/gu;
|
||||
|
||||
function tsToSeconds(ts: string): number {
|
||||
const parts = ts.split(":").map((n) => parseInt(n, 10));
|
||||
return parts.length === 3
|
||||
? parts[0] * 3600 + parts[1] * 60 + parts[2]
|
||||
: parts[0] * 60 + parts[1];
|
||||
}
|
||||
|
||||
// Parse a YouTube `t`/`start` param: "90", "90s", or "1h2m3s".
|
||||
function parseStart(t: string | null): number | null {
|
||||
if (!t) return null;
|
||||
if (/^\d+$/.test(t)) return parseInt(t, 10) || null;
|
||||
const m = t.match(/^(?:(\d+)h)?(?:(\d+)m)?(?:(\d+)s)?$/);
|
||||
if (!m) return null;
|
||||
const total =
|
||||
parseInt(m[1] || "0", 10) * 3600 +
|
||||
parseInt(m[2] || "0", 10) * 60 +
|
||||
parseInt(m[3] || "0", 10);
|
||||
return total || null;
|
||||
}
|
||||
|
||||
// Extract a video id (+ optional start) from a YouTube URL, else null.
|
||||
function parseYouTube(url: string): { id: string; start: number | null } | null {
|
||||
let u: URL;
|
||||
try {
|
||||
u = new URL(url);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
const host = u.hostname.replace(/^www\./, "");
|
||||
let id: string | null = null;
|
||||
if (host === "youtu.be") {
|
||||
id = u.pathname.slice(1) || null;
|
||||
} else if (host === "youtube.com" || host === "m.youtube.com" || host === "music.youtube.com") {
|
||||
if (u.pathname === "/watch") id = u.searchParams.get("v");
|
||||
else {
|
||||
const m = u.pathname.match(/^\/(?:shorts|embed|live)\/([^/?#]+)/);
|
||||
if (m) id = m[1];
|
||||
}
|
||||
}
|
||||
if (!id) return null;
|
||||
return { id, start: parseStart(u.searchParams.get("t") || u.searchParams.get("start")) };
|
||||
}
|
||||
|
||||
export function renderDescription(
|
||||
text: string,
|
||||
opts: {
|
||||
currentId: string;
|
||||
onSeek: (seconds: number) => void;
|
||||
onLoadVideo: (id: string, start: number | null) => void;
|
||||
t: TFunction;
|
||||
}
|
||||
): ReactNode[] {
|
||||
const { t } = opts;
|
||||
const out: ReactNode[] = [];
|
||||
let key = 0;
|
||||
const linkCls = "text-accent hover:underline break-all";
|
||||
// Tidy YouTube descriptions: drop trailing spaces and remove blank lines entirely
|
||||
// (they're just noise in the popover), keeping single line breaks.
|
||||
const clean = text.replace(/[ \t]+\n/g, "\n").replace(/\n{2,}/g, "\n").trim();
|
||||
for (const chunk of clean.split(URL_RE)) {
|
||||
if (/^https?:\/\//.test(chunk)) {
|
||||
const href = chunk.replace(/[.,;:!?)\]]+$/, "");
|
||||
const yt = parseYouTube(href);
|
||||
if (yt) {
|
||||
const sameVideo = yt.id === opts.currentId;
|
||||
out.push(
|
||||
<button
|
||||
key={key++}
|
||||
onClick={() =>
|
||||
sameVideo ? opts.onSeek(yt.start ?? 0) : opts.onLoadVideo(yt.id, yt.start)
|
||||
}
|
||||
className={linkCls}
|
||||
title={sameVideo ? t("player.jumpToTime") : t("player.playInApp")}
|
||||
>
|
||||
{href}
|
||||
</button>
|
||||
);
|
||||
} else {
|
||||
out.push(
|
||||
<a key={key++} href={href} target="_blank" rel="noreferrer" className={linkCls}>
|
||||
{href}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
// Plain text: pull out emails, hashtags and bare timestamps.
|
||||
INLINE_RE.lastIndex = 0;
|
||||
let last = 0;
|
||||
let m: RegExpExecArray | null;
|
||||
while ((m = INLINE_RE.exec(chunk))) {
|
||||
if (m.index > last) out.push(<span key={key++}>{chunk.slice(last, m.index)}</span>);
|
||||
if (m[1]) {
|
||||
// Email — trim trailing punctuation the domain rule may have swallowed.
|
||||
const email = m[1].replace(/[.,;:]+$/, "");
|
||||
out.push(
|
||||
<a key={key++} href={`mailto:${email}`} className={linkCls}>
|
||||
{email}
|
||||
</a>
|
||||
);
|
||||
if (m[1].length > email.length) out.push(<span key={key++}>{m[1].slice(email.length)}</span>);
|
||||
} else if (m[2]) {
|
||||
// Hashtag → YouTube's hashtag feed (mirrors native YouTube behavior).
|
||||
const tag = m[2].slice(1);
|
||||
out.push(
|
||||
<a
|
||||
key={key++}
|
||||
href={`https://www.youtube.com/hashtag/${encodeURIComponent(tag)}`}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className={linkCls}
|
||||
>
|
||||
{m[2]}
|
||||
</a>
|
||||
);
|
||||
} else {
|
||||
const ts = m[3];
|
||||
out.push(
|
||||
<button
|
||||
key={key++}
|
||||
onClick={() => opts.onSeek(tsToSeconds(ts))}
|
||||
className="text-accent hover:underline"
|
||||
>
|
||||
{ts}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
last = m.index + m[0].length;
|
||||
}
|
||||
if (last < chunk.length) out.push(<span key={key++}>{chunk.slice(last)}</span>);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
|
@ -1,4 +1,5 @@
|
|||
import i18n from "../i18n";
|
||||
import { createStore } from "./store";
|
||||
|
||||
// A single, self-explanatory error dialog (modal) the user must acknowledge — used when the
|
||||
// server can't carry out an action (a definitive 4xx/5xx response). Distinct from toasts
|
||||
|
|
@ -9,30 +10,18 @@ export interface AppError {
|
|||
message: string;
|
||||
}
|
||||
|
||||
let current: AppError | null = null;
|
||||
let listeners: Array<() => void> = [];
|
||||
const emit = () => listeners.forEach((l) => l());
|
||||
const store = createStore<AppError | null>(null);
|
||||
|
||||
export function reportError(message?: string, title?: string): void {
|
||||
current = {
|
||||
store.set({
|
||||
title: title || i18n.t("errors.title"),
|
||||
message: message || i18n.t("errors.generic"),
|
||||
};
|
||||
emit();
|
||||
});
|
||||
}
|
||||
|
||||
export function dismissError(): void {
|
||||
current = null;
|
||||
emit();
|
||||
store.set(null);
|
||||
}
|
||||
|
||||
export function subscribeError(listener: () => void): () => void {
|
||||
listeners.push(listener);
|
||||
return () => {
|
||||
listeners = listeners.filter((l) => l !== listener);
|
||||
};
|
||||
}
|
||||
|
||||
export function getError(): AppError | null {
|
||||
return current;
|
||||
}
|
||||
export const subscribeError = store.subscribe;
|
||||
export const getError = store.get;
|
||||
|
|
|
|||
|
|
@ -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"}`;
|
||||
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) });
|
||||
}
|
||||
const d = Math.round(hours / 24);
|
||||
return `~${d} day${d === 1 ? "" : "s"}`;
|
||||
|
||||
/** Compact whole-channel total length ("12 h", "45 m"), hours-scale so H:MM:SS isn't huge. */
|
||||
export function formatTotalHours(sec: number): string {
|
||||
if (!sec) return "—";
|
||||
const h = Math.round(sec / 3600);
|
||||
return h >= 1 ? `${h.toLocaleString()} h` : `${Math.max(1, Math.round(sec / 60))} m`;
|
||||
}
|
||||
|
||||
export function formatViews(n: number | null): string {
|
||||
|
|
|
|||
|
|
@ -1,36 +1,27 @@
|
|||
// App-wide "hints" toggle: when on, Tooltip components reveal short explanatory
|
||||
// captions on hover so the UI is somewhat self-documenting for new users. Experienced
|
||||
// users can turn it off in Settings. Persisted to localStorage (+ server preferences).
|
||||
const KEY = "siftlode.hints";
|
||||
|
||||
let enabled = load();
|
||||
let listeners: Array<() => void> = [];
|
||||
import { createStore } from "./store";
|
||||
import { LS } from "./storage";
|
||||
|
||||
function load(): boolean {
|
||||
try {
|
||||
return localStorage.getItem(KEY) !== "0"; // default ON
|
||||
return localStorage.getItem(LS.hints) !== "0"; // default ON
|
||||
} catch {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
export function hintsEnabled(): boolean {
|
||||
return enabled;
|
||||
}
|
||||
const store = createStore<boolean>(load());
|
||||
|
||||
export const hintsEnabled = store.get;
|
||||
export const subscribeHints = store.subscribe;
|
||||
|
||||
export function setHintsEnabled(v: boolean): void {
|
||||
enabled = v;
|
||||
store.set(v);
|
||||
try {
|
||||
localStorage.setItem(KEY, v ? "1" : "0");
|
||||
localStorage.setItem(LS.hints, v ? "1" : "0");
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
listeners.forEach((l) => l());
|
||||
}
|
||||
|
||||
export function subscribeHints(listener: () => void): () => void {
|
||||
listeners.push(listener);
|
||||
return () => {
|
||||
listeners = listeners.filter((l) => l !== listener);
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
// the Notification Center shows (info events, actions awaiting interaction, and app
|
||||
// errors). History is persisted to localStorage so it survives reloads; action
|
||||
// callbacks are live-only and dropped on reload.
|
||||
import { LS, readJSON, readMerged, writeJSON } from "./storage";
|
||||
|
||||
export type NotifLevel = "info" | "success" | "warning" | "error" | "fatal";
|
||||
|
||||
|
|
@ -71,8 +72,8 @@ export interface NotifyInput {
|
|||
transient?: boolean; // live status: sticky toast, not persisted (see Notification.transient)
|
||||
}
|
||||
|
||||
const HISTORY_KEY = "siftlode.notifications";
|
||||
const SETTINGS_KEY = "siftlode.notifSettings";
|
||||
const HISTORY_KEY = LS.notifHistory;
|
||||
const SETTINGS_KEY = LS.notifSettings;
|
||||
const MAX_HISTORY = 100;
|
||||
const ERROR_TTL = 15000;
|
||||
|
||||
|
|
@ -86,11 +87,7 @@ const DEFAULT_SETTINGS: NotifSettings = { sound: false, durationMs: 6000 };
|
|||
let config: NotifSettings = loadSettings();
|
||||
|
||||
function loadSettings(): NotifSettings {
|
||||
try {
|
||||
return { ...DEFAULT_SETTINGS, ...JSON.parse(localStorage.getItem(SETTINGS_KEY) || "{}") };
|
||||
} catch {
|
||||
return DEFAULT_SETTINGS;
|
||||
}
|
||||
return readMerged(SETTINGS_KEY, DEFAULT_SETTINGS);
|
||||
}
|
||||
|
||||
export function getNotifSettings(): NotifSettings {
|
||||
|
|
@ -99,11 +96,7 @@ export function getNotifSettings(): NotifSettings {
|
|||
|
||||
export function configureNotifications(p: Partial<NotifSettings>): void {
|
||||
config = { ...config, ...p };
|
||||
try {
|
||||
localStorage.setItem(SETTINGS_KEY, JSON.stringify(config));
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
writeJSON(SETTINGS_KEY, config);
|
||||
}
|
||||
|
||||
let audioCtx: AudioContext | null = null;
|
||||
|
|
@ -137,7 +130,7 @@ let cachedUnread = 0;
|
|||
|
||||
function load(): Notification[] {
|
||||
try {
|
||||
const raw = JSON.parse(localStorage.getItem(HISTORY_KEY) || "[]");
|
||||
const raw = readJSON<unknown>(HISTORY_KEY, []);
|
||||
if (!Array.isArray(raw)) return [];
|
||||
// Restored entries are history only. Mark them dismissed so they don't
|
||||
// resurrect as active toasts on reload — their auto-dismiss timers aren't
|
||||
|
|
@ -154,7 +147,7 @@ function persist() {
|
|||
// Transient status notices are live-session only — never write them to history,
|
||||
// so a reload can't leave one stranded with no producer left to clear it.
|
||||
const slim = items.filter((n) => !n.transient).map(({ action: _action, ...n }) => n);
|
||||
localStorage.setItem(HISTORY_KEY, JSON.stringify(slim));
|
||||
writeJSON(HISTORY_KEY, slim);
|
||||
} catch {
|
||||
/* ignore quota / serialization errors */
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -4,6 +4,8 @@
|
|||
|
||||
// `show`, `sort` and `content` moved to the feed toolbar (above the cards); they are no
|
||||
// longer sidebar widgets. normalizeLayout drops them from any persisted layout automatically.
|
||||
import { LS, readJSON, writeJSON } from "./storage";
|
||||
|
||||
export type WidgetId = "date" | "language" | "topic" | "tags";
|
||||
|
||||
export const ALL_WIDGETS: WidgetId[] = ["date", "language", "topic", "tags"];
|
||||
|
|
@ -27,8 +29,6 @@ export const DEFAULT_LAYOUT: SidebarLayout = {
|
|||
hidden: {},
|
||||
};
|
||||
|
||||
const KEY = "siftlode.sidebarLayout";
|
||||
|
||||
// Tolerate stale/partial data: keep only known widgets and append any that are missing
|
||||
// (e.g. a widget added in a later version) so nothing silently disappears.
|
||||
export function normalizeLayout(raw: unknown): SidebarLayout {
|
||||
|
|
@ -45,13 +45,9 @@ export function normalizeLayout(raw: unknown): SidebarLayout {
|
|||
}
|
||||
|
||||
export function loadLayout(): SidebarLayout {
|
||||
try {
|
||||
return normalizeLayout(JSON.parse(localStorage.getItem(KEY) || "{}"));
|
||||
} catch {
|
||||
return DEFAULT_LAYOUT;
|
||||
}
|
||||
return normalizeLayout(readJSON<unknown>(LS.sidebarLayout, {}));
|
||||
}
|
||||
|
||||
export function saveLayoutLocal(l: SidebarLayout): void {
|
||||
localStorage.setItem(KEY, JSON.stringify(l));
|
||||
writeJSON(LS.sidebarLayout, l);
|
||||
}
|
||||
|
|
|
|||
80
frontend/src/lib/storage.ts
Normal file
80
frontend/src/lib/storage.ts
Normal file
|
|
@ -0,0 +1,80 @@
|
|||
import { useState } from "react";
|
||||
|
||||
// Central registry of every localStorage key the app uses. One place to see them all, rename
|
||||
// safely, and avoid collisions — instead of `"siftlode.x"` string literals scattered across
|
||||
// files. Parametric (per-user) keys are functions. All keys are namespaced `siftlode.*`.
|
||||
export const LS = {
|
||||
theme: "siftlode.theme",
|
||||
sidebarLayout: "siftlode.sidebarLayout",
|
||||
hints: "siftlode.hints",
|
||||
lang: "siftlode.lang",
|
||||
filters: "siftlode.filters",
|
||||
page: "siftlode.page",
|
||||
perfMode: "siftlode.perfMode",
|
||||
channelFilter: "siftlode.channelFilter",
|
||||
channelsView: "siftlode.channelsView",
|
||||
settingsTab: "siftlode.settingsTab",
|
||||
statsTab: "siftlode.statsTab",
|
||||
adminUsersTab: "siftlode.adminUsersTab",
|
||||
navCollapsed: "siftlode.navCollapsed",
|
||||
playlist: "siftlode.playlist",
|
||||
plSort: "siftlode.plSort",
|
||||
notifHistory: "siftlode.notifications",
|
||||
notifSettings: "siftlode.notifSettings",
|
||||
onboardingDismissed: "siftlode.onboarding.dismissed",
|
||||
seenVersion: "siftlode.seenVersion",
|
||||
chatDock: (userId: number) => `siftlode.chatDock.${userId}`,
|
||||
} as const;
|
||||
|
||||
// --- typed read/write helpers (do the JSON + try/catch once) ----------------------------
|
||||
|
||||
/** Read an object-shaped value merged over `defaults` (so missing/added keys are tolerated),
|
||||
* falling back to `defaults` on missing or corrupt data. */
|
||||
export function readMerged<T extends object>(key: string, defaults: T): T {
|
||||
try {
|
||||
return { ...defaults, ...JSON.parse(localStorage.getItem(key) || "{}") };
|
||||
} catch {
|
||||
return { ...defaults };
|
||||
}
|
||||
}
|
||||
|
||||
/** Read any JSON value (arrays/scalars), falling back on missing or corrupt data. */
|
||||
export function readJSON<T>(key: string, fallback: T): T {
|
||||
try {
|
||||
const raw = localStorage.getItem(key);
|
||||
return raw == null ? fallback : (JSON.parse(raw) as T);
|
||||
} catch {
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
/** Persist a value as JSON, swallowing quota/serialization errors. */
|
||||
export function writeJSON(key: string, value: unknown): void {
|
||||
try {
|
||||
localStorage.setItem(key, JSON.stringify(value));
|
||||
} catch {
|
||||
/* ignore quota / serialization errors */
|
||||
}
|
||||
}
|
||||
|
||||
// --- reactive persisted string state ----------------------------------------------------
|
||||
|
||||
/** A `useState` whose string value is mirrored to localStorage, so it survives a reload (F5).
|
||||
* Validation is deferred to the caller (clamp at render) so it can be used before an
|
||||
* async-loaded option list is known, keeping hook order stable. Generalizes the per-component
|
||||
* "persisted active tab" pattern (Settings, Stats, admin pages, the channel filter…). */
|
||||
export function usePersistedState(
|
||||
key: string,
|
||||
fallback = "",
|
||||
): [string, (value: string) => void] {
|
||||
const [value, setValue] = useState<string>(() => localStorage.getItem(key) ?? fallback);
|
||||
const set = (next: string) => {
|
||||
setValue(next);
|
||||
try {
|
||||
localStorage.setItem(key, next);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
};
|
||||
return [value, set];
|
||||
}
|
||||
33
frontend/src/lib/store.ts
Normal file
33
frontend/src/lib/store.ts
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
import { useSyncExternalStore } from "react";
|
||||
|
||||
// A minimal observable value shared across React components AND non-React code (the api
|
||||
// layer, websocket handlers, etc.). Replaces the hand-rolled `listeners`/`subscribe`/`emit`
|
||||
// triad that several modules each re-implemented slightly differently.
|
||||
export interface Store<T> {
|
||||
get(): T;
|
||||
set(next: T | ((prev: T) => T)): void;
|
||||
subscribe(listener: () => void): () => void;
|
||||
/** Subscribe from a component; re-renders on every change. */
|
||||
use(): T;
|
||||
}
|
||||
|
||||
export function createStore<T>(initial: T): Store<T> {
|
||||
let value = initial;
|
||||
const listeners = new Set<() => void>();
|
||||
const get = () => value;
|
||||
const subscribe = (l: () => void) => {
|
||||
listeners.add(l);
|
||||
return () => {
|
||||
listeners.delete(l);
|
||||
};
|
||||
};
|
||||
return {
|
||||
get,
|
||||
set(next) {
|
||||
value = typeof next === "function" ? (next as (p: T) => T)(value) : next;
|
||||
listeners.forEach((l) => l());
|
||||
},
|
||||
subscribe,
|
||||
use: () => useSyncExternalStore(subscribe, get, get),
|
||||
};
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
32
frontend/src/lib/useDismiss.ts
Normal file
32
frontend/src/lib/useDismiss.ts
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
import { useEffect, type RefObject } from "react";
|
||||
|
||||
/** While `active`, close (call `onClose`) on Escape or a mousedown outside ALL of the given
|
||||
* element(s). The reusable half of the popover/dropdown pattern that several components each
|
||||
* hand-rolled; positioning stays with the caller (it varies per popover). Pass the panel ref
|
||||
* (and optionally a trigger ref so clicking the trigger doesn't immediately re-close). */
|
||||
export function useDismiss(
|
||||
active: boolean,
|
||||
onClose: () => void,
|
||||
refs: RefObject<HTMLElement | null> | RefObject<HTMLElement | null>[],
|
||||
): void {
|
||||
useEffect(() => {
|
||||
if (!active) return;
|
||||
const list = Array.isArray(refs) ? refs : [refs];
|
||||
const onDown = (e: MouseEvent) => {
|
||||
const target = e.target as Node;
|
||||
if (!list.some((r) => r.current?.contains(target))) onClose();
|
||||
};
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") onClose();
|
||||
};
|
||||
document.addEventListener("mousedown", onDown);
|
||||
document.addEventListener("keydown", onKey);
|
||||
return () => {
|
||||
document.removeEventListener("mousedown", onDown);
|
||||
document.removeEventListener("keydown", onKey);
|
||||
};
|
||||
// Matches the original effects: (re)subscribe only when open/closed toggles. Refs are
|
||||
// stable and onClose is read fresh from this run's closure.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [active]);
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue