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 logging
|
||||||
import re
|
|
||||||
import secrets
|
import secrets
|
||||||
from datetime import datetime, timedelta, timezone
|
from datetime import datetime, timedelta, timezone
|
||||||
|
|
||||||
|
|
@ -8,7 +6,7 @@ import httpx
|
||||||
from authlib.integrations.starlette_client import OAuth, OAuthError
|
from authlib.integrations.starlette_client import OAuth, OAuthError
|
||||||
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Request
|
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Request
|
||||||
from fastapi.responses import JSONResponse, RedirectResponse
|
from fastapi.responses import JSONResponse, RedirectResponse
|
||||||
from sqlalchemy import delete, select
|
from sqlalchemy import delete, func, select
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app import email as email_mod
|
from app import email as email_mod
|
||||||
|
|
@ -17,7 +15,8 @@ from app.config import settings
|
||||||
from app.db import get_db
|
from app.db import get_db
|
||||||
from app.models import AuthToken, DemoWhitelist, Invite, OAuthToken, User
|
from app.models import AuthToken, DemoWhitelist, Invite, OAuthToken, User
|
||||||
from app.ratelimit import RateLimiter
|
from app.ratelimit import RateLimiter
|
||||||
from app.security import decrypt, encrypt, hash_password, verify_password
|
from app.security import decrypt, encrypt, hash_password, hash_token, verify_password
|
||||||
|
from app.utils import valid_email
|
||||||
|
|
||||||
# Email+password auth tuning.
|
# Email+password auth tuning.
|
||||||
PASSWORD_MIN_LEN = 10
|
PASSWORD_MIN_LEN = 10
|
||||||
|
|
@ -43,8 +42,6 @@ def _notify_suspended(background: BackgroundTasks, email: str) -> None:
|
||||||
if _suspend_email_limiter.allow(email):
|
if _suspend_email_limiter.allow(email):
|
||||||
background.add_task(email_mod.send_account_suspended, email, operator_contact())
|
background.add_task(email_mod.send_account_suspended, email, operator_contact())
|
||||||
|
|
||||||
_EMAIL_RE = re.compile(r"^[^@\s]+@[^@\s]+\.[^@\s]+$")
|
|
||||||
|
|
||||||
# The single shared demo account's stable identity. The row is created lazily on first
|
# The single shared demo account's stable identity. The row is created lazily on first
|
||||||
# demo login (see get_or_create_demo_user); these are just its sentinel keys.
|
# demo login (see get_or_create_demo_user); these are just its sentinel keys.
|
||||||
DEMO_GOOGLE_SUB = "__demo__"
|
DEMO_GOOGLE_SUB = "__demo__"
|
||||||
|
|
@ -184,7 +181,7 @@ def upsert_pending_invite(db: Session, email: str) -> Invite | None:
|
||||||
"""Idempotently record an access request. Returns the Invite if a *new* pending row
|
"""Idempotently record an access request. Returns the Invite if a *new* pending row
|
||||||
was created (so the caller can notify admins), else None for already-known emails."""
|
was created (so the caller can notify admins), else None for already-known emails."""
|
||||||
email = (email or "").lower()
|
email = (email or "").lower()
|
||||||
if not _EMAIL_RE.match(email):
|
if not valid_email(email):
|
||||||
return None
|
return None
|
||||||
inv = db.execute(select(Invite).where(Invite.email == email)).scalar_one_or_none()
|
inv = db.execute(select(Invite).where(Invite.email == email)).scalar_one_or_none()
|
||||||
if inv is not None:
|
if inv is not None:
|
||||||
|
|
@ -443,7 +440,7 @@ async def request_access(
|
||||||
"""Public: ask for access. Idempotent — repeat requests for the same email don't
|
"""Public: ask for access. Idempotent — repeat requests for the same email don't
|
||||||
duplicate or re-spam admins (upsert returns None for an already-pending row)."""
|
duplicate or re-spam admins (upsert returns None for an already-pending row)."""
|
||||||
email = (payload.get("email") or "").strip().lower()
|
email = (payload.get("email") or "").strip().lower()
|
||||||
if not _EMAIL_RE.match(email):
|
if not valid_email(email):
|
||||||
raise HTTPException(status_code=400, detail="Enter a valid email address.")
|
raise HTTPException(status_code=400, detail="Enter a valid email address.")
|
||||||
if is_allowed(db, email):
|
if is_allowed(db, email):
|
||||||
return {"status": "approved"} # already allowed — just sign in
|
return {"status": "approved"} # already allowed — just sign in
|
||||||
|
|
@ -465,7 +462,7 @@ def demo_login(payload: dict, request: Request, db: Session = Depends(get_db)) -
|
||||||
if not _demo_limiter.allow(_client_ip(request)):
|
if not _demo_limiter.allow(_client_ip(request)):
|
||||||
return {"authenticated": False}
|
return {"authenticated": False}
|
||||||
email = (payload.get("email") or "").strip().lower()
|
email = (payload.get("email") or "").strip().lower()
|
||||||
if _EMAIL_RE.match(email) and is_demo_allowed(db, email):
|
if valid_email(email) and is_demo_allowed(db, email):
|
||||||
demo = get_or_create_demo_user(db)
|
demo = get_or_create_demo_user(db)
|
||||||
request.session["user_id"] = demo.id
|
request.session["user_id"] = demo.id
|
||||||
log.info("Demo login (key=%s, demo_id=%s)", email, demo.id)
|
log.info("Demo login (key=%s, demo_id=%s)", email, demo.id)
|
||||||
|
|
@ -487,10 +484,6 @@ def _app_base() -> str:
|
||||||
return settings.app_base
|
return settings.app_base
|
||||||
|
|
||||||
|
|
||||||
def _hash_token(raw: str) -> str:
|
|
||||||
return hashlib.sha256(raw.encode()).hexdigest()
|
|
||||||
|
|
||||||
|
|
||||||
def _issue_token(db: Session, user: User, kind: str, ttl: timedelta) -> str:
|
def _issue_token(db: Session, user: User, kind: str, ttl: timedelta) -> str:
|
||||||
"""Create a single-use token of `kind`; only its hash is stored. Returns the raw token
|
"""Create a single-use token of `kind`; only its hash is stored. Returns the raw token
|
||||||
(goes only into the emailed link)."""
|
(goes only into the emailed link)."""
|
||||||
|
|
@ -499,7 +492,7 @@ def _issue_token(db: Session, user: User, kind: str, ttl: timedelta) -> str:
|
||||||
AuthToken(
|
AuthToken(
|
||||||
user_id=user.id,
|
user_id=user.id,
|
||||||
kind=kind,
|
kind=kind,
|
||||||
token_hash=_hash_token(raw),
|
token_hash=hash_token(raw),
|
||||||
expires_at=datetime.now(timezone.utc) + ttl,
|
expires_at=datetime.now(timezone.utc) + ttl,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
@ -513,7 +506,7 @@ def _consume_token(db: Session, raw: str | None, kind: str) -> User | None:
|
||||||
return None
|
return None
|
||||||
row = db.execute(
|
row = db.execute(
|
||||||
select(AuthToken).where(
|
select(AuthToken).where(
|
||||||
AuthToken.token_hash == _hash_token(raw), AuthToken.kind == kind
|
AuthToken.token_hash == hash_token(raw), AuthToken.kind == kind
|
||||||
)
|
)
|
||||||
).scalar_one_or_none()
|
).scalar_one_or_none()
|
||||||
if row is None or row.used_at is not None or row.expires_at < datetime.now(timezone.utc):
|
if row is None or row.used_at is not None or row.expires_at < datetime.now(timezone.utc):
|
||||||
|
|
@ -537,7 +530,7 @@ def register(
|
||||||
email = (payload.get("email") or "").strip().lower()
|
email = (payload.get("email") or "").strip().lower()
|
||||||
password = payload.get("password") or ""
|
password = payload.get("password") or ""
|
||||||
# Input validation is safe to reveal (independent of whether the email exists).
|
# Input validation is safe to reveal (independent of whether the email exists).
|
||||||
if not _EMAIL_RE.match(email):
|
if not valid_email(email):
|
||||||
raise HTTPException(status_code=400, detail="Enter a valid email address.")
|
raise HTTPException(status_code=400, detail="Enter a valid email address.")
|
||||||
if len(password) < PASSWORD_MIN_LEN:
|
if len(password) < PASSWORD_MIN_LEN:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
|
|
@ -633,7 +626,7 @@ def password_reset_request(
|
||||||
if not _reset_limiter.allow(_client_ip(request)):
|
if not _reset_limiter.allow(_client_ip(request)):
|
||||||
return {"status": "ok"}
|
return {"status": "ok"}
|
||||||
email = (payload.get("email") or "").strip().lower()
|
email = (payload.get("email") or "").strip().lower()
|
||||||
if _EMAIL_RE.match(email):
|
if valid_email(email):
|
||||||
user = db.execute(select(User).where(User.email == email)).scalar_one_or_none()
|
user = db.execute(select(User).where(User.email == email)).scalar_one_or_none()
|
||||||
if user is not None and user.password_hash and not user.is_demo:
|
if user is not None and user.password_hash and not user.is_demo:
|
||||||
raw = _issue_token(db, user, "reset", RESET_TTL)
|
raw = _issue_token(db, user, "reset", RESET_TTL)
|
||||||
|
|
@ -694,6 +687,23 @@ def require_human(user: User = Depends(current_user)) -> User:
|
||||||
return user
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
def admin_user(user: User = Depends(current_user)) -> User:
|
||||||
|
"""Dependency: require the signed-in user to be an admin. Single source of truth for the
|
||||||
|
admin gate — every admin route/action depends on this rather than re-checking the role."""
|
||||||
|
if user.role != "admin":
|
||||||
|
raise HTTPException(status_code=403, detail="Admin only")
|
||||||
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
def count_admins(db: Session, *, active_only: bool = False) -> int:
|
||||||
|
"""How many admins exist (optionally only un-suspended ones). Used by the 'can't remove /
|
||||||
|
suspend / delete the last admin' guards so they don't each re-spell the count query."""
|
||||||
|
q = select(func.count()).select_from(User).where(User.role == "admin")
|
||||||
|
if active_only:
|
||||||
|
q = q.where(User.is_suspended.is_(False))
|
||||||
|
return db.scalar(q) or 0
|
||||||
|
|
||||||
|
|
||||||
@router.get("/link")
|
@router.get("/link")
|
||||||
async def link_google(
|
async def link_google(
|
||||||
request: Request,
|
request: Request,
|
||||||
|
|
|
||||||
|
|
@ -19,7 +19,24 @@ from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
from app.db import Base
|
from app.db import Base
|
||||||
|
|
||||||
|
|
||||||
class User(Base):
|
class TimestampMixin:
|
||||||
|
"""Adds a server-defaulted `created_at`. Models that index created_at or order it
|
||||||
|
differently (QuotaEvent, Notification, Message) keep their own column on purpose."""
|
||||||
|
|
||||||
|
created_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True), server_default=func.now()
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class UpdatedAtMixin:
|
||||||
|
"""Adds an `updated_at` that server-defaults on insert and bumps on every UPDATE."""
|
||||||
|
|
||||||
|
updated_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class User(Base, TimestampMixin):
|
||||||
__tablename__ = "users"
|
__tablename__ = "users"
|
||||||
|
|
||||||
id: Mapped[int] = mapped_column(primary_key=True)
|
id: Mapped[int] = mapped_column(primary_key=True)
|
||||||
|
|
@ -57,9 +74,6 @@ class User(Base):
|
||||||
)
|
)
|
||||||
# Free-form UI preferences (theme, color scheme, font scale, default filters…).
|
# Free-form UI preferences (theme, color scheme, font scale, default filters…).
|
||||||
preferences: Mapped[dict | None] = mapped_column(JSON)
|
preferences: Mapped[dict | None] = mapped_column(JSON)
|
||||||
created_at: Mapped[datetime] = mapped_column(
|
|
||||||
DateTime(timezone=True), server_default=func.now()
|
|
||||||
)
|
|
||||||
|
|
||||||
token: Mapped["OAuthToken | None"] = relationship(
|
token: Mapped["OAuthToken | None"] = relationship(
|
||||||
back_populates="user", uselist=False, cascade="all, delete-orphan"
|
back_populates="user", uselist=False, cascade="all, delete-orphan"
|
||||||
|
|
@ -69,7 +83,7 @@ class User(Base):
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
class OAuthToken(Base):
|
class OAuthToken(Base, UpdatedAtMixin):
|
||||||
__tablename__ = "oauth_tokens"
|
__tablename__ = "oauth_tokens"
|
||||||
|
|
||||||
id: Mapped[int] = mapped_column(primary_key=True)
|
id: Mapped[int] = mapped_column(primary_key=True)
|
||||||
|
|
@ -81,14 +95,11 @@ class OAuthToken(Base):
|
||||||
access_token: Mapped[str | None] = mapped_column(String)
|
access_token: Mapped[str | None] = mapped_column(String)
|
||||||
expiry: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
expiry: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||||
scopes: Mapped[str | None] = mapped_column(String)
|
scopes: Mapped[str | None] = mapped_column(String)
|
||||||
updated_at: Mapped[datetime] = mapped_column(
|
|
||||||
DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
|
|
||||||
)
|
|
||||||
|
|
||||||
user: Mapped["User"] = relationship(back_populates="token")
|
user: Mapped["User"] = relationship(back_populates="token")
|
||||||
|
|
||||||
|
|
||||||
class AuthToken(Base):
|
class AuthToken(Base, TimestampMixin):
|
||||||
"""Single-use, expiring token for email verification or password reset. Only the SHA-256
|
"""Single-use, expiring token for email verification or password reset. Only the SHA-256
|
||||||
HASH of the token is stored, so a DB leak can't be used to verify/reset; the raw token
|
HASH of the token is stored, so a DB leak can't be used to verify/reset; the raw token
|
||||||
lives only in the emailed link. Consumed (used_at set) on first valid use."""
|
lives only in the emailed link. Consumed (used_at set) on first valid use."""
|
||||||
|
|
@ -103,9 +114,6 @@ class AuthToken(Base):
|
||||||
token_hash: Mapped[str] = mapped_column(String(64), unique=True, index=True)
|
token_hash: Mapped[str] = mapped_column(String(64), unique=True, index=True)
|
||||||
expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True))
|
expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True))
|
||||||
used_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
used_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||||
created_at: Mapped[datetime] = mapped_column(
|
|
||||||
DateTime(timezone=True), server_default=func.now()
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class Invite(Base):
|
class Invite(Base):
|
||||||
|
|
@ -128,7 +136,7 @@ class Invite(Base):
|
||||||
decided_by: Mapped[str | None] = mapped_column(String(320)) # admin email
|
decided_by: Mapped[str | None] = mapped_column(String(320)) # admin email
|
||||||
|
|
||||||
|
|
||||||
class DemoWhitelist(Base):
|
class DemoWhitelist(Base, TimestampMixin):
|
||||||
"""Emails that may enter the shared demo account from the login page (no Google OAuth).
|
"""Emails that may enter the shared demo account from the login page (no Google OAuth).
|
||||||
A hidden, admin-curated list of "keys to the same door" — every entry logs into the one
|
A hidden, admin-curated list of "keys to the same door" — every entry logs into the one
|
||||||
shared demo user. Distinct from Invite (which gates real Google sign-in)."""
|
shared demo user. Distinct from Invite (which gates real Google sign-in)."""
|
||||||
|
|
@ -139,12 +147,9 @@ class DemoWhitelist(Base):
|
||||||
email: Mapped[str] = mapped_column(String(320), unique=True, index=True)
|
email: Mapped[str] = mapped_column(String(320), unique=True, index=True)
|
||||||
note: Mapped[str | None] = mapped_column(Text)
|
note: Mapped[str | None] = mapped_column(Text)
|
||||||
added_by: Mapped[str | None] = mapped_column(String(320)) # admin email
|
added_by: Mapped[str | None] = mapped_column(String(320)) # admin email
|
||||||
created_at: Mapped[datetime] = mapped_column(
|
|
||||||
DateTime(timezone=True), server_default=func.now()
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class Channel(Base):
|
class Channel(Base, TimestampMixin):
|
||||||
"""A YouTube channel. Shared across all users (one channel's videos are the same
|
"""A YouTube channel. Shared across all users (one channel's videos are the same
|
||||||
for everyone), so its expensive metadata is fetched and stored only once."""
|
for everyone), so its expensive metadata is fetched and stored only once."""
|
||||||
|
|
||||||
|
|
@ -170,15 +175,12 @@ class Channel(Base):
|
||||||
backfill_done: Mapped[bool] = mapped_column(
|
backfill_done: Mapped[bool] = mapped_column(
|
||||||
Boolean, default=False, server_default="false"
|
Boolean, default=False, server_default="false"
|
||||||
)
|
)
|
||||||
created_at: Mapped[datetime] = mapped_column(
|
|
||||||
DateTime(timezone=True), server_default=func.now()
|
|
||||||
)
|
|
||||||
|
|
||||||
videos: Mapped[list["Video"]] = relationship(back_populates="channel")
|
videos: Mapped[list["Video"]] = relationship(back_populates="channel")
|
||||||
subscriptions: Mapped[list["Subscription"]] = relationship(back_populates="channel")
|
subscriptions: Mapped[list["Subscription"]] = relationship(back_populates="channel")
|
||||||
|
|
||||||
|
|
||||||
class Subscription(Base):
|
class Subscription(Base, TimestampMixin):
|
||||||
"""Per-user link to a channel, with the user's own priority/visibility overrides."""
|
"""Per-user link to a channel, with the user's own priority/visibility overrides."""
|
||||||
|
|
||||||
__tablename__ = "subscriptions"
|
__tablename__ = "subscriptions"
|
||||||
|
|
@ -202,15 +204,12 @@ class Subscription(Base):
|
||||||
Boolean, default=False, server_default="false"
|
Boolean, default=False, server_default="false"
|
||||||
)
|
)
|
||||||
subscribed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
subscribed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||||
created_at: Mapped[datetime] = mapped_column(
|
|
||||||
DateTime(timezone=True), server_default=func.now()
|
|
||||||
)
|
|
||||||
|
|
||||||
user: Mapped["User"] = relationship(back_populates="subscriptions")
|
user: Mapped["User"] = relationship(back_populates="subscriptions")
|
||||||
channel: Mapped["Channel"] = relationship(back_populates="subscriptions")
|
channel: Mapped["Channel"] = relationship(back_populates="subscriptions")
|
||||||
|
|
||||||
|
|
||||||
class Video(Base):
|
class Video(Base, TimestampMixin):
|
||||||
"""A YouTube video. Shared across users; per-user state lives elsewhere."""
|
"""A YouTube video. Shared across users; per-user state lives elsewhere."""
|
||||||
|
|
||||||
__tablename__ = "videos"
|
__tablename__ = "videos"
|
||||||
|
|
@ -260,14 +259,11 @@ class Video(Base):
|
||||||
DateTime(timezone=True), index=True
|
DateTime(timezone=True), index=True
|
||||||
)
|
)
|
||||||
unavailable_reason: Mapped[str | None] = mapped_column(String(24))
|
unavailable_reason: Mapped[str | None] = mapped_column(String(24))
|
||||||
created_at: Mapped[datetime] = mapped_column(
|
|
||||||
DateTime(timezone=True), server_default=func.now()
|
|
||||||
)
|
|
||||||
|
|
||||||
channel: Mapped["Channel"] = relationship(back_populates="videos")
|
channel: Mapped["Channel"] = relationship(back_populates="videos")
|
||||||
|
|
||||||
|
|
||||||
class Tag(Base):
|
class Tag(Base, TimestampMixin):
|
||||||
"""A label. System tags (user_id NULL) are computed automatically and shared by all
|
"""A label. System tags (user_id NULL) are computed automatically and shared by all
|
||||||
users; user tags (user_id set) are personal. category groups them in the UI."""
|
users; user tags (user_id set) are personal. category groups them in the UI."""
|
||||||
|
|
||||||
|
|
@ -283,9 +279,6 @@ class Tag(Base):
|
||||||
category: Mapped[str] = mapped_column(
|
category: Mapped[str] = mapped_column(
|
||||||
String(16), default="other", server_default="other"
|
String(16), default="other", server_default="other"
|
||||||
)
|
)
|
||||||
created_at: Mapped[datetime] = mapped_column(
|
|
||||||
DateTime(timezone=True), server_default=func.now()
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class ChannelTag(Base):
|
class ChannelTag(Base):
|
||||||
|
|
@ -312,7 +305,7 @@ class ChannelTag(Base):
|
||||||
confidence: Mapped[float | None] = mapped_column(Float)
|
confidence: Mapped[float | None] = mapped_column(Float)
|
||||||
|
|
||||||
|
|
||||||
class VideoState(Base):
|
class VideoState(Base, UpdatedAtMixin):
|
||||||
"""Per-user watch state for a video. Rows exist only for non-default states; the
|
"""Per-user watch state for a video. Rows exist only for non-default states; the
|
||||||
absence of a row means 'new/unseen'."""
|
absence of a row means 'new/unseen'."""
|
||||||
|
|
||||||
|
|
@ -326,7 +319,7 @@ class VideoState(Base):
|
||||||
video_id: Mapped[str] = mapped_column(
|
video_id: Mapped[str] = mapped_column(
|
||||||
ForeignKey("videos.id", ondelete="CASCADE"), index=True
|
ForeignKey("videos.id", ondelete="CASCADE"), index=True
|
||||||
)
|
)
|
||||||
# new | watched | saved | hidden
|
# new | watched | hidden ("saved" is now Watch-later playlist membership, not a status)
|
||||||
status: Mapped[str] = mapped_column(String(12), default="new", server_default="new")
|
status: Mapped[str] = mapped_column(String(12), default="new", server_default="new")
|
||||||
watched_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
watched_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||||
# Resume position (seconds into the video) for the in-app player. Orthogonal to
|
# Resume position (seconds into the video) for the in-app player. Orthogonal to
|
||||||
|
|
@ -337,9 +330,6 @@ class VideoState(Base):
|
||||||
Integer, default=0, server_default="0", nullable=False
|
Integer, default=0, server_default="0", nullable=False
|
||||||
)
|
)
|
||||||
progress_updated_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
progress_updated_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||||
updated_at: Mapped[datetime] = mapped_column(
|
|
||||||
DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class ApiQuotaUsage(Base):
|
class ApiQuotaUsage(Base):
|
||||||
|
|
@ -422,7 +412,7 @@ class SystemConfig(Base):
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
class Playlist(Base):
|
class Playlist(Base, TimestampMixin, UpdatedAtMixin):
|
||||||
"""A per-user named, ordered collection of videos from the shared catalog.
|
"""A per-user named, ordered collection of videos from the shared catalog.
|
||||||
|
|
||||||
`kind`: 'user' (normal) or 'watch_later' (built-in, undeletable, one per user).
|
`kind`: 'user' (normal) or 'watch_later' (built-in, undeletable, one per user).
|
||||||
|
|
@ -450,12 +440,6 @@ class Playlist(Base):
|
||||||
# baseline `dirty` is derived against. NULL = no baseline yet (treated as dirty).
|
# baseline `dirty` is derived against. NULL = no baseline yet (treated as dirty).
|
||||||
synced_fingerprint: Mapped[str | None] = mapped_column(String)
|
synced_fingerprint: Mapped[str | None] = mapped_column(String)
|
||||||
position: Mapped[int] = mapped_column(Integer, default=0, server_default="0")
|
position: Mapped[int] = mapped_column(Integer, default=0, server_default="0")
|
||||||
created_at: Mapped[datetime] = mapped_column(
|
|
||||||
DateTime(timezone=True), server_default=func.now()
|
|
||||||
)
|
|
||||||
updated_at: Mapped[datetime] = mapped_column(
|
|
||||||
DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
|
|
||||||
)
|
|
||||||
|
|
||||||
items: Mapped[list["PlaylistItem"]] = relationship(
|
items: Mapped[list["PlaylistItem"]] = relationship(
|
||||||
back_populates="playlist",
|
back_populates="playlist",
|
||||||
|
|
@ -560,7 +544,7 @@ class Message(Base):
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
class MessageKey(Base):
|
class MessageKey(Base, TimestampMixin):
|
||||||
"""A user's end-to-end-encryption key material for direct messaging (phase 2).
|
"""A user's end-to-end-encryption key material for direct messaging (phase 2).
|
||||||
|
|
||||||
One row per user. `public_key` (base64 SPKI, ECDH P-256) is shared with others so they can
|
One row per user. `public_key` (base64 SPKI, ECDH P-256) is shared with others so they can
|
||||||
|
|
@ -581,6 +565,3 @@ class MessageKey(Base):
|
||||||
salt: Mapped[str] = mapped_column(String(64))
|
salt: Mapped[str] = mapped_column(String(64))
|
||||||
wrap_iv: Mapped[str] = mapped_column(String(32))
|
wrap_iv: Mapped[str] = mapped_column(String(32))
|
||||||
key_check: Mapped[str] = mapped_column(Text)
|
key_check: Mapped[str] = mapped_column(Text)
|
||||||
created_at: Mapped[datetime] = mapped_column(
|
|
||||||
DateTime(timezone=True), server_default=func.now()
|
|
||||||
)
|
|
||||||
|
|
|
||||||
|
|
@ -66,6 +66,26 @@ def attribute(actor_id: int | None, action: str):
|
||||||
_action.reset(t2)
|
_action.reset(t2)
|
||||||
|
|
||||||
|
|
||||||
|
class Measure:
|
||||||
|
"""Result handle from `measured()` — read .used / .remaining after the block exits."""
|
||||||
|
|
||||||
|
used = 0
|
||||||
|
remaining = 0
|
||||||
|
|
||||||
|
|
||||||
|
@contextlib.contextmanager
|
||||||
|
def measured(db: Session, actor_id: int | None, action: str):
|
||||||
|
"""Like `attribute()`, but also reports how much quota the block spent. Yields a `Measure`
|
||||||
|
whose `.used` (units charged inside the block) and `.remaining` (today's remaining after)
|
||||||
|
are populated on exit — so manual sync routes don't each re-spell the before/after diff."""
|
||||||
|
m = Measure()
|
||||||
|
before = units_used_today(db)
|
||||||
|
with attribute(actor_id, action):
|
||||||
|
yield m
|
||||||
|
m.used = units_used_today(db) - before
|
||||||
|
m.remaining = remaining_today(db)
|
||||||
|
|
||||||
|
|
||||||
def pacific_today():
|
def pacific_today():
|
||||||
return datetime.now(_PACIFIC).date()
|
return datetime.now(_PACIFIC).date()
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,5 @@
|
||||||
"""Admin-only onboarding: review access requests (Invites), approve/deny, manual add.
|
"""Admin-only onboarding: review access requests (Invites), approve/deny, manual add.
|
||||||
Also the demo-account admin surface: the demo email whitelist + a manual state reset."""
|
Also the demo-account admin surface: the demo email whitelist + a manual state reset."""
|
||||||
import re
|
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException
|
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException
|
||||||
|
|
@ -8,7 +7,13 @@ from sqlalchemy import delete, func, select
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app import email as email_mod
|
from app import email as email_mod
|
||||||
from app.auth import current_user, get_or_create_demo_user, operator_contact, purge_user
|
from app.auth import (
|
||||||
|
admin_user,
|
||||||
|
count_admins,
|
||||||
|
get_or_create_demo_user,
|
||||||
|
operator_contact,
|
||||||
|
purge_user,
|
||||||
|
)
|
||||||
from app.db import get_db
|
from app.db import get_db
|
||||||
from app.models import (
|
from app.models import (
|
||||||
DemoWhitelist,
|
DemoWhitelist,
|
||||||
|
|
@ -20,17 +25,10 @@ from app.models import (
|
||||||
Video,
|
Video,
|
||||||
VideoState,
|
VideoState,
|
||||||
)
|
)
|
||||||
|
from app.utils import valid_email
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/admin", tags=["admin"])
|
router = APIRouter(prefix="/api/admin", tags=["admin"])
|
||||||
|
|
||||||
_EMAIL_RE = re.compile(r"^[^@\s]+@[^@\s]+\.[^@\s]+$")
|
|
||||||
|
|
||||||
|
|
||||||
def admin_user(user: User = Depends(current_user)) -> User:
|
|
||||||
if user.role != "admin":
|
|
||||||
raise HTTPException(status_code=403, detail="Admin only")
|
|
||||||
return user
|
|
||||||
|
|
||||||
|
|
||||||
def _serialize(inv: Invite) -> dict:
|
def _serialize(inv: Invite) -> dict:
|
||||||
return {
|
return {
|
||||||
|
|
@ -110,7 +108,7 @@ def add_invite(
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""Manually whitelist an email (approved straight away). Convenience for the admin."""
|
"""Manually whitelist an email (approved straight away). Convenience for the admin."""
|
||||||
email = (payload.get("email") or "").strip().lower()
|
email = (payload.get("email") or "").strip().lower()
|
||||||
if "@" not in email:
|
if not valid_email(email):
|
||||||
raise HTTPException(status_code=400, detail="Enter a valid email address.")
|
raise HTTPException(status_code=400, detail="Enter a valid email address.")
|
||||||
inv = db.execute(select(Invite).where(Invite.email == email)).scalar_one_or_none()
|
inv = db.execute(select(Invite).where(Invite.email == email)).scalar_one_or_none()
|
||||||
if inv is None:
|
if inv is None:
|
||||||
|
|
@ -168,10 +166,8 @@ def set_user_role(
|
||||||
raise HTTPException(status_code=400, detail="The demo account's role can't be changed.")
|
raise HTTPException(status_code=400, detail="The demo account's role can't be changed.")
|
||||||
if target.id == admin.id:
|
if target.id == admin.id:
|
||||||
raise HTTPException(status_code=400, detail="You can't change your own role.")
|
raise HTTPException(status_code=400, detail="You can't change your own role.")
|
||||||
if target.role == "admin" and role == "user":
|
if target.role == "admin" and role == "user" and count_admins(db) <= 1:
|
||||||
admin_count = db.scalar(select(func.count()).select_from(User).where(User.role == "admin"))
|
raise HTTPException(status_code=400, detail="Can't remove the last admin.")
|
||||||
if (admin_count or 0) <= 1:
|
|
||||||
raise HTTPException(status_code=400, detail="Can't remove the last admin.")
|
|
||||||
changed = target.role != role
|
changed = target.role != role
|
||||||
target.role = role
|
target.role = role
|
||||||
db.commit()
|
db.commit()
|
||||||
|
|
@ -201,14 +197,13 @@ def set_user_suspended(
|
||||||
raise HTTPException(status_code=400, detail="The demo account can't be suspended.")
|
raise HTTPException(status_code=400, detail="The demo account can't be suspended.")
|
||||||
if target.id == admin.id:
|
if target.id == admin.id:
|
||||||
raise HTTPException(status_code=400, detail="You can't suspend your own account.")
|
raise HTTPException(status_code=400, detail="You can't suspend your own account.")
|
||||||
if suspended and target.role == "admin" and not target.is_suspended:
|
if (
|
||||||
active_admins = db.scalar(
|
suspended
|
||||||
select(func.count())
|
and target.role == "admin"
|
||||||
.select_from(User)
|
and not target.is_suspended
|
||||||
.where(User.role == "admin", User.is_suspended.is_(False))
|
and count_admins(db, active_only=True) <= 1
|
||||||
)
|
):
|
||||||
if (active_admins or 0) <= 1:
|
raise HTTPException(status_code=400, detail="Can't suspend the last active admin.")
|
||||||
raise HTTPException(status_code=400, detail="Can't suspend the last active admin.")
|
|
||||||
was_suspended = target.is_suspended
|
was_suspended = target.is_suspended
|
||||||
target.is_suspended = suspended
|
target.is_suspended = suspended
|
||||||
db.commit()
|
db.commit()
|
||||||
|
|
@ -238,12 +233,8 @@ def admin_delete_user(
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=400, detail="Delete your own account from Settings → Account."
|
status_code=400, detail="Delete your own account from Settings → Account."
|
||||||
)
|
)
|
||||||
if target.role == "admin":
|
if target.role == "admin" and count_admins(db) <= 1:
|
||||||
admin_count = db.scalar(
|
raise HTTPException(status_code=400, detail="Can't delete the last admin.")
|
||||||
select(func.count()).select_from(User).where(User.role == "admin")
|
|
||||||
)
|
|
||||||
if (admin_count or 0) <= 1:
|
|
||||||
raise HTTPException(status_code=400, detail="Can't delete the last admin.")
|
|
||||||
purge_user(db, target, background)
|
purge_user(db, target, background)
|
||||||
return {"deleted": user_id}
|
return {"deleted": user_id}
|
||||||
|
|
||||||
|
|
@ -281,7 +272,7 @@ def add_demo_whitelist(
|
||||||
"""Add an email that may enter the shared demo account from the login page (no Google
|
"""Add an email that may enter the shared demo account from the login page (no Google
|
||||||
sign-in). Idempotent — re-adding an existing email just returns it."""
|
sign-in). Idempotent — re-adding an existing email just returns it."""
|
||||||
email = (payload.get("email") or "").strip().lower()
|
email = (payload.get("email") or "").strip().lower()
|
||||||
if not _EMAIL_RE.match(email):
|
if not valid_email(email):
|
||||||
raise HTTPException(status_code=400, detail="Enter a valid email address.")
|
raise HTTPException(status_code=400, detail="Enter a valid email address.")
|
||||||
row = db.execute(
|
row = db.execute(
|
||||||
select(DemoWhitelist).where(DemoWhitelist.email == email)
|
select(DemoWhitelist).where(DemoWhitelist.email == email)
|
||||||
|
|
|
||||||
|
|
@ -62,6 +62,29 @@ def _saved_expr(user: User):
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def feed_columns(user: User, state) -> list:
|
||||||
|
"""The shared feed-row projection (joined Channel fields + per-user watch state +
|
||||||
|
`saved`), consumed by both the feed query and the playlist-detail query so a new column
|
||||||
|
is added in exactly one place. `state` is the caller's aliased VideoState (outer-joined)."""
|
||||||
|
return [
|
||||||
|
Video.id,
|
||||||
|
Video.title,
|
||||||
|
Video.channel_id,
|
||||||
|
Channel.title.label("channel_title"),
|
||||||
|
Channel.thumbnail_url.label("channel_thumbnail"),
|
||||||
|
Channel.handle.label("channel_handle"),
|
||||||
|
Video.published_at,
|
||||||
|
Video.thumbnail_url,
|
||||||
|
Video.duration_seconds,
|
||||||
|
Video.view_count,
|
||||||
|
Video.is_short,
|
||||||
|
Video.live_status,
|
||||||
|
func.coalesce(state.status, "new").label("status"),
|
||||||
|
func.coalesce(state.position_seconds, 0).label("position_seconds"),
|
||||||
|
_saved_expr(user),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
def _serialize(row) -> dict:
|
def _serialize(row) -> dict:
|
||||||
return {
|
return {
|
||||||
"id": row.id,
|
"id": row.id,
|
||||||
|
|
@ -115,23 +138,9 @@ def _filtered_query(
|
||||||
status_expr = func.coalesce(state.status, "new")
|
status_expr = func.coalesce(state.status, "new")
|
||||||
position_expr = func.coalesce(state.position_seconds, 0)
|
position_expr = func.coalesce(state.position_seconds, 0)
|
||||||
|
|
||||||
query = select(
|
query = select(*feed_columns(user, state)).join(
|
||||||
Video.id,
|
Channel, Channel.id == Video.channel_id
|
||||||
Video.title,
|
)
|
||||||
Video.channel_id,
|
|
||||||
Channel.title.label("channel_title"),
|
|
||||||
Channel.thumbnail_url.label("channel_thumbnail"),
|
|
||||||
Channel.handle.label("channel_handle"),
|
|
||||||
Video.published_at,
|
|
||||||
Video.thumbnail_url,
|
|
||||||
Video.duration_seconds,
|
|
||||||
Video.view_count,
|
|
||||||
Video.is_short,
|
|
||||||
Video.live_status,
|
|
||||||
status_expr.label("status"),
|
|
||||||
position_expr.label("position_seconds"),
|
|
||||||
_saved_expr(user),
|
|
||||||
).join(Channel, Channel.id == Video.channel_id)
|
|
||||||
|
|
||||||
if scope == "all":
|
if scope == "all":
|
||||||
# Whole shared catalog; subscription is optional (only for priority sort).
|
# Whole shared catalog; subscription is optional (only for priority sort).
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ from sqlalchemy import func, select
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app.auth import (
|
from app.auth import (
|
||||||
|
count_admins,
|
||||||
current_user,
|
current_user,
|
||||||
google_enabled,
|
google_enabled,
|
||||||
has_read_scope,
|
has_read_scope,
|
||||||
|
|
@ -111,13 +112,11 @@ def delete_my_account(
|
||||||
(that would lock everyone out of the admin surfaces)."""
|
(that would lock everyone out of the admin surfaces)."""
|
||||||
if user.is_demo:
|
if user.is_demo:
|
||||||
raise HTTPException(status_code=403, detail="The shared demo account can't be deleted.")
|
raise HTTPException(status_code=403, detail="The shared demo account can't be deleted.")
|
||||||
if user.role == "admin":
|
if user.role == "admin" and count_admins(db) <= 1:
|
||||||
admins = db.scalar(select(func.count()).select_from(User).where(User.role == "admin")) or 0
|
raise HTTPException(
|
||||||
if admins <= 1:
|
status_code=400,
|
||||||
raise HTTPException(
|
detail="You're the only admin — promote another admin before deleting your account.",
|
||||||
status_code=400,
|
)
|
||||||
detail="You're the only admin — promote another admin before deleting your account.",
|
|
||||||
)
|
|
||||||
user_id = user.id
|
user_id = user.id
|
||||||
# Full erasure (cascades) + access-request cleanup + Google-grant revocation; shared with the
|
# Full erasure (cascades) + access-request cleanup + Google-grant revocation; shared with the
|
||||||
# admin delete path. Capture the id first — `user` is detached once purge_user deletes the row.
|
# admin delete path. Capture the id first — `user` is detached once purge_user deletes the row.
|
||||||
|
|
|
||||||
|
|
@ -103,6 +103,7 @@ def _serialize_msg(m: Message) -> dict:
|
||||||
|
|
||||||
|
|
||||||
def _messageable() -> list:
|
def _messageable() -> list:
|
||||||
|
"""SQL clauses for "a real, active, non-suspended human" — for directory/recipient queries."""
|
||||||
return [
|
return [
|
||||||
User.is_demo.is_(False),
|
User.is_demo.is_(False),
|
||||||
User.is_active.is_(True),
|
User.is_active.is_(True),
|
||||||
|
|
@ -110,6 +111,11 @@ def _messageable() -> list:
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def is_messageable_user(u: User | None) -> bool:
|
||||||
|
"""Python mirror of `_messageable()` for an already-loaded row (WS auth, send recipient)."""
|
||||||
|
return bool(u and not u.is_demo and u.is_active and not u.is_suspended)
|
||||||
|
|
||||||
|
|
||||||
def ensure_welcome(db: Session, user: User) -> None:
|
def ensure_welcome(db: Session, user: User) -> None:
|
||||||
"""Create the one-time system welcome message for a (human) user the first time it's needed.
|
"""Create the one-time system welcome message for a (human) user the first time it's needed.
|
||||||
Idempotent: guarded on whether any system message already exists for them."""
|
Idempotent: guarded on whether any system message already exists for them."""
|
||||||
|
|
@ -315,7 +321,7 @@ def send_message(
|
||||||
if payload.recipient_id == user.id:
|
if payload.recipient_id == user.id:
|
||||||
raise HTTPException(status_code=400, detail="You can't message yourself.")
|
raise HTTPException(status_code=400, detail="You can't message yourself.")
|
||||||
recipient = db.get(User, payload.recipient_id)
|
recipient = db.get(User, payload.recipient_id)
|
||||||
if recipient is None or recipient.is_demo or not recipient.is_active or recipient.is_suspended:
|
if not is_messageable_user(recipient):
|
||||||
raise HTTPException(status_code=404, detail="Recipient not available.")
|
raise HTTPException(status_code=404, detail="Recipient not available.")
|
||||||
if db.get(MessageKey, recipient.id) is None:
|
if db.get(MessageKey, recipient.id) is None:
|
||||||
raise HTTPException(status_code=404, detail="That member hasn't set up messaging yet.")
|
raise HTTPException(status_code=404, detail="That member hasn't set up messaging yet.")
|
||||||
|
|
@ -363,8 +369,7 @@ async def messages_ws(ws: WebSocket) -> None:
|
||||||
return
|
return
|
||||||
db = SessionLocal()
|
db = SessionLocal()
|
||||||
try:
|
try:
|
||||||
u = db.get(User, uid)
|
ok = is_messageable_user(db.get(User, uid))
|
||||||
ok = bool(u and not u.is_demo and not u.is_suspended and u.is_active)
|
|
||||||
finally:
|
finally:
|
||||||
db.close()
|
db.close()
|
||||||
if not ok:
|
if not ok:
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,7 @@ from app import quota
|
||||||
from app.auth import current_user, has_read_scope, has_write_scope
|
from app.auth import current_user, has_read_scope, has_write_scope
|
||||||
from app.db import get_db
|
from app.db import get_db
|
||||||
from app.models import Channel, Playlist, PlaylistItem, User, Video, VideoState
|
from app.models import Channel, Playlist, PlaylistItem, User, Video, VideoState
|
||||||
from app.routes.feed import _saved_expr, _serialize
|
from app.routes.feed import _serialize, feed_columns
|
||||||
from app.sync.playlists import (
|
from app.sync.playlists import (
|
||||||
plan_push,
|
plan_push,
|
||||||
push_playlist,
|
push_playlist,
|
||||||
|
|
@ -29,6 +29,51 @@ def _own_playlist(db: Session, user: User, playlist_id: int) -> Playlist:
|
||||||
return pl
|
return pl
|
||||||
|
|
||||||
|
|
||||||
|
def _next_playlist_position(db: Session, user_id: int) -> int:
|
||||||
|
"""Append position for a new playlist in this user's rail."""
|
||||||
|
return (
|
||||||
|
db.scalar(
|
||||||
|
select(func.coalesce(func.max(Playlist.position), -1)).where(
|
||||||
|
Playlist.user_id == user_id
|
||||||
|
)
|
||||||
|
)
|
||||||
|
+ 1
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _next_item_position(db: Session, playlist_id: int) -> int:
|
||||||
|
"""Append position for a new item at the end of a playlist."""
|
||||||
|
return (
|
||||||
|
db.scalar(
|
||||||
|
select(func.coalesce(func.max(PlaylistItem.position), -1)).where(
|
||||||
|
PlaylistItem.playlist_id == playlist_id
|
||||||
|
)
|
||||||
|
)
|
||||||
|
+ 1
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _add_item(db: Session, pl: Playlist, video_id: str, *, mark_dirty: bool) -> bool:
|
||||||
|
"""Append a video to a playlist if not already present (idempotent). Returns whether it
|
||||||
|
was added. `mark_dirty` recomputes the YouTube-sync dirty flag (skip for Watch later)."""
|
||||||
|
exists = db.scalar(
|
||||||
|
select(PlaylistItem).where(
|
||||||
|
PlaylistItem.playlist_id == pl.id, PlaylistItem.video_id == video_id
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if exists is not None:
|
||||||
|
return False
|
||||||
|
db.add(
|
||||||
|
PlaylistItem(
|
||||||
|
playlist_id=pl.id, video_id=video_id, position=_next_item_position(db, pl.id)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if mark_dirty:
|
||||||
|
recompute_dirty(db, pl)
|
||||||
|
db.commit()
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
def _summary(
|
def _summary(
|
||||||
db: Session,
|
db: Session,
|
||||||
pl: Playlist,
|
pl: Playlist,
|
||||||
|
|
@ -142,15 +187,7 @@ def create_playlist(
|
||||||
name = (payload.get("name") or "").strip()
|
name = (payload.get("name") or "").strip()
|
||||||
if not name:
|
if not name:
|
||||||
raise HTTPException(status_code=400, detail="name is required")
|
raise HTTPException(status_code=400, detail="name is required")
|
||||||
next_pos = (
|
pl = Playlist(user_id=user.id, name=name, position=_next_playlist_position(db, user.id))
|
||||||
db.scalar(
|
|
||||||
select(func.coalesce(func.max(Playlist.position), -1)).where(
|
|
||||||
Playlist.user_id == user.id
|
|
||||||
)
|
|
||||||
)
|
|
||||||
+ 1
|
|
||||||
)
|
|
||||||
pl = Playlist(user_id=user.id, name=name, position=next_pos)
|
|
||||||
db.add(pl)
|
db.add(pl)
|
||||||
db.commit()
|
db.commit()
|
||||||
return _summary(db, pl, 0)
|
return _summary(db, pl, 0)
|
||||||
|
|
@ -164,16 +201,11 @@ def _watch_later(db: Session, user: User) -> Playlist:
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
if pl is None:
|
if pl is None:
|
||||||
next_pos = (
|
|
||||||
db.scalar(
|
|
||||||
select(func.coalesce(func.max(Playlist.position), -1)).where(
|
|
||||||
Playlist.user_id == user.id
|
|
||||||
)
|
|
||||||
)
|
|
||||||
+ 1
|
|
||||||
)
|
|
||||||
pl = Playlist(
|
pl = Playlist(
|
||||||
user_id=user.id, name="Watch later", kind="watch_later", position=next_pos
|
user_id=user.id,
|
||||||
|
name="Watch later",
|
||||||
|
kind="watch_later",
|
||||||
|
position=_next_playlist_position(db, user.id),
|
||||||
)
|
)
|
||||||
db.add(pl)
|
db.add(pl)
|
||||||
db.commit()
|
db.commit()
|
||||||
|
|
@ -191,22 +223,7 @@ def add_watch_later(
|
||||||
if not video_id or db.get(Video, video_id) is None:
|
if not video_id or db.get(Video, video_id) is None:
|
||||||
raise HTTPException(status_code=404, detail="Unknown video")
|
raise HTTPException(status_code=404, detail="Unknown video")
|
||||||
pl = _watch_later(db, user)
|
pl = _watch_later(db, user)
|
||||||
exists = db.scalar(
|
_add_item(db, pl, video_id, mark_dirty=False)
|
||||||
select(PlaylistItem).where(
|
|
||||||
PlaylistItem.playlist_id == pl.id, PlaylistItem.video_id == video_id
|
|
||||||
)
|
|
||||||
)
|
|
||||||
if exists is None:
|
|
||||||
next_pos = (
|
|
||||||
db.scalar(
|
|
||||||
select(func.coalesce(func.max(PlaylistItem.position), -1)).where(
|
|
||||||
PlaylistItem.playlist_id == pl.id
|
|
||||||
)
|
|
||||||
)
|
|
||||||
+ 1
|
|
||||||
)
|
|
||||||
db.add(PlaylistItem(playlist_id=pl.id, video_id=video_id, position=next_pos))
|
|
||||||
db.commit()
|
|
||||||
return {"saved": True, "playlist_id": pl.id}
|
return {"saved": True, "playlist_id": pl.id}
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -344,23 +361,7 @@ def get_playlist(
|
||||||
pl = _own_playlist(db, user, playlist_id)
|
pl = _own_playlist(db, user, playlist_id)
|
||||||
state = aliased(VideoState)
|
state = aliased(VideoState)
|
||||||
rows = db.execute(
|
rows = db.execute(
|
||||||
select(
|
select(*feed_columns(user, state))
|
||||||
Video.id,
|
|
||||||
Video.title,
|
|
||||||
Video.channel_id,
|
|
||||||
Channel.title.label("channel_title"),
|
|
||||||
Channel.thumbnail_url.label("channel_thumbnail"),
|
|
||||||
Channel.handle.label("channel_handle"),
|
|
||||||
Video.published_at,
|
|
||||||
Video.thumbnail_url,
|
|
||||||
Video.duration_seconds,
|
|
||||||
Video.view_count,
|
|
||||||
Video.is_short,
|
|
||||||
Video.live_status,
|
|
||||||
func.coalesce(state.status, "new").label("status"),
|
|
||||||
func.coalesce(state.position_seconds, 0).label("position_seconds"),
|
|
||||||
_saved_expr(user),
|
|
||||||
)
|
|
||||||
.join(PlaylistItem, PlaylistItem.video_id == Video.id)
|
.join(PlaylistItem, PlaylistItem.video_id == Video.id)
|
||||||
.join(Channel, Channel.id == Video.channel_id)
|
.join(Channel, Channel.id == Video.channel_id)
|
||||||
.outerjoin(state, and_(state.video_id == Video.id, state.user_id == user.id))
|
.outerjoin(state, and_(state.video_id == Video.id, state.user_id == user.id))
|
||||||
|
|
@ -439,25 +440,7 @@ def add_item(
|
||||||
video_id = payload.get("video_id")
|
video_id = payload.get("video_id")
|
||||||
if not video_id or db.get(Video, video_id) is None:
|
if not video_id or db.get(Video, video_id) is None:
|
||||||
raise HTTPException(status_code=404, detail="Unknown video")
|
raise HTTPException(status_code=404, detail="Unknown video")
|
||||||
exists = db.scalar(
|
_add_item(db, pl, video_id, mark_dirty=True)
|
||||||
select(PlaylistItem).where(
|
|
||||||
PlaylistItem.playlist_id == pl.id, PlaylistItem.video_id == video_id
|
|
||||||
)
|
|
||||||
)
|
|
||||||
if exists is None:
|
|
||||||
next_pos = (
|
|
||||||
db.scalar(
|
|
||||||
select(func.coalesce(func.max(PlaylistItem.position), -1)).where(
|
|
||||||
PlaylistItem.playlist_id == pl.id
|
|
||||||
)
|
|
||||||
)
|
|
||||||
+ 1
|
|
||||||
)
|
|
||||||
db.add(
|
|
||||||
PlaylistItem(playlist_id=pl.id, video_id=video_id, position=next_pos)
|
|
||||||
)
|
|
||||||
recompute_dirty(db, pl)
|
|
||||||
db.commit()
|
|
||||||
return {"playlist_id": pl.id, "video_id": video_id}
|
return {"playlist_id": pl.id, "video_id": video_id}
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@ from sqlalchemy import and_, case, func, select, update
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app import quota, state
|
from app import quota, state
|
||||||
from app.auth import current_user, has_read_scope, require_human
|
from app.auth import admin_user, current_user, has_read_scope, require_human
|
||||||
from app.db import get_db
|
from app.db import get_db
|
||||||
from app.models import Channel, Subscription, User, Video
|
from app.models import Channel, Subscription, User, Video
|
||||||
from app.sync.runner import (
|
from app.sync.runner import (
|
||||||
|
|
@ -40,11 +40,10 @@ def sync_subscriptions(
|
||||||
status_code=403,
|
status_code=403,
|
||||||
detail="Connect your YouTube account (read access) to import subscriptions.",
|
detail="Connect your YouTube account (read access) to import subscriptions.",
|
||||||
)
|
)
|
||||||
before = quota.units_used_today(db)
|
with quota.measured(db, user.id, quota.QuotaAction.SUBSCRIPTIONS_PULL) as m:
|
||||||
with quota.attribute(user.id, quota.QuotaAction.SUBSCRIPTIONS_PULL):
|
|
||||||
result = import_subscriptions(db, user)
|
result = import_subscriptions(db, user)
|
||||||
result["quota_used_estimate"] = quota.units_used_today(db) - before
|
result["quota_used_estimate"] = m.used
|
||||||
result["quota_remaining_today"] = quota.remaining_today(db)
|
result["quota_remaining_today"] = m.remaining
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -62,10 +61,9 @@ def sync_backfill(
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
max_channels: int = 25,
|
max_channels: int = 25,
|
||||||
) -> dict:
|
) -> dict:
|
||||||
before = quota.units_used_today(db)
|
with quota.measured(db, user.id, quota.QuotaAction.VIDEOS_BACKFILL_RECENT) as m:
|
||||||
with quota.attribute(user.id, quota.QuotaAction.VIDEOS_BACKFILL_RECENT):
|
|
||||||
result = run_recent_backfill(db, _user_channels(db, user), max_channels=max_channels)
|
result = run_recent_backfill(db, _user_channels(db, user), max_channels=max_channels)
|
||||||
result["quota_used_estimate"] = quota.units_used_today(db) - before
|
result["quota_used_estimate"] = m.used
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -73,13 +71,9 @@ def sync_backfill(
|
||||||
def sync_enrich(
|
def sync_enrich(
|
||||||
user: User = Depends(require_human), db: Session = Depends(get_db)
|
user: User = Depends(require_human), db: Session = Depends(get_db)
|
||||||
) -> dict:
|
) -> dict:
|
||||||
before = quota.units_used_today(db)
|
with quota.measured(db, user.id, quota.QuotaAction.VIDEOS_ENRICH) as m:
|
||||||
with quota.attribute(user.id, quota.QuotaAction.VIDEOS_ENRICH):
|
|
||||||
enriched = run_enrich(db)
|
enriched = run_enrich(db)
|
||||||
return {
|
return {"enriched": enriched, "quota_used_estimate": m.used}
|
||||||
"enriched": enriched,
|
|
||||||
"quota_used_estimate": quota.units_used_today(db) - before,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/status")
|
@router.get("/status")
|
||||||
|
|
@ -194,20 +188,12 @@ def deep_all(
|
||||||
|
|
||||||
|
|
||||||
@router.post("/pause")
|
@router.post("/pause")
|
||||||
def pause_sync(
|
def pause_sync(_: User = Depends(admin_user), db: Session = Depends(get_db)) -> dict:
|
||||||
user: User = Depends(current_user), db: Session = Depends(get_db)
|
|
||||||
) -> dict:
|
|
||||||
if user.role != "admin":
|
|
||||||
raise HTTPException(status_code=403, detail="Admin only")
|
|
||||||
state.set_sync_paused(db, True)
|
state.set_sync_paused(db, True)
|
||||||
return {"paused": True}
|
return {"paused": True}
|
||||||
|
|
||||||
|
|
||||||
@router.post("/resume")
|
@router.post("/resume")
|
||||||
def resume_sync(
|
def resume_sync(_: User = Depends(admin_user), db: Session = Depends(get_db)) -> dict:
|
||||||
user: User = Depends(current_user), db: Session = Depends(get_db)
|
|
||||||
) -> dict:
|
|
||||||
if user.role != "admin":
|
|
||||||
raise HTTPException(status_code=403, detail="Admin only")
|
|
||||||
state.set_sync_paused(db, False)
|
state.set_sync_paused(db, False)
|
||||||
return {"paused": False}
|
return {"paused": False}
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@ from sqlalchemy import func, or_, select
|
||||||
from sqlalchemy.exc import IntegrityError
|
from sqlalchemy.exc import IntegrityError
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app.auth import current_user
|
from app.auth import admin_user, current_user
|
||||||
from app.db import get_db
|
from app.db import get_db
|
||||||
from app.models import ChannelTag, Tag, User
|
from app.models import ChannelTag, Tag, User
|
||||||
from app.sync.autotag import run_autotag_all
|
from app.sync.autotag import run_autotag_all
|
||||||
|
|
@ -103,10 +103,8 @@ def delete_tag(
|
||||||
|
|
||||||
@router.post("/recompute")
|
@router.post("/recompute")
|
||||||
def recompute(
|
def recompute(
|
||||||
user: User = Depends(current_user),
|
_: User = Depends(admin_user),
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
only_missing: bool = False,
|
only_missing: bool = False,
|
||||||
) -> dict:
|
) -> dict:
|
||||||
if user.role != "admin":
|
|
||||||
raise HTTPException(status_code=403, detail="Admin only")
|
|
||||||
return run_autotag_all(db, only_missing=only_missing)
|
return run_autotag_all(db, only_missing=only_missing)
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,5 @@
|
||||||
|
import hashlib
|
||||||
|
|
||||||
from argon2 import PasswordHasher
|
from argon2 import PasswordHasher
|
||||||
from cryptography.fernet import Fernet
|
from cryptography.fernet import Fernet
|
||||||
|
|
||||||
|
|
@ -7,6 +9,12 @@ from app.config import settings
|
||||||
_ph = PasswordHasher()
|
_ph = PasswordHasher()
|
||||||
|
|
||||||
|
|
||||||
|
def hash_token(raw: str) -> str:
|
||||||
|
"""SHA-256 hex digest for storing single-use tokens (email-link tokens, the setup token)
|
||||||
|
by hash rather than in cleartext. Not a password hash — these are high-entropy randoms."""
|
||||||
|
return hashlib.sha256(raw.encode()).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
def hash_password(password: str) -> str:
|
def hash_password(password: str) -> str:
|
||||||
return _ph.hash(password)
|
return _ph.hash(password)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,4 @@
|
||||||
"""Global admin-controlled app state (e.g. pausing background sync, first-run setup)."""
|
"""Global admin-controlled app state (e.g. pausing background sync, first-run setup)."""
|
||||||
import hashlib
|
|
||||||
import logging
|
import logging
|
||||||
import secrets
|
import secrets
|
||||||
|
|
||||||
|
|
@ -7,6 +6,7 @@ from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app.config import settings
|
from app.config import settings
|
||||||
from app.models import AppState
|
from app.models import AppState
|
||||||
|
from app.security import hash_token
|
||||||
|
|
||||||
log = logging.getLogger("siftlode.state")
|
log = logging.getLogger("siftlode.state")
|
||||||
|
|
||||||
|
|
@ -73,16 +73,12 @@ def mark_configured(db: Session) -> None:
|
||||||
log.info("Instance marked configured; setup wizard disabled.")
|
log.info("Instance marked configured; setup wizard disabled.")
|
||||||
|
|
||||||
|
|
||||||
def _hash_token(raw: str) -> str:
|
|
||||||
return hashlib.sha256(raw.encode()).hexdigest()
|
|
||||||
|
|
||||||
|
|
||||||
def rotate_setup_token(db: Session) -> str:
|
def rotate_setup_token(db: Session) -> str:
|
||||||
"""Generate a fresh one-time setup token, persist only its hash, and return the raw token
|
"""Generate a fresh one-time setup token, persist only its hash, and return the raw token
|
||||||
(logged at startup so the admin can open the wizard)."""
|
(logged at startup so the admin can open the wizard)."""
|
||||||
raw = secrets.token_urlsafe(24)
|
raw = secrets.token_urlsafe(24)
|
||||||
row = _row(db)
|
row = _row(db)
|
||||||
row.setup_token_hash = _hash_token(raw)
|
row.setup_token_hash = hash_token(raw)
|
||||||
db.add(row)
|
db.add(row)
|
||||||
db.commit()
|
db.commit()
|
||||||
return raw
|
return raw
|
||||||
|
|
@ -93,7 +89,7 @@ def check_setup_token(db: Session, raw: str | None) -> bool:
|
||||||
if not raw:
|
if not raw:
|
||||||
return False
|
return False
|
||||||
stored = _row(db).setup_token_hash
|
stored = _row(db).setup_token_hash
|
||||||
return stored is not None and secrets.compare_digest(stored, _hash_token(raw))
|
return stored is not None and secrets.compare_digest(stored, hash_token(raw))
|
||||||
|
|
||||||
|
|
||||||
def get_maintenance_batch(db: Session) -> int:
|
def get_maintenance_batch(db: Session) -> int:
|
||||||
|
|
|
||||||
|
|
@ -20,18 +20,19 @@ Quota: phase 1 costs ~(flagged / 50) units, phase 2 ~(batch / 50). Both respect
|
||||||
backfill reserve as the other jobs so interactive syncs never starve.
|
backfill reserve as the other jobs so interactive syncs never starve.
|
||||||
"""
|
"""
|
||||||
import logging
|
import logging
|
||||||
from datetime import datetime, timedelta, timezone
|
from datetime import timedelta
|
||||||
|
|
||||||
from sqlalchemy import or_, select
|
from sqlalchemy import select
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app import progress, quota, sysconfig
|
from app import progress, quota, sysconfig
|
||||||
from app.config import settings
|
from app.config import settings
|
||||||
from app.models import Playlist, PlaylistItem, Video, VideoState
|
from app.models import Playlist, PlaylistItem, Video
|
||||||
from app.notifications import create_notification
|
from app.notifications import create_notification
|
||||||
from app.state import get_maintenance_batch
|
from app.state import get_maintenance_batch
|
||||||
from app.sync.runner import get_service_user
|
from app.sync.runner import get_service_user
|
||||||
from app.sync.videos import apply_video_details, parse_dt
|
from app.sync.videos import apply_video_details, parse_dt
|
||||||
|
from app.utils import now_utc as _now
|
||||||
from app.youtube.client import YouTubeClient
|
from app.youtube.client import YouTubeClient
|
||||||
|
|
||||||
log = logging.getLogger("siftlode.maintenance")
|
log = logging.getLogger("siftlode.maintenance")
|
||||||
|
|
@ -45,10 +46,6 @@ GRACE_DAYS_BY_REASON = {
|
||||||
DEFAULT_GRACE_DAYS = settings.maintenance_grace_days
|
DEFAULT_GRACE_DAYS = settings.maintenance_grace_days
|
||||||
|
|
||||||
|
|
||||||
def _now() -> datetime:
|
|
||||||
return datetime.now(timezone.utc)
|
|
||||||
|
|
||||||
|
|
||||||
def _grace_days(reason: str | None) -> int:
|
def _grace_days(reason: str | None) -> int:
|
||||||
return GRACE_DAYS_BY_REASON.get(reason or "", DEFAULT_GRACE_DAYS)
|
return GRACE_DAYS_BY_REASON.get(reason or "", DEFAULT_GRACE_DAYS)
|
||||||
|
|
||||||
|
|
@ -91,14 +88,9 @@ def _collect_user_impact(db: Session, videos: list[Video]) -> dict[int, list[dic
|
||||||
impact: dict[int, set[str]] = {}
|
impact: dict[int, set[str]] = {}
|
||||||
if not ids:
|
if not ids:
|
||||||
return {}
|
return {}
|
||||||
# Saved (VideoState.status == "saved").
|
# Anyone who had it in a playlist — which now also covers "saved": Watch later became a
|
||||||
for user_id, video_id in db.execute(
|
# built-in playlist, so "saved" is no longer a VideoState status (the old status=="saved"
|
||||||
select(VideoState.user_id, VideoState.video_id).where(
|
# scan matched nothing and under-counted impacted users).
|
||||||
VideoState.video_id.in_(ids), VideoState.status == "saved"
|
|
||||||
)
|
|
||||||
):
|
|
||||||
impact.setdefault(user_id, set()).add(video_id)
|
|
||||||
# In any of the user's playlists (covers watch-later, which is a playlist).
|
|
||||||
for user_id, video_id in db.execute(
|
for user_id, video_id in db.execute(
|
||||||
select(Playlist.user_id, PlaylistItem.video_id)
|
select(Playlist.user_id, PlaylistItem.video_id)
|
||||||
.join(PlaylistItem, PlaylistItem.playlist_id == Playlist.id)
|
.join(PlaylistItem, PlaylistItem.playlist_id == Playlist.id)
|
||||||
|
|
|
||||||
|
|
@ -94,6 +94,34 @@ def _ensure_videos(db: Session, yt: YouTubeClient, video_ids: list[str]) -> None
|
||||||
db.commit()
|
db.commit()
|
||||||
|
|
||||||
|
|
||||||
|
def _replace_items_from_youtube(
|
||||||
|
db: Session, yt: YouTubeClient, pl: Playlist, ids: list[str]
|
||||||
|
) -> list[str]:
|
||||||
|
"""Mirror a YouTube playlist's ordered video ids into `pl`'s local items (authoritative
|
||||||
|
replace): ensure the videos exist in the catalog, keep only those present in their YT
|
||||||
|
order (de-duplicated), swap the items out, and reset the synced fingerprint + dirty flag.
|
||||||
|
`pl.name` must already be set to the desired name. The caller commits. Returns the final
|
||||||
|
ordered ids."""
|
||||||
|
_ensure_videos(db, yt, ids)
|
||||||
|
present = (
|
||||||
|
set(db.execute(select(Video.id).where(Video.id.in_(ids))).scalars().all())
|
||||||
|
if ids
|
||||||
|
else set()
|
||||||
|
)
|
||||||
|
ordered: list[str] = []
|
||||||
|
seen: set[str] = set()
|
||||||
|
for v in ids:
|
||||||
|
if v in present and v not in seen:
|
||||||
|
seen.add(v)
|
||||||
|
ordered.append(v)
|
||||||
|
db.execute(delete(PlaylistItem).where(PlaylistItem.playlist_id == pl.id))
|
||||||
|
for pos, vid in enumerate(ordered):
|
||||||
|
db.add(PlaylistItem(playlist_id=pl.id, video_id=vid, position=pos))
|
||||||
|
pl.synced_fingerprint = fingerprint(pl.name, ordered)
|
||||||
|
pl.dirty = False
|
||||||
|
return ordered
|
||||||
|
|
||||||
|
|
||||||
def sync_user_playlists(db: Session, user: User) -> dict:
|
def sync_user_playlists(db: Session, user: User) -> dict:
|
||||||
"""Mirror the user's YouTube playlists into local source='youtube' playlists. Leaves
|
"""Mirror the user's YouTube playlists into local source='youtube' playlists. Leaves
|
||||||
the user's local playlists and the built-in Watch later untouched."""
|
the user's local playlists and the built-in Watch later untouched."""
|
||||||
|
|
@ -152,25 +180,9 @@ def sync_user_playlists(db: Session, user: User) -> dict:
|
||||||
db.flush()
|
db.flush()
|
||||||
else:
|
else:
|
||||||
pl.name = p.get("title") or pl.name
|
pl.name = p.get("title") or pl.name
|
||||||
ids = list(yt.iter_my_playlist_video_ids(ytid))
|
|
||||||
_ensure_videos(db, yt, ids)
|
|
||||||
present = (
|
|
||||||
set(db.execute(select(Video.id).where(Video.id.in_(ids))).scalars().all())
|
|
||||||
if ids
|
|
||||||
else set()
|
|
||||||
)
|
|
||||||
ordered: list[str] = []
|
|
||||||
seen: set[str] = set()
|
|
||||||
for v in ids:
|
|
||||||
if v in present and v not in seen:
|
|
||||||
seen.add(v)
|
|
||||||
ordered.append(v)
|
|
||||||
# Mirror is authoritative: replace the items to match YouTube's order.
|
# Mirror is authoritative: replace the items to match YouTube's order.
|
||||||
db.execute(delete(PlaylistItem).where(PlaylistItem.playlist_id == pl.id))
|
ids = list(yt.iter_my_playlist_video_ids(ytid))
|
||||||
for pos, vid in enumerate(ordered):
|
_replace_items_from_youtube(db, yt, pl, ids)
|
||||||
db.add(PlaylistItem(playlist_id=pl.id, video_id=vid, position=pos))
|
|
||||||
pl.synced_fingerprint = fingerprint(pl.name, ordered)
|
|
||||||
pl.dirty = False
|
|
||||||
db.commit()
|
db.commit()
|
||||||
synced += 1
|
synced += 1
|
||||||
return {"synced": synced}
|
return {"synced": synced}
|
||||||
|
|
@ -349,25 +361,9 @@ def repull_playlist(db: Session, user: User, pl: Playlist) -> dict:
|
||||||
snippet = yt.get_playlist_snippet(pl.yt_playlist_id)
|
snippet = yt.get_playlist_snippet(pl.yt_playlist_id)
|
||||||
if snippet is None:
|
if snippet is None:
|
||||||
raise YouTubeError("Playlist no longer exists on YouTube")
|
raise YouTubeError("Playlist no longer exists on YouTube")
|
||||||
ids = list(yt.iter_my_playlist_video_ids(pl.yt_playlist_id))
|
|
||||||
_ensure_videos(db, yt, ids)
|
|
||||||
present = (
|
|
||||||
set(db.execute(select(Video.id).where(Video.id.in_(ids))).scalars().all())
|
|
||||||
if ids
|
|
||||||
else set()
|
|
||||||
)
|
|
||||||
ordered: list[str] = []
|
|
||||||
seen: set[str] = set()
|
|
||||||
for v in ids:
|
|
||||||
if v in present and v not in seen:
|
|
||||||
seen.add(v)
|
|
||||||
ordered.append(v)
|
|
||||||
pl.name = snippet.get("title") or pl.name
|
pl.name = snippet.get("title") or pl.name
|
||||||
db.execute(delete(PlaylistItem).where(PlaylistItem.playlist_id == pl.id))
|
ids = list(yt.iter_my_playlist_video_ids(pl.yt_playlist_id))
|
||||||
for pos, vid in enumerate(ordered):
|
ordered = _replace_items_from_youtube(db, yt, pl, ids)
|
||||||
db.add(PlaylistItem(playlist_id=pl.id, video_id=vid, position=pos))
|
|
||||||
pl.synced_fingerprint = fingerprint(pl.name, ordered)
|
|
||||||
pl.dirty = False
|
|
||||||
db.commit()
|
db.commit()
|
||||||
return {"items": len(ordered)}
|
return {"items": len(ordered)}
|
||||||
|
|
||||||
|
|
|
||||||
17
backend/app/utils.py
Normal file
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";
|
} from "./lib/sidebarLayout";
|
||||||
import { configureNotifications, getNotifSettings, notify, removeByMetaKind, type NotifSettings } from "./lib/notifications";
|
import { configureNotifications, getNotifSettings, notify, removeByMetaKind, type NotifSettings } from "./lib/notifications";
|
||||||
import { hintsEnabled, setHintsEnabled } from "./lib/hints";
|
import { hintsEnabled, setHintsEnabled } from "./lib/hints";
|
||||||
|
import { LS, readMerged, usePersistedState } from "./lib/storage";
|
||||||
import { useConfirm } from "./components/ConfirmProvider";
|
import { useConfirm } from "./components/ConfirmProvider";
|
||||||
import type { PrefsController } from "./components/SettingsPanel";
|
import type { PrefsController } from "./components/SettingsPanel";
|
||||||
import Welcome from "./components/Welcome";
|
import Welcome from "./components/Welcome";
|
||||||
|
|
@ -59,9 +60,9 @@ const DEFAULT_FILTERS: FeedFilters = {
|
||||||
show: "unwatched",
|
show: "unwatched",
|
||||||
};
|
};
|
||||||
|
|
||||||
const FILTERS_KEY = "siftlode.filters";
|
const FILTERS_KEY = LS.filters;
|
||||||
const PAGE_KEY = "siftlode.page";
|
const PAGE_KEY = LS.page;
|
||||||
const PERF_KEY = "siftlode.perfMode";
|
const PERF_KEY = LS.perfMode;
|
||||||
|
|
||||||
// The preferences edited on the Settings page. They apply locally for instant preview but
|
// The preferences edited on the Settings page. They apply locally for instant preview but
|
||||||
// persist to the server only on an explicit Save — so App holds both the live "draft" (the
|
// persist to the server only on an explicit Save — so App holds both the live "draft" (the
|
||||||
|
|
@ -84,11 +85,7 @@ function loadInitialPage(): Page {
|
||||||
}
|
}
|
||||||
|
|
||||||
function loadStoredFilters(): FeedFilters {
|
function loadStoredFilters(): FeedFilters {
|
||||||
try {
|
return readMerged(FILTERS_KEY, DEFAULT_FILTERS);
|
||||||
return { ...DEFAULT_FILTERS, ...JSON.parse(localStorage.getItem(FILTERS_KEY) || "{}") };
|
|
||||||
} catch {
|
|
||||||
return DEFAULT_FILTERS;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// URL wins over localStorage so a pasted link reproduces the exact view.
|
// URL wins over localStorage so a pasted link reproduces the exact view.
|
||||||
|
|
@ -126,18 +123,13 @@ export default function App() {
|
||||||
const [sidebarLayout, setSidebarLayoutState] = useState<SidebarLayout>(loadLayout);
|
const [sidebarLayout, setSidebarLayoutState] = useState<SidebarLayout>(loadLayout);
|
||||||
const [page, setPageState] = useState<Page>(loadInitialPage);
|
const [page, setPageState] = useState<Page>(loadInitialPage);
|
||||||
const [wizardOpen, setWizardOpen] = useState(false);
|
const [wizardOpen, setWizardOpen] = useState(false);
|
||||||
const CHANNEL_FILTER_KEY = "siftlode.channelFilter";
|
// Channel status chip, persisted so a reload (F5) keeps it; clamp a stale value at render.
|
||||||
const [channelFilter, setChannelFilterState] = useState<ChannelStatusFilter>(() => {
|
const [channelFilterRaw, setChannelFilter] = usePersistedState(LS.channelFilter, "all");
|
||||||
const v = localStorage.getItem(CHANNEL_FILTER_KEY);
|
const channelFilter: ChannelStatusFilter = (
|
||||||
return v === "needs_full" || v === "fully_synced" || v === "hidden" || v === "all"
|
["needs_full", "fully_synced", "hidden", "all"] as const
|
||||||
? v
|
).includes(channelFilterRaw as ChannelStatusFilter)
|
||||||
: "all";
|
? (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 [aboutOpen, setAboutOpen] = useState(false);
|
||||||
const [notesOpen, setNotesOpen] = useState(false);
|
const [notesOpen, setNotesOpen] = useState(false);
|
||||||
const [notesHighlight, setNotesHighlight] = useState<string | undefined>(undefined);
|
const [notesHighlight, setNotesHighlight] = useState<string | undefined>(undefined);
|
||||||
|
|
@ -145,14 +137,9 @@ export default function App() {
|
||||||
// persisted so navigation intents that target the subscriptions table — the header's
|
// persisted so navigation intents that target the subscriptions table — the header's
|
||||||
// "without full history" link, focus-channel — can force it back to "subscribed" instead
|
// "without full history" link, focus-channel — can force it back to "subscribed" instead
|
||||||
// of dumping the user on whatever tab they last left open.
|
// of dumping the user on whatever tab they last left open.
|
||||||
const CHANNELS_VIEW_KEY = "siftlode.channelsView";
|
const [channelsViewRaw, setChannelsView] = usePersistedState(LS.channelsView, "subscribed");
|
||||||
const [channelsView, setChannelsViewState] = useState<ChannelsView>(() =>
|
const channelsView: ChannelsView =
|
||||||
localStorage.getItem(CHANNELS_VIEW_KEY) === "discovery" ? "discovery" : "subscribed"
|
channelsViewRaw === "discovery" ? "discovery" : "subscribed";
|
||||||
);
|
|
||||||
const setChannelsView = (v: ChannelsView) => {
|
|
||||||
setChannelsViewState(v);
|
|
||||||
localStorage.setItem(CHANNELS_VIEW_KEY, v);
|
|
||||||
};
|
|
||||||
// Bumped to tell the channel manager to drop a stale column filter when we send the user
|
// Bumped to tell the channel manager to drop a stale column filter when we send the user
|
||||||
// there to see a specific set (the header's "without full history" link).
|
// there to see a specific set (the header's "without full history" link).
|
||||||
const [channelsFilterReset, setChannelsFilterReset] = useState(0);
|
const [channelsFilterReset, setChannelsFilterReset] = useState(0);
|
||||||
|
|
@ -318,7 +305,7 @@ export default function App() {
|
||||||
if (result === "ok") {
|
if (result === "ok") {
|
||||||
notify({ level: "success", message: t("settings.account.googleLink.linked") });
|
notify({ level: "success", message: t("settings.account.googleLink.linked") });
|
||||||
void meQuery.refetch();
|
void meQuery.refetch();
|
||||||
localStorage.setItem("siftlode.settingsTab", "account");
|
localStorage.setItem(LS.settingsTab, "account");
|
||||||
setPage("settings");
|
setPage("settings");
|
||||||
} else {
|
} else {
|
||||||
const key = result === "conflict" || result === "mismatch" ? result : "error";
|
const key = result === "conflict" || result === "mismatch" ? result : "error";
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ import { useTranslation } from "react-i18next";
|
||||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import { Check, ListPlus, Plus, Youtube } from "lucide-react";
|
import { Check, ListPlus, Plus, Youtube } from "lucide-react";
|
||||||
import { api, type Playlist } from "../lib/api";
|
import { api, type Playlist } from "../lib/api";
|
||||||
|
import { useDismiss } from "../lib/useDismiss";
|
||||||
|
|
||||||
// A small popover (portaled to body, so the feed grid can't clip it) for toggling a
|
// A small popover (portaled to body, so the feed grid can't clip it) for toggling a
|
||||||
// video's membership in the user's playlists, with inline "new playlist" creation.
|
// video's membership in the user's playlists, with inline "new playlist" creation.
|
||||||
|
|
@ -59,25 +60,13 @@ export default function AddToPlaylist({
|
||||||
setOpen((o) => !o);
|
setOpen((o) => !o);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
useDismiss(open, () => setOpen(false), [panelRef, triggerRef]);
|
||||||
|
// Keep the panel anchored to the trigger as the page resizes/scrolls while open.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open) return;
|
if (!open) return;
|
||||||
function onDoc(e: MouseEvent) {
|
|
||||||
if (
|
|
||||||
!panelRef.current?.contains(e.target as Node) &&
|
|
||||||
!triggerRef.current?.contains(e.target as Node)
|
|
||||||
)
|
|
||||||
setOpen(false);
|
|
||||||
}
|
|
||||||
function onKey(e: KeyboardEvent) {
|
|
||||||
if (e.key === "Escape") setOpen(false);
|
|
||||||
}
|
|
||||||
document.addEventListener("mousedown", onDoc);
|
|
||||||
document.addEventListener("keydown", onKey);
|
|
||||||
window.addEventListener("resize", place);
|
window.addEventListener("resize", place);
|
||||||
window.addEventListener("scroll", place, true);
|
window.addEventListener("scroll", place, true);
|
||||||
return () => {
|
return () => {
|
||||||
document.removeEventListener("mousedown", onDoc);
|
|
||||||
document.removeEventListener("keydown", onKey);
|
|
||||||
window.removeEventListener("resize", place);
|
window.removeEventListener("resize", place);
|
||||||
window.removeEventListener("scroll", place, true);
|
window.removeEventListener("scroll", place, true);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,9 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import { Ban, Check, RotateCcw, Shield, Trash2, UserPlus, X } from "lucide-react";
|
import { Ban, Check, RotateCcw, Shield, Trash2, UserPlus, X } from "lucide-react";
|
||||||
import { api, type AdminUserRow, type Invite, type Me } from "../lib/api";
|
import { api, type AdminUserRow, type Invite, type Me } from "../lib/api";
|
||||||
import { notify } from "../lib/notifications";
|
import { notify } from "../lib/notifications";
|
||||||
|
import { LS } from "../lib/storage";
|
||||||
import Tooltip from "./Tooltip";
|
import Tooltip from "./Tooltip";
|
||||||
|
import { Section } from "./ui/form";
|
||||||
import { useConfirm } from "./ConfirmProvider";
|
import { useConfirm } from "./ConfirmProvider";
|
||||||
import Tabs, { usePersistedTab } from "./Tabs";
|
import Tabs, { usePersistedTab } from "./Tabs";
|
||||||
|
|
||||||
|
|
@ -14,7 +16,7 @@ import Tabs, { usePersistedTab } from "./Tabs";
|
||||||
// The persisted-tab storage key + the access-requests tab id, exported so a notification's
|
// The persisted-tab storage key + the access-requests tab id, exported so a notification's
|
||||||
// "Review" link can pre-select that tab before navigating here (usePersistedTab reads the key
|
// "Review" link can pre-select that tab before navigating here (usePersistedTab reads the key
|
||||||
// on mount, and this page mounts fresh on navigation).
|
// on mount, and this page mounts fresh on navigation).
|
||||||
export const ADMIN_USERS_TAB_KEY = "siftlode.adminUsersTab";
|
export const ADMIN_USERS_TAB_KEY = LS.adminUsersTab;
|
||||||
export const ADMIN_USERS_ACCESS_TAB = "access";
|
export const ADMIN_USERS_ACCESS_TAB = "access";
|
||||||
|
|
||||||
export function focusAccessRequestsTab(): void {
|
export function focusAccessRequestsTab(): void {
|
||||||
|
|
@ -40,15 +42,6 @@ export default function AdminUsers({ me }: { me: Me }) {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function Section({ title, children }: { title: string; children: React.ReactNode }) {
|
|
||||||
return (
|
|
||||||
<div className="glass rounded-2xl p-4 mb-4">
|
|
||||||
<div className="text-xs uppercase tracking-wide text-muted mb-3">{title}</div>
|
|
||||||
{children}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function Badge({ tone, children }: { tone: "accent" | "muted" | "warning"; children: React.ReactNode }) {
|
function Badge({ tone, children }: { tone: "accent" | "muted" | "warning"; children: React.ReactNode }) {
|
||||||
const cls =
|
const cls =
|
||||||
tone === "accent"
|
tone === "accent"
|
||||||
|
|
@ -137,7 +130,7 @@ function UsersRoles({ me }: { me: Me }) {
|
||||||
const rows = q.data ?? [];
|
const rows = q.data ?? [];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Section title={t("users.roles.title")}>
|
<Section card title={t("users.roles.title")}>
|
||||||
<p className="text-xs text-muted leading-relaxed mb-3">{t("users.roles.intro")}</p>
|
<p className="text-xs text-muted leading-relaxed mb-3">{t("users.roles.intro")}</p>
|
||||||
{rows.length === 0 ? (
|
{rows.length === 0 ? (
|
||||||
<p className="text-sm text-muted">{t("users.roles.empty")}</p>
|
<p className="text-sm text-muted">{t("users.roles.empty")}</p>
|
||||||
|
|
@ -265,7 +258,7 @@ function AdminInvites() {
|
||||||
const decided = list.filter((i) => i.status !== "pending");
|
const decided = list.filter((i) => i.status !== "pending");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Section title={t("settings.invites.title")}>
|
<Section card title={t("settings.invites.title")}>
|
||||||
{pending.length === 0 ? (
|
{pending.length === 0 ? (
|
||||||
<p className="text-sm text-muted">{t("settings.invites.noPending")}</p>
|
<p className="text-sm text-muted">{t("settings.invites.noPending")}</p>
|
||||||
) : (
|
) : (
|
||||||
|
|
@ -359,7 +352,7 @@ function AdminDemo() {
|
||||||
const rows = list.data ?? [];
|
const rows = list.data ?? [];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Section title={t("settings.demo.title")}>
|
<Section card title={t("settings.demo.title")}>
|
||||||
<p className="text-xs text-muted leading-relaxed mb-2">{t("settings.demo.intro")}</p>
|
<p className="text-xs text-muted leading-relaxed mb-2">{t("settings.demo.intro")}</p>
|
||||||
|
|
||||||
{rows.length > 0 && (
|
{rows.length > 0 && (
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { useEffect, useRef, useState } from "react";
|
import { useRef, useState } from "react";
|
||||||
import { Trans, useTranslation } from "react-i18next";
|
import { Trans, useTranslation } from "react-i18next";
|
||||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import {
|
import {
|
||||||
|
|
@ -14,7 +14,8 @@ import {
|
||||||
UserMinus,
|
UserMinus,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { api, HttpError, type ManagedChannel, type Tag } from "../lib/api";
|
import { api, HttpError, type ManagedChannel, type Tag } from "../lib/api";
|
||||||
import { formatEta, formatViews, relativeTime } from "../lib/format";
|
import { useDismiss } from "../lib/useDismiss";
|
||||||
|
import { formatEta, formatTotalHours, formatViews, relativeTime } from "../lib/format";
|
||||||
import { notify } from "../lib/notifications";
|
import { notify } from "../lib/notifications";
|
||||||
import Tooltip from "./Tooltip";
|
import Tooltip from "./Tooltip";
|
||||||
import DataTable, { type Column } from "./DataTable";
|
import DataTable, { type Column } from "./DataTable";
|
||||||
|
|
@ -27,13 +28,6 @@ export type ChannelsView = "subscribed" | "discovery";
|
||||||
|
|
||||||
export type ChannelStatusFilter = "all" | "needs_full" | "fully_synced" | "hidden";
|
export type ChannelStatusFilter = "all" | "needs_full" | "fully_synced" | "hidden";
|
||||||
|
|
||||||
// Compact total-duration label for a whole channel (hours-scale, so H:MM:SS would be huge).
|
|
||||||
function fmtTotalDuration(sec: number): string {
|
|
||||||
if (!sec) return "—";
|
|
||||||
const h = Math.round(sec / 3600);
|
|
||||||
return h >= 1 ? `${h.toLocaleString()} h` : `${Math.max(1, Math.round(sec / 60))} m`;
|
|
||||||
}
|
|
||||||
|
|
||||||
const STATUS_FILTERS: { id: ChannelStatusFilter; labelKey: string }[] = [
|
const STATUS_FILTERS: { id: ChannelStatusFilter; labelKey: string }[] = [
|
||||||
{ id: "all", labelKey: "channels.filters.all" },
|
{ id: "all", labelKey: "channels.filters.all" },
|
||||||
{ id: "needs_full", labelKey: "channels.filters.needsFull" },
|
{ id: "needs_full", labelKey: "channels.filters.needsFull" },
|
||||||
|
|
@ -286,7 +280,7 @@ export default function Channels({
|
||||||
nowrap: true,
|
nowrap: true,
|
||||||
sortable: true,
|
sortable: true,
|
||||||
sortValue: (c) => c.total_duration_seconds,
|
sortValue: (c) => c.total_duration_seconds,
|
||||||
render: (c) => <span className="text-muted tabular-nums">{fmtTotalDuration(c.total_duration_seconds)}</span>,
|
render: (c) => <span className="text-muted tabular-nums">{formatTotalHours(c.total_duration_seconds)}</span>,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "types",
|
key: "types",
|
||||||
|
|
@ -616,19 +610,7 @@ function TagsCell({
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const ref = useRef<HTMLDivElement | null>(null);
|
const ref = useRef<HTMLDivElement | null>(null);
|
||||||
useEffect(() => {
|
useDismiss(open, () => setOpen(false), ref);
|
||||||
if (!open) return;
|
|
||||||
const onDown = (e: MouseEvent) => {
|
|
||||||
if (!ref.current?.contains(e.target as Node)) setOpen(false);
|
|
||||||
};
|
|
||||||
const onKey = (e: KeyboardEvent) => e.key === "Escape" && setOpen(false);
|
|
||||||
document.addEventListener("mousedown", onDown);
|
|
||||||
document.addEventListener("keydown", onKey);
|
|
||||||
return () => {
|
|
||||||
document.removeEventListener("mousedown", onDown);
|
|
||||||
document.removeEventListener("keydown", onKey);
|
|
||||||
};
|
|
||||||
}, [open]);
|
|
||||||
if (userTags.length === 0) return null;
|
if (userTags.length === 0) return null;
|
||||||
// Show only the tags actually attached to this channel; the “+” opens a per-channel
|
// Show only the tags actually attached to this channel; the “+” opens a per-channel
|
||||||
// picker to attach/detach (kept the convenient "kirakás" without listing every tag
|
// picker to attach/detach (kept the convenient "kirakás" without listing every tag
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,13 @@
|
||||||
import { useEffect, useMemo, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import { Check, RotateCcw, Save, Send } from "lucide-react";
|
import { RotateCcw, Send } from "lucide-react";
|
||||||
import { api, type ConfigItem } from "../lib/api";
|
import { api, type ConfigItem } from "../lib/api";
|
||||||
import { notify } from "../lib/notifications";
|
import { notify } from "../lib/notifications";
|
||||||
import Tooltip from "./Tooltip";
|
import Tooltip from "./Tooltip";
|
||||||
import Tabs, { usePersistedTab } from "./Tabs";
|
import Tabs, { usePersistedTab } from "./Tabs";
|
||||||
|
import { Switch } from "./ui/form";
|
||||||
|
import { DraftSaveBar } from "./ui/DraftSaveBar";
|
||||||
|
|
||||||
// Admin Configuration page: edit DB-overridable operational settings (registry-driven from the
|
// Admin Configuration page: edit DB-overridable operational settings (registry-driven from the
|
||||||
// backend — adding a key there makes it appear here automatically). Edits are drafted and
|
// backend — adding a key there makes it appear here automatically). Edits are drafted and
|
||||||
|
|
@ -150,40 +152,21 @@ export default function ConfigPanel() {
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{(dirty || saveState !== "idle") && (
|
<DraftSaveBar
|
||||||
<div className="fixed bottom-4 left-1/2 -translate-x-1/2 z-40 glass rounded-2xl shadow-lg flex items-center justify-between gap-4 px-4 py-3 w-[min(48rem,calc(100%-2rem))]">
|
variant="floating"
|
||||||
<span className="text-sm text-muted">
|
dirty={dirty}
|
||||||
{saveState === "saved" ? (
|
state={saveState}
|
||||||
<span className="text-emerald-500 inline-flex items-center gap-1.5">
|
onSave={save}
|
||||||
<Check className="w-4 h-4" />
|
onDiscard={discard}
|
||||||
{t("config.saved")}
|
labels={{
|
||||||
</span>
|
saved: t("config.saved"),
|
||||||
) : saveState === "error" ? (
|
failed: t("config.saveFailed"),
|
||||||
<span className="text-red-500">{t("config.saveFailed")}</span>
|
unsaved: t("config.unsaved"),
|
||||||
) : (
|
discard: t("config.discard"),
|
||||||
t("config.unsaved")
|
save: t("config.save"),
|
||||||
)}
|
saving: t("config.saving"),
|
||||||
</span>
|
}}
|
||||||
<div className="flex items-center gap-2">
|
/>
|
||||||
<button
|
|
||||||
onClick={discard}
|
|
||||||
disabled={saveState === "saving" || !dirty}
|
|
||||||
className="glass-card glass-hover flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-sm disabled:opacity-40 transition"
|
|
||||||
>
|
|
||||||
<RotateCcw className="w-4 h-4" />
|
|
||||||
{t("config.discard")}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={save}
|
|
||||||
disabled={saveState === "saving" || !dirty}
|
|
||||||
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-sm bg-accent text-accent-fg font-medium shadow-md shadow-accent/20 disabled:opacity-40 transition"
|
|
||||||
>
|
|
||||||
<Save className="w-4 h-4" />
|
|
||||||
{saveState === "saving" ? t("config.saving") : t("config.save")}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -241,7 +224,7 @@ function Field({
|
||||||
|
|
||||||
<div className="flex flex-col items-end gap-1.5 shrink-0">
|
<div className="flex flex-col items-end gap-1.5 shrink-0">
|
||||||
{isBool ? (
|
{isBool ? (
|
||||||
<Toggle checked={value === "true"} onChange={(v) => onChange(v ? "true" : "false")} />
|
<Switch checked={value === "true"} onChange={(v) => onChange(v ? "true" : "false")} />
|
||||||
) : (
|
) : (
|
||||||
<input
|
<input
|
||||||
type={item.secret ? "password" : isNum ? "number" : "text"}
|
type={item.secret ? "password" : isNum ? "number" : "text"}
|
||||||
|
|
@ -272,20 +255,4 @@ function Field({
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function Toggle({ checked, onChange }: { checked: boolean; onChange: (v: boolean) => void }) {
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => onChange(!checked)}
|
|
||||||
className={`w-9 h-5 rounded-full transition relative shrink-0 ${checked ? "bg-accent" : "bg-border"}`}
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
className={`absolute top-0.5 w-4 h-4 rounded-full bg-white shadow transition-all ${
|
|
||||||
checked ? "left-[18px]" : "left-0.5"
|
|
||||||
}`}
|
|
||||||
/>
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import { useEffect, useRef, useState, type ReactNode } from "react";
|
import { useEffect, useRef, useState, type ReactNode } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { ArrowDown, ArrowUp, ChevronLeft, ChevronRight, ListFilter, X } from "lucide-react";
|
import { ArrowDown, ArrowUp, ChevronLeft, ChevronRight, ListFilter, X } from "lucide-react";
|
||||||
|
import { useDismiss } from "../lib/useDismiss";
|
||||||
|
|
||||||
// A reusable, client-side data table: per-column sort + filter (in the header) + pagination,
|
// A reusable, client-side data table: per-column sort + filter (in the header) + pagination,
|
||||||
// with its sort/filter/page state optionally persisted to localStorage so a reload (F5) keeps
|
// with its sort/filter/page state optionally persisted to localStorage so a reload (F5) keeps
|
||||||
|
|
@ -121,21 +122,7 @@ export default function DataTable<T>({
|
||||||
setPage(0);
|
setPage(0);
|
||||||
}, [resetFiltersToken]);
|
}, [resetFiltersToken]);
|
||||||
|
|
||||||
useEffect(() => {
|
useDismiss(!!openFilter, () => setOpenFilter(null), popRef);
|
||||||
if (!openFilter) return;
|
|
||||||
function onDown(e: MouseEvent) {
|
|
||||||
if (!popRef.current?.contains(e.target as Node)) setOpenFilter(null);
|
|
||||||
}
|
|
||||||
function onKey(e: KeyboardEvent) {
|
|
||||||
if (e.key === "Escape") setOpenFilter(null);
|
|
||||||
}
|
|
||||||
document.addEventListener("mousedown", onDown);
|
|
||||||
document.addEventListener("keydown", onKey);
|
|
||||||
return () => {
|
|
||||||
document.removeEventListener("mousedown", onDown);
|
|
||||||
document.removeEventListener("keydown", onKey);
|
|
||||||
};
|
|
||||||
}, [openFilter]);
|
|
||||||
|
|
||||||
const filtered = rows.filter((row) =>
|
const filtered = rows.filter((row) =>
|
||||||
columns.every((col) => {
|
columns.every((col) => {
|
||||||
|
|
|
||||||
|
|
@ -22,7 +22,9 @@ export default function Messages({ meId }: { meId: number }) {
|
||||||
// the module (open() pushes an entry; back() = history.back()).
|
// the module (open() pushes an entry; back() = history.back()).
|
||||||
const { view, open, back } = useHistorySubview<View>("list");
|
const { view, open, back } = useHistorySubview<View>("list");
|
||||||
|
|
||||||
if (typeof view === "object") {
|
// Guard against a stale sub-view whose partnerId is the current user's own id (you can't
|
||||||
|
// message yourself). This can be inherited across an account switch — fall back to the list.
|
||||||
|
if (typeof view === "object" && view.partnerId !== meId) {
|
||||||
return (
|
return (
|
||||||
<PageThread
|
<PageThread
|
||||||
meId={meId}
|
meId={meId}
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,7 @@ import {
|
||||||
import { api, type Me } from "../lib/api";
|
import { api, type Me } from "../lib/api";
|
||||||
import { useLiveQuery } from "../lib/useLiveQuery";
|
import { useLiveQuery } from "../lib/useLiveQuery";
|
||||||
import { getUnreadCount, subscribe } from "../lib/notifications";
|
import { getUnreadCount, subscribe } from "../lib/notifications";
|
||||||
|
import { LS } from "../lib/storage";
|
||||||
import type { Page } from "../lib/urlState";
|
import type { Page } from "../lib/urlState";
|
||||||
import { type LangCode } from "../i18n";
|
import { type LangCode } from "../i18n";
|
||||||
import AvatarImg from "./Avatar";
|
import AvatarImg from "./Avatar";
|
||||||
|
|
@ -48,7 +49,7 @@ export default function NavSidebar({
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [collapsed, setCollapsed] = useState<boolean>(
|
const [collapsed, setCollapsed] = useState<boolean>(
|
||||||
() => localStorage.getItem("siftlode.navCollapsed") === "1"
|
() => localStorage.getItem(LS.navCollapsed) === "1"
|
||||||
);
|
);
|
||||||
const [acctOpen, setAcctOpen] = useState(false);
|
const [acctOpen, setAcctOpen] = useState(false);
|
||||||
const acctBtnRef = useRef<HTMLButtonElement | null>(null);
|
const acctBtnRef = useRef<HTMLButtonElement | null>(null);
|
||||||
|
|
@ -90,7 +91,7 @@ export default function NavSidebar({
|
||||||
function toggleCollapsed() {
|
function toggleCollapsed() {
|
||||||
setCollapsed((c) => {
|
setCollapsed((c) => {
|
||||||
const next = !c;
|
const next = !c;
|
||||||
localStorage.setItem("siftlode.navCollapsed", next ? "1" : "0");
|
localStorage.setItem(LS.navCollapsed, next ? "1" : "0");
|
||||||
return next;
|
return next;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
@ -111,6 +112,14 @@ export default function NavSidebar({
|
||||||
async function switchTo(id: number) {
|
async function switchTo(id: number) {
|
||||||
try {
|
try {
|
||||||
await api.switchAccount(id);
|
await api.switchAccount(id);
|
||||||
|
// Drop the previous account's in-module sub-view/overlay before the reload (which would
|
||||||
|
// otherwise preserve history.state across the swap). Otherwise the new account lands on a
|
||||||
|
// stale sub-view — e.g. an open chat thread whose partnerId belongs to the OLD identity
|
||||||
|
// (often the new account's own id → an empty self-thread). Start at the module root instead.
|
||||||
|
const st = { ...(window.history.state || {}) };
|
||||||
|
delete st._sub;
|
||||||
|
delete st._ov;
|
||||||
|
window.history.replaceState(st, "");
|
||||||
location.reload(); // cleanest way to reload all per-user state for the new account
|
location.reload(); // cleanest way to reload all per-user state for the new account
|
||||||
} catch {
|
} catch {
|
||||||
/* a revoked/removed account — leave the popover open */
|
/* a revoked/removed account — leave the popover open */
|
||||||
|
|
|
||||||
|
|
@ -19,7 +19,7 @@ import {
|
||||||
type VideoHiddenMeta,
|
type VideoHiddenMeta,
|
||||||
type VideoWatchedMeta,
|
type VideoWatchedMeta,
|
||||||
} from "../lib/notifications";
|
} from "../lib/notifications";
|
||||||
import { channelYouTubeUrl } from "../lib/format";
|
import { channelYouTubeUrl, relativeFromMs, relativeTime } from "../lib/format";
|
||||||
import type { Page } from "../lib/urlState";
|
import type { Page } from "../lib/urlState";
|
||||||
import { LEVEL_STYLE } from "./Toaster";
|
import { LEVEL_STYLE } from "./Toaster";
|
||||||
|
|
||||||
|
|
@ -29,22 +29,6 @@ import { LEVEL_STYLE } from "./Toaster";
|
||||||
// there's one indicator and one place to look. Transient toasts still pop via the Toaster.
|
// there's one indicator and one place to look. Transient toasts still pop via the Toaster.
|
||||||
const POLL_MS = 8000;
|
const POLL_MS = 8000;
|
||||||
|
|
||||||
function relativeTime(iso: string | null, t: (k: string, o?: any) => string): string {
|
|
||||||
if (!iso) return "";
|
|
||||||
return relativeFromMs(new Date(iso).getTime(), t);
|
|
||||||
}
|
|
||||||
|
|
||||||
function relativeFromMs(ms: number, t: (k: string, o?: any) => string): string {
|
|
||||||
const secs = Math.max(0, Math.round((Date.now() - ms) / 1000));
|
|
||||||
// Reuse the bell's relative-time strings (notifications.time.*) — same format, no dupes.
|
|
||||||
if (secs < 60) return t("notifications.time.justNow");
|
|
||||||
const mins = Math.round(secs / 60);
|
|
||||||
if (mins < 60) return t("notifications.time.minutes", { count: mins });
|
|
||||||
const hours = Math.round(mins / 60);
|
|
||||||
if (hours < 24) return t("notifications.time.hours", { count: hours });
|
|
||||||
return t("notifications.time.days", { count: Math.round(hours / 24) });
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function NotificationsPanel({
|
export default function NotificationsPanel({
|
||||||
filters,
|
filters,
|
||||||
setFilters,
|
setFilters,
|
||||||
|
|
@ -253,7 +237,7 @@ function NotificationRow({
|
||||||
<div className="flex items-baseline gap-2">
|
<div className="flex items-baseline gap-2">
|
||||||
<span className="font-medium text-sm">{title}</span>
|
<span className="font-medium text-sm">{title}</span>
|
||||||
<span className="text-[11px] text-muted shrink-0">
|
<span className="text-[11px] text-muted shrink-0">
|
||||||
{relativeTime(n.created_at, t)}
|
{relativeTime(n.created_at)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{body && <div className="text-sm text-muted mt-0.5">{body}</div>}
|
{body && <div className="text-sm text-muted mt-0.5">{body}</div>}
|
||||||
|
|
@ -338,7 +322,7 @@ function ClientActivityRow({
|
||||||
{n.title && <div className="text-sm font-semibold">{n.title}</div>}
|
{n.title && <div className="text-sm font-semibold">{n.title}</div>}
|
||||||
<div className="text-sm break-words">{n.message}</div>
|
<div className="text-sm break-words">{n.message}</div>
|
||||||
<div className="flex items-center gap-2 mt-0.5">
|
<div className="flex items-center gap-2 mt-0.5">
|
||||||
<span className="text-[11px] text-muted">{relativeFromMs(n.ts, t)}</span>
|
<span className="text-[11px] text-muted">{relativeFromMs(n.ts)}</span>
|
||||||
{needsAction && (
|
{needsAction && (
|
||||||
<span className="text-[10px] uppercase tracking-wide font-semibold text-accent">
|
<span className="text-[10px] uppercase tracking-wide font-semibold text-accent">
|
||||||
{t("notifications.actionNeeded")}
|
{t("notifications.actionNeeded")}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,5 @@
|
||||||
import { useEffect, useRef, useState, type ReactNode } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import type { TFunction } from "i18next";
|
|
||||||
import { createPortal } from "react-dom";
|
import { createPortal } from "react-dom";
|
||||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import { AlertTriangle, ArrowLeft, Check, CheckCheck, ExternalLink, SkipBack, SkipForward, X } from "lucide-react";
|
import { AlertTriangle, ArrowLeft, Check, CheckCheck, ExternalLink, SkipBack, SkipForward, X } from "lucide-react";
|
||||||
|
|
@ -8,152 +7,9 @@ import Avatar from "./Avatar";
|
||||||
import AddToPlaylist from "./AddToPlaylist";
|
import AddToPlaylist from "./AddToPlaylist";
|
||||||
import { api, type Video } from "../lib/api";
|
import { api, type Video } from "../lib/api";
|
||||||
import { channelYouTubeUrl, formatDuration, formatViews, relativeTime } from "../lib/format";
|
import { channelYouTubeUrl, formatDuration, formatViews, relativeTime } from "../lib/format";
|
||||||
|
import { renderDescription } from "../lib/descriptionLinks";
|
||||||
import { useBackToClose } from "../lib/history";
|
import { useBackToClose } from "../lib/history";
|
||||||
|
|
||||||
// Turn a description into clickable nodes:
|
|
||||||
// - bare timestamps (mm:ss / hh:mm:ss) → seek the inline player
|
|
||||||
// - a YouTube link to the *current* video → seek (to its t= if any)
|
|
||||||
// - a YouTube link to *another* video → load it in the inline player
|
|
||||||
// - emails → mailto:, hashtags → YouTube's hashtag page, other URLs → new tab
|
|
||||||
const URL_RE = /(https?:\/\/[^\s<>]+)/g;
|
|
||||||
// One pass over plain text for the three inline token kinds (email | hashtag | timestamp).
|
|
||||||
const INLINE_RE =
|
|
||||||
/([A-Za-z0-9._%+-]+@[A-Za-z0-9-]+\.[A-Za-z0-9.-]+)|(#[\p{L}\p{N}_]+)|(\b(?:\d{1,2}:)?\d{1,2}:\d{2}\b)/gu;
|
|
||||||
|
|
||||||
function tsToSeconds(ts: string): number {
|
|
||||||
const parts = ts.split(":").map((n) => parseInt(n, 10));
|
|
||||||
return parts.length === 3
|
|
||||||
? parts[0] * 3600 + parts[1] * 60 + parts[2]
|
|
||||||
: parts[0] * 60 + parts[1];
|
|
||||||
}
|
|
||||||
|
|
||||||
// Parse a YouTube `t`/`start` param: "90", "90s", or "1h2m3s".
|
|
||||||
function parseStart(t: string | null): number | null {
|
|
||||||
if (!t) return null;
|
|
||||||
if (/^\d+$/.test(t)) return parseInt(t, 10) || null;
|
|
||||||
const m = t.match(/^(?:(\d+)h)?(?:(\d+)m)?(?:(\d+)s)?$/);
|
|
||||||
if (!m) return null;
|
|
||||||
const total =
|
|
||||||
parseInt(m[1] || "0", 10) * 3600 +
|
|
||||||
parseInt(m[2] || "0", 10) * 60 +
|
|
||||||
parseInt(m[3] || "0", 10);
|
|
||||||
return total || null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Extract a video id (+ optional start) from a YouTube URL, else null.
|
|
||||||
function parseYouTube(url: string): { id: string; start: number | null } | null {
|
|
||||||
let u: URL;
|
|
||||||
try {
|
|
||||||
u = new URL(url);
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
const host = u.hostname.replace(/^www\./, "");
|
|
||||||
let id: string | null = null;
|
|
||||||
if (host === "youtu.be") {
|
|
||||||
id = u.pathname.slice(1) || null;
|
|
||||||
} else if (host === "youtube.com" || host === "m.youtube.com" || host === "music.youtube.com") {
|
|
||||||
if (u.pathname === "/watch") id = u.searchParams.get("v");
|
|
||||||
else {
|
|
||||||
const m = u.pathname.match(/^\/(?:shorts|embed|live)\/([^/?#]+)/);
|
|
||||||
if (m) id = m[1];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (!id) return null;
|
|
||||||
return { id, start: parseStart(u.searchParams.get("t") || u.searchParams.get("start")) };
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderDescription(
|
|
||||||
text: string,
|
|
||||||
opts: {
|
|
||||||
currentId: string;
|
|
||||||
onSeek: (seconds: number) => void;
|
|
||||||
onLoadVideo: (id: string, start: number | null) => void;
|
|
||||||
t: TFunction;
|
|
||||||
}
|
|
||||||
): ReactNode[] {
|
|
||||||
const { t } = opts;
|
|
||||||
const out: ReactNode[] = [];
|
|
||||||
let key = 0;
|
|
||||||
const linkCls = "text-accent hover:underline break-all";
|
|
||||||
// Tidy YouTube descriptions: drop trailing spaces and remove blank lines entirely
|
|
||||||
// (they're just noise in the popover), keeping single line breaks.
|
|
||||||
const clean = text.replace(/[ \t]+\n/g, "\n").replace(/\n{2,}/g, "\n").trim();
|
|
||||||
for (const chunk of clean.split(URL_RE)) {
|
|
||||||
if (/^https?:\/\//.test(chunk)) {
|
|
||||||
const href = chunk.replace(/[.,;:!?)\]]+$/, "");
|
|
||||||
const yt = parseYouTube(href);
|
|
||||||
if (yt) {
|
|
||||||
const sameVideo = yt.id === opts.currentId;
|
|
||||||
out.push(
|
|
||||||
<button
|
|
||||||
key={key++}
|
|
||||||
onClick={() =>
|
|
||||||
sameVideo ? opts.onSeek(yt.start ?? 0) : opts.onLoadVideo(yt.id, yt.start)
|
|
||||||
}
|
|
||||||
className={linkCls}
|
|
||||||
title={sameVideo ? t("player.jumpToTime") : t("player.playInApp")}
|
|
||||||
>
|
|
||||||
{href}
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
out.push(
|
|
||||||
<a key={key++} href={href} target="_blank" rel="noreferrer" className={linkCls}>
|
|
||||||
{href}
|
|
||||||
</a>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
// Plain text: pull out emails, hashtags and bare timestamps.
|
|
||||||
INLINE_RE.lastIndex = 0;
|
|
||||||
let last = 0;
|
|
||||||
let m: RegExpExecArray | null;
|
|
||||||
while ((m = INLINE_RE.exec(chunk))) {
|
|
||||||
if (m.index > last) out.push(<span key={key++}>{chunk.slice(last, m.index)}</span>);
|
|
||||||
if (m[1]) {
|
|
||||||
// Email — trim trailing punctuation the domain rule may have swallowed.
|
|
||||||
const email = m[1].replace(/[.,;:]+$/, "");
|
|
||||||
out.push(
|
|
||||||
<a key={key++} href={`mailto:${email}`} className={linkCls}>
|
|
||||||
{email}
|
|
||||||
</a>
|
|
||||||
);
|
|
||||||
if (m[1].length > email.length) out.push(<span key={key++}>{m[1].slice(email.length)}</span>);
|
|
||||||
} else if (m[2]) {
|
|
||||||
// Hashtag → YouTube's hashtag feed (mirrors native YouTube behavior).
|
|
||||||
const tag = m[2].slice(1);
|
|
||||||
out.push(
|
|
||||||
<a
|
|
||||||
key={key++}
|
|
||||||
href={`https://www.youtube.com/hashtag/${encodeURIComponent(tag)}`}
|
|
||||||
target="_blank"
|
|
||||||
rel="noreferrer"
|
|
||||||
className={linkCls}
|
|
||||||
>
|
|
||||||
{m[2]}
|
|
||||||
</a>
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
const ts = m[3];
|
|
||||||
out.push(
|
|
||||||
<button
|
|
||||||
key={key++}
|
|
||||||
onClick={() => opts.onSeek(tsToSeconds(ts))}
|
|
||||||
className="text-accent hover:underline"
|
|
||||||
>
|
|
||||||
{ts}
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
last = m.index + m[0].length;
|
|
||||||
}
|
|
||||||
if (last < chunk.length) out.push(<span key={key++}>{chunk.slice(last)}</span>);
|
|
||||||
}
|
|
||||||
return out;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Experiment (branch experiment/inline-player): play the video in-app via the
|
// Experiment (branch experiment/inline-player): play the video in-app via the
|
||||||
// YouTube IFrame Player API (not a bare embed) so we can read playback position
|
// YouTube IFrame Player API (not a bare embed) so we can read playback position
|
||||||
// and resume where the user left off. The modal closes via the in-card Close
|
// and resume where the user left off. The modal closes via the in-card Close
|
||||||
|
|
|
||||||
|
|
@ -37,6 +37,7 @@ import {
|
||||||
import { api, type Playlist, type Video } from "../lib/api";
|
import { api, type Playlist, type Video } from "../lib/api";
|
||||||
import { formatDuration } from "../lib/format";
|
import { formatDuration } from "../lib/format";
|
||||||
import { notify } from "../lib/notifications";
|
import { notify } from "../lib/notifications";
|
||||||
|
import { LS, readMerged, writeJSON } from "../lib/storage";
|
||||||
import { useUndoable } from "../lib/useUndoable";
|
import { useUndoable } from "../lib/useUndoable";
|
||||||
import PlayerModal from "./PlayerModal";
|
import PlayerModal from "./PlayerModal";
|
||||||
import UndoToolbar from "./UndoToolbar";
|
import UndoToolbar from "./UndoToolbar";
|
||||||
|
|
@ -187,26 +188,18 @@ export default function Playlists({ canWrite }: { canWrite: boolean }) {
|
||||||
const confirm = useConfirm();
|
const confirm = useConfirm();
|
||||||
// Persist the selected playlist so F5 keeps it (the de-URL refactor dropped it from the URL).
|
// Persist the selected playlist so F5 keeps it (the de-URL refactor dropped it from the URL).
|
||||||
const [selectedId, setSelectedId] = useState<number | null>(() => {
|
const [selectedId, setSelectedId] = useState<number | null>(() => {
|
||||||
const s = localStorage.getItem("siftlode.playlist");
|
const s = localStorage.getItem(LS.playlist);
|
||||||
return s ? Number(s) : null;
|
return s ? Number(s) : null;
|
||||||
});
|
});
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (selectedId != null) localStorage.setItem("siftlode.playlist", String(selectedId));
|
if (selectedId != null) localStorage.setItem(LS.playlist, String(selectedId));
|
||||||
}, [selectedId]);
|
}, [selectedId]);
|
||||||
const selectedRef = useRef<HTMLButtonElement | null>(null);
|
const selectedRef = useRef<HTMLButtonElement | null>(null);
|
||||||
const scrolledRef = useRef(false);
|
const scrolledRef = useRef(false);
|
||||||
// How the left playlist rail is ordered (persisted).
|
// How the left playlist rail is ordered (persisted).
|
||||||
const [plSort, setPlSort] = useState<PlSort>(() => {
|
const [plSort, setPlSort] = useState<PlSort>(() => readMerged(LS.plSort, PL_SORT_DEFAULT));
|
||||||
try {
|
|
||||||
const s = localStorage.getItem("siftlode.plSort");
|
|
||||||
if (s) return { ...PL_SORT_DEFAULT, ...JSON.parse(s) };
|
|
||||||
} catch {
|
|
||||||
/* ignore */
|
|
||||||
}
|
|
||||||
return PL_SORT_DEFAULT;
|
|
||||||
});
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
localStorage.setItem("siftlode.plSort", JSON.stringify(plSort));
|
writeJSON(LS.plSort, plSort);
|
||||||
}, [plSort]);
|
}, [plSort]);
|
||||||
const [newName, setNewName] = useState("");
|
const [newName, setNewName] = useState("");
|
||||||
const [renaming, setRenaming] = useState(false);
|
const [renaming, setRenaming] = useState(false);
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
import i18n from "../i18n";
|
||||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
import {
|
import {
|
||||||
Activity,
|
Activity,
|
||||||
|
|
@ -31,7 +32,7 @@ function secsUntil(iso: string | null): number | null {
|
||||||
}
|
}
|
||||||
|
|
||||||
function fmtCountdown(s: number): string {
|
function fmtCountdown(s: number): string {
|
||||||
if (s <= 0) return "now";
|
if (s <= 0) return i18n.t("time.eta.now");
|
||||||
if (s < 60) return `${s}s`;
|
if (s < 60) return `${s}s`;
|
||||||
const m = Math.floor(s / 60);
|
const m = Math.floor(s / 60);
|
||||||
if (m < 60) return `${m}m ${s % 60}s`;
|
if (m < 60) return `${m}m ${s % 60}s`;
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,15 @@
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useQueryClient } from "@tanstack/react-query";
|
import { useQueryClient } from "@tanstack/react-query";
|
||||||
import { Bell, Check, Monitor, RotateCcw, Save, Trash2, User } from "lucide-react";
|
import { Bell, Monitor, Trash2, User } from "lucide-react";
|
||||||
import { SCHEMES, type Scheme, type ThemePrefs } from "../lib/theme";
|
import { SCHEMES, type Scheme, type ThemePrefs } from "../lib/theme";
|
||||||
import { api, type Me } from "../lib/api";
|
import { api, type Me } from "../lib/api";
|
||||||
|
import { LS, usePersistedState } from "../lib/storage";
|
||||||
import Avatar from "./Avatar";
|
import Avatar from "./Avatar";
|
||||||
import { notify, type NotifSettings } from "../lib/notifications";
|
import { notify, type NotifSettings } from "../lib/notifications";
|
||||||
import Tooltip from "./Tooltip";
|
import Tooltip from "./Tooltip";
|
||||||
|
import { Section, SettingRow, Switch } from "./ui/form";
|
||||||
|
import { DraftSaveBar } from "./ui/DraftSaveBar";
|
||||||
import { useConfirm } from "./ConfirmProvider";
|
import { useConfirm } from "./ConfirmProvider";
|
||||||
|
|
||||||
// The Settings page edits server-persisted preferences as a draft: changes apply locally
|
// The Settings page edits server-persisted preferences as a draft: changes apply locally
|
||||||
|
|
@ -38,14 +41,6 @@ const TABS: { id: TabId; icon: typeof Monitor }[] = [
|
||||||
{ id: "account", icon: User },
|
{ id: "account", icon: User },
|
||||||
];
|
];
|
||||||
|
|
||||||
// Persist the active tab so a reload (F5) keeps the user where they were instead of
|
|
||||||
// snapping back to "appearance".
|
|
||||||
const SETTINGS_TAB_KEY = "siftlode.settingsTab";
|
|
||||||
function loadSettingsTab(): TabId {
|
|
||||||
const v = localStorage.getItem(SETTINGS_TAB_KEY);
|
|
||||||
return TABS.some((tabItem) => tabItem.id === v) ? (v as TabId) : "appearance";
|
|
||||||
}
|
|
||||||
|
|
||||||
// Settings as a page (Design B): rendered in the main content area, not an overlay. It keeps
|
// Settings as a page (Design B): rendered in the main content area, not an overlay. It keeps
|
||||||
// the frosted `.glass` surface so it still refracts the ambient backdrop. The page title is
|
// the frosted `.glass` surface so it still refracts the ambient backdrop. The page title is
|
||||||
// shown by the Header; the tab rail stays as in-page sub-navigation.
|
// shown by the Header; the tab rail stays as in-page sub-navigation.
|
||||||
|
|
@ -59,11 +54,11 @@ export default function SettingsPanel({
|
||||||
onOpenWizard: () => void;
|
onOpenWizard: () => void;
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [tab, setTabState] = useState<TabId>(loadSettingsTab);
|
const [tabRaw, setTab] = usePersistedState(LS.settingsTab, "appearance");
|
||||||
const setTab = (id: TabId) => {
|
// Clamp at render so a stale/removed tab id falls back to Appearance.
|
||||||
setTabState(id);
|
const tab: TabId = TABS.some((tabItem) => tabItem.id === tabRaw)
|
||||||
localStorage.setItem(SETTINGS_TAB_KEY, id);
|
? (tabRaw as TabId)
|
||||||
};
|
: "appearance";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-4 max-w-3xl w-full mx-auto">
|
<div className="p-4 max-w-3xl w-full mx-auto">
|
||||||
|
|
@ -110,87 +105,22 @@ export default function SettingsPanel({
|
||||||
|
|
||||||
function PrefsSaveBar({ prefs }: { prefs: PrefsController }) {
|
function PrefsSaveBar({ prefs }: { prefs: PrefsController }) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
// Stay mounted while a transient "saved"/"error" message is fading, even after dirty clears.
|
|
||||||
if (!prefs.dirty && prefs.saveState === "idle") return null;
|
|
||||||
const saving = prefs.saveState === "saving";
|
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-between gap-3 border-t border-border/60 px-4 py-3 bg-card/40">
|
<DraftSaveBar
|
||||||
<span className="text-sm text-muted">
|
variant="inline"
|
||||||
{prefs.saveState === "saved"
|
dirty={prefs.dirty}
|
||||||
? <span className="text-emerald-500 inline-flex items-center gap-1.5"><Check className="w-4 h-4" />{t("settings.save.saved")}</span>
|
state={prefs.saveState}
|
||||||
: prefs.saveState === "error"
|
onSave={prefs.save}
|
||||||
? <span className="text-red-500">{t("settings.save.failed")}</span>
|
onDiscard={prefs.discard}
|
||||||
: t("settings.save.unsaved")}
|
labels={{
|
||||||
</span>
|
saved: t("settings.save.saved"),
|
||||||
<div className="flex items-center gap-2">
|
failed: t("settings.save.failed"),
|
||||||
<button
|
unsaved: t("settings.save.unsaved"),
|
||||||
onClick={prefs.discard}
|
discard: t("settings.save.discard"),
|
||||||
disabled={saving || !prefs.dirty}
|
save: t("settings.save.save"),
|
||||||
className="glass-card glass-hover flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-sm disabled:opacity-40 transition"
|
saving: t("settings.save.saving"),
|
||||||
>
|
}}
|
||||||
<RotateCcw className="w-4 h-4" />
|
/>
|
||||||
{t("settings.save.discard")}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={prefs.save}
|
|
||||||
disabled={saving || !prefs.dirty}
|
|
||||||
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-sm bg-accent text-accent-fg font-medium shadow-md shadow-accent/20 disabled:opacity-40 transition"
|
|
||||||
>
|
|
||||||
<Save className="w-4 h-4" />
|
|
||||||
{saving ? t("settings.save.saving") : t("settings.save.save")}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function Section({ title, children }: { title: string; children: React.ReactNode }) {
|
|
||||||
return (
|
|
||||||
<div className="mb-6">
|
|
||||||
<div className="text-xs uppercase tracking-wide text-muted mb-2">{title}</div>
|
|
||||||
{children}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function Row({
|
|
||||||
label,
|
|
||||||
hint,
|
|
||||||
children,
|
|
||||||
}: {
|
|
||||||
label: string;
|
|
||||||
hint?: string;
|
|
||||||
children: React.ReactNode;
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<div className="flex items-center justify-between gap-3 py-1.5 text-sm">
|
|
||||||
<Tooltip hint={hint ?? ""}>
|
|
||||||
<span
|
|
||||||
className={
|
|
||||||
hint ? "underline decoration-dotted decoration-muted/40 underline-offset-4 cursor-help" : ""
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{label}
|
|
||||||
</span>
|
|
||||||
</Tooltip>
|
|
||||||
{children}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function Switch({ checked, onChange }: { checked: boolean; onChange: (v: boolean) => void }) {
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => onChange(!checked)}
|
|
||||||
className={`w-9 h-5 rounded-full transition relative shrink-0 ${checked ? "bg-accent" : "bg-border"}`}
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
className={`absolute top-0.5 w-4 h-4 rounded-full bg-white shadow transition-all ${
|
|
||||||
checked ? "left-[18px]" : "left-0.5"
|
|
||||||
}`}
|
|
||||||
/>
|
|
||||||
</button>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -219,27 +149,27 @@ function Appearance({ prefs }: { prefs: PrefsController }) {
|
||||||
</Section>
|
</Section>
|
||||||
|
|
||||||
<Section title={t("settings.appearance.display")}>
|
<Section title={t("settings.appearance.display")}>
|
||||||
<Row label={t("settings.appearance.darkMode")}>
|
<SettingRow label={t("settings.appearance.darkMode")}>
|
||||||
<Switch
|
<Switch
|
||||||
checked={theme.mode === "dark"}
|
checked={theme.mode === "dark"}
|
||||||
onChange={(v) => setTheme({ ...theme, mode: v ? "dark" : "light" })}
|
onChange={(v) => setTheme({ ...theme, mode: v ? "dark" : "light" })}
|
||||||
/>
|
/>
|
||||||
</Row>
|
</SettingRow>
|
||||||
<Row label={t("settings.appearance.listView")} hint={t("settings.appearance.listViewHint")}>
|
<SettingRow label={t("settings.appearance.listView")} hint={t("settings.appearance.listViewHint")}>
|
||||||
<Switch checked={view === "list"} onChange={(v) => setView(v ? "list" : "grid")} />
|
<Switch checked={view === "list"} onChange={(v) => setView(v ? "list" : "grid")} />
|
||||||
</Row>
|
</SettingRow>
|
||||||
<Row
|
<SettingRow
|
||||||
label={t("settings.appearance.performanceMode")}
|
label={t("settings.appearance.performanceMode")}
|
||||||
hint={t("settings.appearance.performanceModeHint")}
|
hint={t("settings.appearance.performanceModeHint")}
|
||||||
>
|
>
|
||||||
<Switch checked={perf} onChange={setPerf} />
|
<Switch checked={perf} onChange={setPerf} />
|
||||||
</Row>
|
</SettingRow>
|
||||||
<Row
|
<SettingRow
|
||||||
label={t("settings.appearance.showHints")}
|
label={t("settings.appearance.showHints")}
|
||||||
hint={t("settings.appearance.showHintsHint")}
|
hint={t("settings.appearance.showHintsHint")}
|
||||||
>
|
>
|
||||||
<Switch checked={hints} onChange={setHints} />
|
<Switch checked={hints} onChange={setHints} />
|
||||||
</Row>
|
</SettingRow>
|
||||||
</Section>
|
</Section>
|
||||||
|
|
||||||
<Section title={t("settings.appearance.textSize")}>
|
<Section title={t("settings.appearance.textSize")}>
|
||||||
|
|
@ -268,18 +198,18 @@ function Notifications({ prefs }: { prefs: PrefsController }) {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Section title={t("settings.notifications.title")}>
|
<Section title={t("settings.notifications.title")}>
|
||||||
<Row
|
<SettingRow
|
||||||
label={t("settings.notifications.sound")}
|
label={t("settings.notifications.sound")}
|
||||||
hint={t("settings.notifications.soundHint")}
|
hint={t("settings.notifications.soundHint")}
|
||||||
>
|
>
|
||||||
<Switch checked={cfg.sound} onChange={(v) => update({ sound: v })} />
|
<Switch checked={cfg.sound} onChange={(v) => update({ sound: v })} />
|
||||||
</Row>
|
</SettingRow>
|
||||||
<Row
|
<SettingRow
|
||||||
label={t("settings.notifications.autoDismiss")}
|
label={t("settings.notifications.autoDismiss")}
|
||||||
hint={t("settings.notifications.autoDismissHint")}
|
hint={t("settings.notifications.autoDismissHint")}
|
||||||
>
|
>
|
||||||
<Switch checked={autoOn} onChange={(v) => update({ durationMs: v ? 6000 : 0 })} />
|
<Switch checked={autoOn} onChange={(v) => update({ durationMs: v ? 6000 : 0 })} />
|
||||||
</Row>
|
</SettingRow>
|
||||||
{autoOn && (
|
{autoOn && (
|
||||||
<div className="py-1.5 text-sm">
|
<div className="py-1.5 text-sm">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
|
|
|
||||||
|
|
@ -36,6 +36,7 @@ import {
|
||||||
} from "../lib/sidebarLayout";
|
} from "../lib/sidebarLayout";
|
||||||
import { shareUrl } from "../lib/urlState";
|
import { shareUrl } from "../lib/urlState";
|
||||||
import { notify } from "../lib/notifications";
|
import { notify } from "../lib/notifications";
|
||||||
|
import { Switch } from "./ui/form";
|
||||||
import TagManager from "./TagManager";
|
import TagManager from "./TagManager";
|
||||||
|
|
||||||
// Filter ids; display labels resolved at render time via t("sidebar.sort.<id>") etc.
|
// Filter ids; display labels resolved at render time via t("sidebar.sort.<id>") etc.
|
||||||
|
|
@ -622,19 +623,7 @@ function Toggle({
|
||||||
return (
|
return (
|
||||||
<label className="flex items-center justify-between py-1 cursor-pointer text-sm">
|
<label className="flex items-center justify-between py-1 cursor-pointer text-sm">
|
||||||
<span>{label}</span>
|
<span>{label}</span>
|
||||||
<button
|
<Switch checked={checked} onChange={onChange} />
|
||||||
type="button"
|
|
||||||
onClick={() => onChange(!checked)}
|
|
||||||
className={`w-9 h-5 rounded-full transition relative ${
|
|
||||||
checked ? "bg-accent" : "bg-border"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
className={`absolute top-0.5 w-4 h-4 rounded-full bg-white transition-all ${
|
|
||||||
checked ? "left-[18px]" : "left-0.5"
|
|
||||||
}`}
|
|
||||||
/>
|
|
||||||
</button>
|
|
||||||
</label>
|
</label>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -5,10 +5,11 @@ import { History, Pause, Play, RefreshCw } from "lucide-react";
|
||||||
import { api, type AdminQuotaRow, type Me } from "../lib/api";
|
import { api, type AdminQuotaRow, type Me } from "../lib/api";
|
||||||
import { formatEta, quotaActionLabel } from "../lib/format";
|
import { formatEta, quotaActionLabel } from "../lib/format";
|
||||||
import { notify } from "../lib/notifications";
|
import { notify } from "../lib/notifications";
|
||||||
|
import { LS, usePersistedState } from "../lib/storage";
|
||||||
import Tooltip from "./Tooltip";
|
import Tooltip from "./Tooltip";
|
||||||
|
import { Section, SettingRow } from "./ui/form";
|
||||||
|
|
||||||
const RANGES = [7, 30, 90] as const;
|
const RANGES = [7, 30, 90] as const;
|
||||||
const STATS_TAB_KEY = "siftlode.statsTab";
|
|
||||||
type StatsTab = "overview" | "system";
|
type StatsTab = "overview" | "system";
|
||||||
|
|
||||||
// Usage & stats page. "Overview" is per-user (sync status + your own API usage + actions);
|
// Usage & stats page. "Overview" is per-user (sync status + your own API usage + actions);
|
||||||
|
|
@ -17,14 +18,9 @@ type StatsTab = "overview" | "system";
|
||||||
export default function Stats({ me }: { me: Me }) {
|
export default function Stats({ me }: { me: Me }) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const isAdmin = me.role === "admin";
|
const isAdmin = me.role === "admin";
|
||||||
const [tab, setTabState] = useState<StatsTab>(() => {
|
const [tabRaw, setTab] = usePersistedState(LS.statsTab, "overview");
|
||||||
const v = localStorage.getItem(STATS_TAB_KEY);
|
// Clamp at render: only admins may land on the System tab (covers a stale stored value).
|
||||||
return v === "system" && isAdmin ? "system" : "overview";
|
const tab: StatsTab = tabRaw === "system" && isAdmin ? "system" : "overview";
|
||||||
});
|
|
||||||
const setTab = (id: StatsTab) => {
|
|
||||||
setTabState(id);
|
|
||||||
localStorage.setItem(STATS_TAB_KEY, id);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-4 max-w-4xl mx-auto">
|
<div className="p-4 max-w-4xl mx-auto">
|
||||||
|
|
@ -52,31 +48,6 @@ export default function Stats({ me }: { me: Me }) {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function Section({ title, children }: { title: string; children: React.ReactNode }) {
|
|
||||||
return (
|
|
||||||
<div className="mb-6">
|
|
||||||
<div className="text-xs uppercase tracking-wide text-muted mb-2">{title}</div>
|
|
||||||
{children}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function Row({ label, hint, children }: { label: string; hint?: string; children: React.ReactNode }) {
|
|
||||||
return (
|
|
||||||
<div className="flex items-center justify-between gap-3 py-1.5 text-sm">
|
|
||||||
<Tooltip hint={hint ?? ""}>
|
|
||||||
<span
|
|
||||||
className={
|
|
||||||
hint ? "underline decoration-dotted decoration-muted/40 underline-offset-4 cursor-help" : ""
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{label}
|
|
||||||
</span>
|
|
||||||
</Tooltip>
|
|
||||||
{children}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Per-user view: sync status + your own API usage + (for real accounts) the manual sync actions.
|
// Per-user view: sync status + your own API usage + (for real accounts) the manual sync actions.
|
||||||
function Overview({ me }: { me: Me }) {
|
function Overview({ me }: { me: Me }) {
|
||||||
|
|
@ -112,29 +83,29 @@ function Overview({ me }: { me: Me }) {
|
||||||
<Section title={t("stats.sync.myStatus")}>
|
<Section title={t("stats.sync.myStatus")}>
|
||||||
{s ? (
|
{s ? (
|
||||||
<div className="space-y-1.5 text-sm">
|
<div className="space-y-1.5 text-sm">
|
||||||
<Row label={t("stats.sync.channels")} hint={t("stats.sync.channelsHint")}>
|
<SettingRow label={t("stats.sync.channels")} hint={t("stats.sync.channelsHint")}>
|
||||||
{s.channels_total}
|
{s.channels_total}
|
||||||
</Row>
|
</SettingRow>
|
||||||
<Row label={t("stats.sync.recentSynced")} hint={t("stats.sync.recentSyncedHint")}>
|
<SettingRow label={t("stats.sync.recentSynced")} hint={t("stats.sync.recentSyncedHint")}>
|
||||||
{`${s.channels_recent_synced}/${s.channels_total}`}
|
{`${s.channels_recent_synced}/${s.channels_total}`}
|
||||||
</Row>
|
</SettingRow>
|
||||||
<Row label={t("stats.sync.fullHistory")} hint={t("stats.sync.fullHistoryHint")}>
|
<SettingRow label={t("stats.sync.fullHistory")} hint={t("stats.sync.fullHistoryHint")}>
|
||||||
{`${s.channels_deep_done}/${s.channels_deep_requested}`}
|
{`${s.channels_deep_done}/${s.channels_deep_requested}`}
|
||||||
</Row>
|
</SettingRow>
|
||||||
{s.deep_pending_count > 0 && (
|
{s.deep_pending_count > 0 && (
|
||||||
<Row
|
<SettingRow
|
||||||
label={t("stats.sync.fullHistoryEta")}
|
label={t("stats.sync.fullHistoryEta")}
|
||||||
hint={t("stats.sync.fullHistoryEtaHint", { count: s.deep_pending_count })}
|
hint={t("stats.sync.fullHistoryEtaHint", { count: s.deep_pending_count })}
|
||||||
>
|
>
|
||||||
{formatEta(s.deep_eta_seconds)}
|
{formatEta(s.deep_eta_seconds)}
|
||||||
</Row>
|
</SettingRow>
|
||||||
)}
|
)}
|
||||||
<Row label={t("stats.sync.myVideos")} hint={t("stats.sync.myVideosHint")}>
|
<SettingRow label={t("stats.sync.myVideos")} hint={t("stats.sync.myVideosHint")}>
|
||||||
{s.my_videos.toLocaleString()}
|
{s.my_videos.toLocaleString()}
|
||||||
</Row>
|
</SettingRow>
|
||||||
<Row label={t("stats.sync.quotaLeft")} hint={t("stats.sync.quotaLeftHint")}>
|
<SettingRow label={t("stats.sync.quotaLeft")} hint={t("stats.sync.quotaLeftHint")}>
|
||||||
{s.quota_remaining_today.toLocaleString()}
|
{s.quota_remaining_today.toLocaleString()}
|
||||||
</Row>
|
</SettingRow>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="text-muted text-sm">{t("stats.sync.loading")}</div>
|
<div className="text-muted text-sm">{t("stats.sync.loading")}</div>
|
||||||
|
|
@ -145,12 +116,12 @@ function Overview({ me }: { me: Me }) {
|
||||||
{usage.data ? (
|
{usage.data ? (
|
||||||
<>
|
<>
|
||||||
<div className="space-y-1.5 text-sm">
|
<div className="space-y-1.5 text-sm">
|
||||||
<Row label={t("stats.sync.today")} hint={t("stats.sync.todayHint")}>
|
<SettingRow label={t("stats.sync.today")} hint={t("stats.sync.todayHint")}>
|
||||||
{usage.data.today.toLocaleString()}
|
{usage.data.today.toLocaleString()}
|
||||||
</Row>
|
</SettingRow>
|
||||||
<Row label={t("stats.sync.last7d")}>{usage.data.last_7d.toLocaleString()}</Row>
|
<SettingRow label={t("stats.sync.last7d")}>{usage.data.last_7d.toLocaleString()}</SettingRow>
|
||||||
<Row label={t("stats.sync.last30d")}>{usage.data.last_30d.toLocaleString()}</Row>
|
<SettingRow label={t("stats.sync.last30d")}>{usage.data.last_30d.toLocaleString()}</SettingRow>
|
||||||
<Row label={t("stats.sync.allTime")}>{usage.data.all_time.toLocaleString()}</Row>
|
<SettingRow label={t("stats.sync.allTime")}>{usage.data.all_time.toLocaleString()}</SettingRow>
|
||||||
</div>
|
</div>
|
||||||
{Object.keys(usage.data.by_action).length > 0 && (
|
{Object.keys(usage.data.by_action).length > 0 && (
|
||||||
<div className="mt-2 pt-2 border-t border-border space-y-1 text-xs text-muted">
|
<div className="mt-2 pt-2 border-t border-border space-y-1 text-xs text-muted">
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { useState } from "react";
|
import { usePersistedState } from "../lib/storage";
|
||||||
|
|
||||||
// Reusable horizontal pill tab-rail for in-page sub-navigation (Configuration, Users & roles…).
|
// Reusable horizontal pill tab-rail for in-page sub-navigation (Configuration, Users & roles…).
|
||||||
// The active tab is persisted to localStorage so a reload (F5) keeps the user where they were,
|
// The active tab is persisted to localStorage so a reload (F5) keeps the user where they were,
|
||||||
|
|
@ -10,16 +10,9 @@ export interface TabDef {
|
||||||
badge?: number; // optional count pill (e.g. pending requests); hidden when 0/undefined
|
badge?: number; // optional count pill (e.g. pending requests); hidden when 0/undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Persisted active-tab state. Validation is deferred to the caller (it clamps to a valid id at
|
/** Persisted active-tab state — the canonical helper now lives in lib/storage as
|
||||||
* render) so this can be called before an async-loaded tab list is known, keeping hook order stable. */
|
* `usePersistedState`; re-exported here under its original name for existing call sites. */
|
||||||
export function usePersistedTab(storageKey: string, fallback = ""): [string, (id: string) => void] {
|
export const usePersistedTab = usePersistedState;
|
||||||
const [tab, setTabState] = useState<string>(() => localStorage.getItem(storageKey) ?? fallback);
|
|
||||||
const setTab = (id: string) => {
|
|
||||||
setTabState(id);
|
|
||||||
localStorage.setItem(storageKey, id);
|
|
||||||
};
|
|
||||||
return [tab, setTab];
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function Tabs({
|
export default function Tabs({
|
||||||
tabs,
|
tabs,
|
||||||
|
|
|
||||||
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 i18n from "i18next";
|
||||||
import { initReactI18next } from "react-i18next";
|
import { initReactI18next } from "react-i18next";
|
||||||
|
import { LS } from "../lib/storage";
|
||||||
|
|
||||||
// Endonyms (shown as-is regardless of the active language — the convention for language pickers).
|
// Endonyms (shown as-is regardless of the active language — the convention for language pickers).
|
||||||
export const LANGUAGES = [
|
export const LANGUAGES = [
|
||||||
|
|
@ -9,7 +10,7 @@ export const LANGUAGES = [
|
||||||
] as const;
|
] as const;
|
||||||
export type LangCode = (typeof LANGUAGES)[number]["code"];
|
export type LangCode = (typeof LANGUAGES)[number]["code"];
|
||||||
const SUPPORTED = LANGUAGES.map((l) => l.code) as readonly string[];
|
const SUPPORTED = LANGUAGES.map((l) => l.code) as readonly string[];
|
||||||
export const LANG_KEY = "siftlode.lang";
|
export const LANG_KEY = LS.lang;
|
||||||
|
|
||||||
// Auto-load every locale file (locales/<lang>/<area>.json) and merge each <area> under one
|
// Auto-load every locale file (locales/<lang>/<area>.json) and merge each <area> under one
|
||||||
// `translation` namespace, so components use t("area.key") and adding an area needs no wiring.
|
// `translation` namespace, so components use t("area.key") and adding an area needs no wiring.
|
||||||
|
|
|
||||||
|
|
@ -11,11 +11,5 @@
|
||||||
"openInManager": "Kanalverwaltung",
|
"openInManager": "Kanalverwaltung",
|
||||||
"openOnYouTube": "Auf YouTube öffnen",
|
"openOnYouTube": "Auf YouTube öffnen",
|
||||||
"unhidden": "Eingeblendet „{{title}}”",
|
"unhidden": "Eingeblendet „{{title}}”",
|
||||||
"unwatched": "Als nicht angesehen markiert „{{title}}”",
|
"unwatched": "Als nicht angesehen markiert „{{title}}”"
|
||||||
"time": {
|
|
||||||
"justNow": "gerade eben",
|
|
||||||
"minutes": "vor {{count}} Min.",
|
|
||||||
"hours": "vor {{count}} Std.",
|
|
||||||
"days": "vor {{count}} T."
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -6,5 +6,14 @@
|
||||||
"daysAgo": "vor {{count}} T.",
|
"daysAgo": "vor {{count}} T.",
|
||||||
"weeksAgo": "vor {{count}} Wo.",
|
"weeksAgo": "vor {{count}} Wo.",
|
||||||
"monthsAgo": "vor {{count}} Mon.",
|
"monthsAgo": "vor {{count}} Mon.",
|
||||||
"yearsAgo": "vor {{count}} J."
|
"yearsAgo": "vor {{count}} J.",
|
||||||
|
"eta": {
|
||||||
|
"now": "jetzt",
|
||||||
|
"done": "fertig",
|
||||||
|
"lessThanHour": "< 1 Stunde",
|
||||||
|
"hours_one": "~{{count}} Stunde",
|
||||||
|
"hours_other": "~{{count}} Stunden",
|
||||||
|
"days_one": "~{{count}} Tag",
|
||||||
|
"days_other": "~{{count}} Tage"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -11,11 +11,5 @@
|
||||||
"openInManager": "Channel manager",
|
"openInManager": "Channel manager",
|
||||||
"openOnYouTube": "Open on YouTube",
|
"openOnYouTube": "Open on YouTube",
|
||||||
"unhidden": "Unhidden “{{title}}”",
|
"unhidden": "Unhidden “{{title}}”",
|
||||||
"unwatched": "Unwatched “{{title}}”",
|
"unwatched": "Unwatched “{{title}}”"
|
||||||
"time": {
|
|
||||||
"justNow": "just now",
|
|
||||||
"minutes": "{{count}}m ago",
|
|
||||||
"hours": "{{count}}h ago",
|
|
||||||
"days": "{{count}}d ago"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -6,5 +6,14 @@
|
||||||
"daysAgo": "{{count}}d ago",
|
"daysAgo": "{{count}}d ago",
|
||||||
"weeksAgo": "{{count}} wk ago",
|
"weeksAgo": "{{count}} wk ago",
|
||||||
"monthsAgo": "{{count}} mo ago",
|
"monthsAgo": "{{count}} mo ago",
|
||||||
"yearsAgo": "{{count}} yr ago"
|
"yearsAgo": "{{count}} yr ago",
|
||||||
|
"eta": {
|
||||||
|
"now": "now",
|
||||||
|
"done": "done",
|
||||||
|
"lessThanHour": "< 1 hour",
|
||||||
|
"hours_one": "~{{count}} hour",
|
||||||
|
"hours_other": "~{{count}} hours",
|
||||||
|
"days_one": "~{{count}} day",
|
||||||
|
"days_other": "~{{count}} days"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -11,11 +11,5 @@
|
||||||
"openInManager": "Csatornakezelő",
|
"openInManager": "Csatornakezelő",
|
||||||
"openOnYouTube": "Megnyitás a YouTube-on",
|
"openOnYouTube": "Megnyitás a YouTube-on",
|
||||||
"unhidden": "Megjelenítve: „{{title}}”",
|
"unhidden": "Megjelenítve: „{{title}}”",
|
||||||
"unwatched": "Megtekintés visszavonva: „{{title}}”",
|
"unwatched": "Megtekintés visszavonva: „{{title}}”"
|
||||||
"time": {
|
|
||||||
"justNow": "az imént",
|
|
||||||
"minutes": "{{count}} perce",
|
|
||||||
"hours": "{{count}} órája",
|
|
||||||
"days": "{{count}} napja"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -6,5 +6,14 @@
|
||||||
"daysAgo": "{{count}} napja",
|
"daysAgo": "{{count}} napja",
|
||||||
"weeksAgo": "{{count}} hete",
|
"weeksAgo": "{{count}} hete",
|
||||||
"monthsAgo": "{{count}} hónapja",
|
"monthsAgo": "{{count}} hónapja",
|
||||||
"yearsAgo": "{{count}} éve"
|
"yearsAgo": "{{count}} éve",
|
||||||
|
"eta": {
|
||||||
|
"now": "most",
|
||||||
|
"done": "kész",
|
||||||
|
"lessThanHour": "< 1 óra",
|
||||||
|
"hours_one": "~{{count}} óra",
|
||||||
|
"hours_other": "~{{count}} óra",
|
||||||
|
"days_one": "~{{count}} nap",
|
||||||
|
"days_other": "~{{count}} nap"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
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 i18n from "../i18n";
|
||||||
|
import { createStore } from "./store";
|
||||||
|
|
||||||
// A single, self-explanatory error dialog (modal) the user must acknowledge — used when the
|
// A single, self-explanatory error dialog (modal) the user must acknowledge — used when the
|
||||||
// server can't carry out an action (a definitive 4xx/5xx response). Distinct from toasts
|
// server can't carry out an action (a definitive 4xx/5xx response). Distinct from toasts
|
||||||
|
|
@ -9,30 +10,18 @@ export interface AppError {
|
||||||
message: string;
|
message: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
let current: AppError | null = null;
|
const store = createStore<AppError | null>(null);
|
||||||
let listeners: Array<() => void> = [];
|
|
||||||
const emit = () => listeners.forEach((l) => l());
|
|
||||||
|
|
||||||
export function reportError(message?: string, title?: string): void {
|
export function reportError(message?: string, title?: string): void {
|
||||||
current = {
|
store.set({
|
||||||
title: title || i18n.t("errors.title"),
|
title: title || i18n.t("errors.title"),
|
||||||
message: message || i18n.t("errors.generic"),
|
message: message || i18n.t("errors.generic"),
|
||||||
};
|
});
|
||||||
emit();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function dismissError(): void {
|
export function dismissError(): void {
|
||||||
current = null;
|
store.set(null);
|
||||||
emit();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function subscribeError(listener: () => void): () => void {
|
export const subscribeError = store.subscribe;
|
||||||
listeners.push(listener);
|
export const getError = store.get;
|
||||||
return () => {
|
|
||||||
listeners = listeners.filter((l) => l !== listener);
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getError(): AppError | null {
|
|
||||||
return current;
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -2,8 +2,14 @@ import i18n from "../i18n";
|
||||||
|
|
||||||
export function relativeTime(iso: string | null): string {
|
export function relativeTime(iso: string | null): string {
|
||||||
if (!iso) return "";
|
if (!iso) return "";
|
||||||
const then = new Date(iso).getTime();
|
return relativeFromMs(new Date(iso).getTime());
|
||||||
const secs = Math.max(0, (Date.now() - then) / 1000);
|
}
|
||||||
|
|
||||||
|
/** Relative-time label from an epoch-millis timestamp (the ms-based half of relativeTime, for
|
||||||
|
* client-side notifications whose timestamps are already numbers). One implementation, one
|
||||||
|
* set of `time.*` strings — shared instead of re-implemented per component. */
|
||||||
|
export function relativeFromMs(ms: number): string {
|
||||||
|
const secs = Math.max(0, (Date.now() - ms) / 1000);
|
||||||
const units: [number, string][] = [
|
const units: [number, string][] = [
|
||||||
[60, "time.secondsAgo"],
|
[60, "time.secondsAgo"],
|
||||||
[3600, "time.minutesAgo"],
|
[3600, "time.minutesAgo"],
|
||||||
|
|
@ -51,17 +57,21 @@ export function quotaActionLabel(action: string): string {
|
||||||
return i18n.t(`quotaActions.${action}`, { defaultValue: action });
|
return i18n.t(`quotaActions.${action}`, { defaultValue: action });
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Coarse, human ETA for a remaining duration in seconds ("~3 hours", "~2 days"). */
|
/** Coarse, human ETA for a remaining duration in seconds ("~3 hours", "~2 days"). Translated
|
||||||
|
* via the `time.eta.*` keys (was hardcoded English, violating the trilingual rule). */
|
||||||
export function formatEta(seconds: number): string {
|
export function formatEta(seconds: number): string {
|
||||||
if (seconds <= 0) return "done";
|
if (seconds <= 0) return i18n.t("time.eta.done");
|
||||||
const hours = seconds / 3600;
|
const hours = seconds / 3600;
|
||||||
if (hours < 1) return "< 1 hour";
|
if (hours < 1) return i18n.t("time.eta.lessThanHour");
|
||||||
if (hours < 24) {
|
if (hours < 24) return i18n.t("time.eta.hours", { count: Math.round(hours) });
|
||||||
const h = Math.round(hours);
|
return i18n.t("time.eta.days", { count: Math.round(hours / 24) });
|
||||||
return `~${h} hour${h === 1 ? "" : "s"}`;
|
}
|
||||||
}
|
|
||||||
const d = Math.round(hours / 24);
|
/** Compact whole-channel total length ("12 h", "45 m"), hours-scale so H:MM:SS isn't huge. */
|
||||||
return `~${d} day${d === 1 ? "" : "s"}`;
|
export function formatTotalHours(sec: number): string {
|
||||||
|
if (!sec) return "—";
|
||||||
|
const h = Math.round(sec / 3600);
|
||||||
|
return h >= 1 ? `${h.toLocaleString()} h` : `${Math.max(1, Math.round(sec / 60))} m`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function formatViews(n: number | null): string {
|
export function formatViews(n: number | null): string {
|
||||||
|
|
|
||||||
|
|
@ -1,36 +1,27 @@
|
||||||
// App-wide "hints" toggle: when on, Tooltip components reveal short explanatory
|
// App-wide "hints" toggle: when on, Tooltip components reveal short explanatory
|
||||||
// captions on hover so the UI is somewhat self-documenting for new users. Experienced
|
// captions on hover so the UI is somewhat self-documenting for new users. Experienced
|
||||||
// users can turn it off in Settings. Persisted to localStorage (+ server preferences).
|
// users can turn it off in Settings. Persisted to localStorage (+ server preferences).
|
||||||
const KEY = "siftlode.hints";
|
import { createStore } from "./store";
|
||||||
|
import { LS } from "./storage";
|
||||||
let enabled = load();
|
|
||||||
let listeners: Array<() => void> = [];
|
|
||||||
|
|
||||||
function load(): boolean {
|
function load(): boolean {
|
||||||
try {
|
try {
|
||||||
return localStorage.getItem(KEY) !== "0"; // default ON
|
return localStorage.getItem(LS.hints) !== "0"; // default ON
|
||||||
} catch {
|
} catch {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function hintsEnabled(): boolean {
|
const store = createStore<boolean>(load());
|
||||||
return enabled;
|
|
||||||
}
|
export const hintsEnabled = store.get;
|
||||||
|
export const subscribeHints = store.subscribe;
|
||||||
|
|
||||||
export function setHintsEnabled(v: boolean): void {
|
export function setHintsEnabled(v: boolean): void {
|
||||||
enabled = v;
|
store.set(v);
|
||||||
try {
|
try {
|
||||||
localStorage.setItem(KEY, v ? "1" : "0");
|
localStorage.setItem(LS.hints, v ? "1" : "0");
|
||||||
} catch {
|
} catch {
|
||||||
/* ignore */
|
/* ignore */
|
||||||
}
|
}
|
||||||
listeners.forEach((l) => l());
|
|
||||||
}
|
|
||||||
|
|
||||||
export function subscribeHints(listener: () => void): () => void {
|
|
||||||
listeners.push(listener);
|
|
||||||
return () => {
|
|
||||||
listeners = listeners.filter((l) => l !== listener);
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@
|
||||||
// the Notification Center shows (info events, actions awaiting interaction, and app
|
// the Notification Center shows (info events, actions awaiting interaction, and app
|
||||||
// errors). History is persisted to localStorage so it survives reloads; action
|
// errors). History is persisted to localStorage so it survives reloads; action
|
||||||
// callbacks are live-only and dropped on reload.
|
// callbacks are live-only and dropped on reload.
|
||||||
|
import { LS, readJSON, readMerged, writeJSON } from "./storage";
|
||||||
|
|
||||||
export type NotifLevel = "info" | "success" | "warning" | "error" | "fatal";
|
export type NotifLevel = "info" | "success" | "warning" | "error" | "fatal";
|
||||||
|
|
||||||
|
|
@ -71,8 +72,8 @@ export interface NotifyInput {
|
||||||
transient?: boolean; // live status: sticky toast, not persisted (see Notification.transient)
|
transient?: boolean; // live status: sticky toast, not persisted (see Notification.transient)
|
||||||
}
|
}
|
||||||
|
|
||||||
const HISTORY_KEY = "siftlode.notifications";
|
const HISTORY_KEY = LS.notifHistory;
|
||||||
const SETTINGS_KEY = "siftlode.notifSettings";
|
const SETTINGS_KEY = LS.notifSettings;
|
||||||
const MAX_HISTORY = 100;
|
const MAX_HISTORY = 100;
|
||||||
const ERROR_TTL = 15000;
|
const ERROR_TTL = 15000;
|
||||||
|
|
||||||
|
|
@ -86,11 +87,7 @@ const DEFAULT_SETTINGS: NotifSettings = { sound: false, durationMs: 6000 };
|
||||||
let config: NotifSettings = loadSettings();
|
let config: NotifSettings = loadSettings();
|
||||||
|
|
||||||
function loadSettings(): NotifSettings {
|
function loadSettings(): NotifSettings {
|
||||||
try {
|
return readMerged(SETTINGS_KEY, DEFAULT_SETTINGS);
|
||||||
return { ...DEFAULT_SETTINGS, ...JSON.parse(localStorage.getItem(SETTINGS_KEY) || "{}") };
|
|
||||||
} catch {
|
|
||||||
return DEFAULT_SETTINGS;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getNotifSettings(): NotifSettings {
|
export function getNotifSettings(): NotifSettings {
|
||||||
|
|
@ -99,11 +96,7 @@ export function getNotifSettings(): NotifSettings {
|
||||||
|
|
||||||
export function configureNotifications(p: Partial<NotifSettings>): void {
|
export function configureNotifications(p: Partial<NotifSettings>): void {
|
||||||
config = { ...config, ...p };
|
config = { ...config, ...p };
|
||||||
try {
|
writeJSON(SETTINGS_KEY, config);
|
||||||
localStorage.setItem(SETTINGS_KEY, JSON.stringify(config));
|
|
||||||
} catch {
|
|
||||||
/* ignore */
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let audioCtx: AudioContext | null = null;
|
let audioCtx: AudioContext | null = null;
|
||||||
|
|
@ -137,7 +130,7 @@ let cachedUnread = 0;
|
||||||
|
|
||||||
function load(): Notification[] {
|
function load(): Notification[] {
|
||||||
try {
|
try {
|
||||||
const raw = JSON.parse(localStorage.getItem(HISTORY_KEY) || "[]");
|
const raw = readJSON<unknown>(HISTORY_KEY, []);
|
||||||
if (!Array.isArray(raw)) return [];
|
if (!Array.isArray(raw)) return [];
|
||||||
// Restored entries are history only. Mark them dismissed so they don't
|
// Restored entries are history only. Mark them dismissed so they don't
|
||||||
// resurrect as active toasts on reload — their auto-dismiss timers aren't
|
// resurrect as active toasts on reload — their auto-dismiss timers aren't
|
||||||
|
|
@ -154,7 +147,7 @@ function persist() {
|
||||||
// Transient status notices are live-session only — never write them to history,
|
// Transient status notices are live-session only — never write them to history,
|
||||||
// so a reload can't leave one stranded with no producer left to clear it.
|
// so a reload can't leave one stranded with no producer left to clear it.
|
||||||
const slim = items.filter((n) => !n.transient).map(({ action: _action, ...n }) => n);
|
const slim = items.filter((n) => !n.transient).map(({ action: _action, ...n }) => n);
|
||||||
localStorage.setItem(HISTORY_KEY, JSON.stringify(slim));
|
writeJSON(HISTORY_KEY, slim);
|
||||||
} catch {
|
} catch {
|
||||||
/* ignore quota / serialization errors */
|
/* ignore quota / serialization errors */
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -12,8 +12,10 @@
|
||||||
// suppresses the auto-popup on future logins (they
|
// suppresses the auto-popup on future logins (they
|
||||||
// can still reopen it from Settings → Account).
|
// can still reopen it from Settings → Account).
|
||||||
|
|
||||||
export const ONBOARD_ACTIVE = "siftlode.onboarding.active";
|
import { LS } from "./storage";
|
||||||
export const ONBOARD_DISMISSED = "siftlode.onboarding.dismissed";
|
|
||||||
|
export const ONBOARD_ACTIVE = "siftlode.onboarding.active"; // sessionStorage (not in LS, which is localStorage)
|
||||||
|
export const ONBOARD_DISMISSED = LS.onboardingDismissed;
|
||||||
|
|
||||||
export function shouldAutoOpenOnboarding(me: {
|
export function shouldAutoOpenOnboarding(me: {
|
||||||
can_read: boolean;
|
can_read: boolean;
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,19 @@ export interface ReleaseEntry {
|
||||||
}
|
}
|
||||||
|
|
||||||
export const RELEASE_NOTES: ReleaseEntry[] = [
|
export const RELEASE_NOTES: ReleaseEntry[] = [
|
||||||
|
{
|
||||||
|
version: "0.17.0",
|
||||||
|
date: "2026-06-29",
|
||||||
|
summary: "Messaging and notification fixes, plus a large under-the-hood cleanup.",
|
||||||
|
fixes: [
|
||||||
|
"Switching between signed-in accounts no longer drops you on a blank chat: after you switch, a message you just received shows up in the right conversation instead of an empty screen.",
|
||||||
|
"You're now reliably notified when a video you'd saved or added to a playlist is removed because it's no longer available on YouTube — this notice could previously be missed.",
|
||||||
|
"“Time remaining” estimates (e.g. on sync progress) and a few small labels are now translated in Hungarian and German like the rest of the app.",
|
||||||
|
],
|
||||||
|
chores: [
|
||||||
|
"A large internal code cleanup: removed duplicated logic across the backend and frontend and factored out shared building blocks (form controls, the save/discard bar, popovers, browser-storage helpers, time/date formatters). No change to how the app behaves — groundwork that keeps future features consistent and quicker to build.",
|
||||||
|
],
|
||||||
|
},
|
||||||
{
|
{
|
||||||
version: "0.16.2",
|
version: "0.16.2",
|
||||||
date: "2026-06-26",
|
date: "2026-06-26",
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,8 @@
|
||||||
|
|
||||||
// `show`, `sort` and `content` moved to the feed toolbar (above the cards); they are no
|
// `show`, `sort` and `content` moved to the feed toolbar (above the cards); they are no
|
||||||
// longer sidebar widgets. normalizeLayout drops them from any persisted layout automatically.
|
// longer sidebar widgets. normalizeLayout drops them from any persisted layout automatically.
|
||||||
|
import { LS, readJSON, writeJSON } from "./storage";
|
||||||
|
|
||||||
export type WidgetId = "date" | "language" | "topic" | "tags";
|
export type WidgetId = "date" | "language" | "topic" | "tags";
|
||||||
|
|
||||||
export const ALL_WIDGETS: WidgetId[] = ["date", "language", "topic", "tags"];
|
export const ALL_WIDGETS: WidgetId[] = ["date", "language", "topic", "tags"];
|
||||||
|
|
@ -27,8 +29,6 @@ export const DEFAULT_LAYOUT: SidebarLayout = {
|
||||||
hidden: {},
|
hidden: {},
|
||||||
};
|
};
|
||||||
|
|
||||||
const KEY = "siftlode.sidebarLayout";
|
|
||||||
|
|
||||||
// Tolerate stale/partial data: keep only known widgets and append any that are missing
|
// Tolerate stale/partial data: keep only known widgets and append any that are missing
|
||||||
// (e.g. a widget added in a later version) so nothing silently disappears.
|
// (e.g. a widget added in a later version) so nothing silently disappears.
|
||||||
export function normalizeLayout(raw: unknown): SidebarLayout {
|
export function normalizeLayout(raw: unknown): SidebarLayout {
|
||||||
|
|
@ -45,13 +45,9 @@ export function normalizeLayout(raw: unknown): SidebarLayout {
|
||||||
}
|
}
|
||||||
|
|
||||||
export function loadLayout(): SidebarLayout {
|
export function loadLayout(): SidebarLayout {
|
||||||
try {
|
return normalizeLayout(readJSON<unknown>(LS.sidebarLayout, {}));
|
||||||
return normalizeLayout(JSON.parse(localStorage.getItem(KEY) || "{}"));
|
|
||||||
} catch {
|
|
||||||
return DEFAULT_LAYOUT;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function saveLayoutLocal(l: SidebarLayout): void {
|
export function saveLayoutLocal(l: SidebarLayout): void {
|
||||||
localStorage.setItem(KEY, JSON.stringify(l));
|
writeJSON(LS.sidebarLayout, l);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
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 Mode = "dark" | "light";
|
||||||
export type Scheme = "midnight" | "forest" | "slate" | "youtube";
|
export type Scheme = "midnight" | "forest" | "slate" | "youtube";
|
||||||
|
|
||||||
|
|
@ -27,16 +29,10 @@ export function applyTheme(t: ThemePrefs): void {
|
||||||
el.style.setProperty("--font-scale", String(t.fontScale));
|
el.style.setProperty("--font-scale", String(t.fontScale));
|
||||||
}
|
}
|
||||||
|
|
||||||
const KEY = "siftlode.theme";
|
|
||||||
|
|
||||||
export function loadLocalTheme(): ThemePrefs {
|
export function loadLocalTheme(): ThemePrefs {
|
||||||
try {
|
return readMerged(LS.theme, DEFAULT_THEME);
|
||||||
return { ...DEFAULT_THEME, ...JSON.parse(localStorage.getItem(KEY) || "{}") };
|
|
||||||
} catch {
|
|
||||||
return DEFAULT_THEME;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function saveLocalTheme(t: ThemePrefs): void {
|
export function saveLocalTheme(t: ThemePrefs): void {
|
||||||
localStorage.setItem(KEY, JSON.stringify(t));
|
writeJSON(LS.theme, t);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
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).
|
// Build info baked into the SPA at build time (Vite inlines VITE_*-prefixed env).
|
||||||
// Injected via Docker build-args (see Dockerfile); falls back to "dev" for un-stamped builds.
|
// Injected via Docker build-args (see Dockerfile); falls back to "dev" for un-stamped builds.
|
||||||
|
import { LS } from "./storage";
|
||||||
|
|
||||||
export const FRONTEND_VERSION = import.meta.env.VITE_APP_VERSION || "dev";
|
export const FRONTEND_VERSION = import.meta.env.VITE_APP_VERSION || "dev";
|
||||||
export const FRONTEND_SHA = import.meta.env.VITE_GIT_SHA || "unknown";
|
export const FRONTEND_SHA = import.meta.env.VITE_GIT_SHA || "unknown";
|
||||||
export const FRONTEND_BUILD_DATE = import.meta.env.VITE_BUILD_DATE || "";
|
export const FRONTEND_BUILD_DATE = import.meta.env.VITE_BUILD_DATE || "";
|
||||||
|
|
||||||
// Key for the "last version the user has seen release notes for" (drives the banner).
|
// Key for the "last version the user has seen release notes for" (drives the banner).
|
||||||
export const SEEN_VERSION_KEY = "siftlode.seenVersion";
|
export const SEEN_VERSION_KEY = LS.seenVersion;
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue