Merge: promote dev to prod
This commit is contained in:
commit
831dee52bc
30 changed files with 790 additions and 95 deletions
59
backend/alembic/versions/0014_demo_account.py
Normal file
59
backend/alembic/versions/0014_demo_account.py
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
"""demo account: users.is_demo + demo_whitelist
|
||||
|
||||
Revision ID: 0014_demo_account
|
||||
Revises: 0013_playlist_fingerprint
|
||||
Create Date: 2026-06-16
|
||||
|
||||
Adds the shared demo-account plumbing:
|
||||
- users.is_demo — marks the single shared demo user (no OAuth token / YouTube scope).
|
||||
- demo_whitelist — admin-curated emails that may enter the demo account from the login
|
||||
page without Google sign-in. Multiple emails are just keys to the same shared door.
|
||||
The demo user row itself is created lazily on first demo login (works across all three DBs
|
||||
without a data migration), so this only adds the schema.
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision: str = "0014_demo_account"
|
||||
down_revision: Union[str, None] = "0013_playlist_fingerprint"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column(
|
||||
"users",
|
||||
sa.Column(
|
||||
"is_demo",
|
||||
sa.Boolean(),
|
||||
nullable=False,
|
||||
server_default=sa.false(),
|
||||
),
|
||||
)
|
||||
op.create_index("ix_users_is_demo", "users", ["is_demo"])
|
||||
|
||||
op.create_table(
|
||||
"demo_whitelist",
|
||||
sa.Column("id", sa.Integer(), primary_key=True),
|
||||
sa.Column("email", sa.String(length=320), nullable=False),
|
||||
sa.Column("note", sa.Text(), nullable=True),
|
||||
sa.Column("added_by", sa.String(length=320), nullable=True),
|
||||
sa.Column(
|
||||
"created_at",
|
||||
sa.DateTime(timezone=True),
|
||||
server_default=sa.func.now(),
|
||||
nullable=False,
|
||||
),
|
||||
)
|
||||
op.create_index(
|
||||
"ix_demo_whitelist_email", "demo_whitelist", ["email"], unique=True
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index("ix_demo_whitelist_email", table_name="demo_whitelist")
|
||||
op.drop_table("demo_whitelist")
|
||||
op.drop_index("ix_users_is_demo", table_name="users")
|
||||
op.drop_column("users", "is_demo")
|
||||
|
|
@ -11,11 +11,21 @@ from sqlalchemy.orm import Session
|
|||
from app import email as email_mod
|
||||
from app.config import settings
|
||||
from app.db import get_db
|
||||
from app.models import Invite, OAuthToken, User
|
||||
from app.models import DemoWhitelist, Invite, OAuthToken, User
|
||||
from app.ratelimit import RateLimiter
|
||||
from app.security import encrypt
|
||||
|
||||
_EMAIL_RE = re.compile(r"^[^@\s]+@[^@\s]+\.[^@\s]+$")
|
||||
|
||||
# The single shared demo account's stable identity. The row is created lazily on first
|
||||
# demo login (see get_or_create_demo_user); these are just its sentinel keys.
|
||||
DEMO_GOOGLE_SUB = "__demo__"
|
||||
DEMO_EMAIL = "demo@siftlode.local"
|
||||
|
||||
# Throttle the hidden demo-login endpoint per client IP: enough for a real paste-and-wait,
|
||||
# stingy enough that the field can't be hammered as an enumeration oracle or for DoS.
|
||||
_demo_limiter = RateLimiter(max_events=5, window_seconds=60)
|
||||
|
||||
# How many recently-used accounts to keep switchable in one browser session.
|
||||
MAX_SESSION_ACCOUNTS = 6
|
||||
|
||||
|
|
@ -77,6 +87,45 @@ def is_allowed(db: Session, email: str) -> bool:
|
|||
return email in settings.allowed_email_set or email in settings.admin_email_set
|
||||
|
||||
|
||||
def is_demo_allowed(db: Session, email: str) -> bool:
|
||||
"""Whether this email may enter the shared demo account (no Google sign-in)."""
|
||||
if not email:
|
||||
return False
|
||||
return (
|
||||
db.execute(
|
||||
select(DemoWhitelist).where(DemoWhitelist.email == email.lower())
|
||||
).scalar_one_or_none()
|
||||
is not None
|
||||
)
|
||||
|
||||
|
||||
def get_or_create_demo_user(db: Session) -> User:
|
||||
"""The one shared demo user, created on first use. No OAuth token / YouTube scope, so
|
||||
has_read_scope/has_write_scope are False and every YouTube-touching path is closed to it."""
|
||||
user = db.query(User).filter(User.is_demo.is_(True)).one_or_none()
|
||||
if user is None:
|
||||
user = User(
|
||||
google_sub=DEMO_GOOGLE_SUB,
|
||||
email=DEMO_EMAIL,
|
||||
display_name="Demo",
|
||||
role="user",
|
||||
is_demo=True,
|
||||
preferences={"language": "en"},
|
||||
)
|
||||
db.add(user)
|
||||
db.commit()
|
||||
return user
|
||||
|
||||
|
||||
def _client_ip(request: Request) -> str:
|
||||
"""Best-effort client IP for rate limiting. Behind our reverse proxy (Caddy/NPM) the
|
||||
real client is the first X-Forwarded-For hop; fall back to the socket peer otherwise."""
|
||||
xff = request.headers.get("x-forwarded-for")
|
||||
if xff:
|
||||
return xff.split(",")[0].strip()
|
||||
return request.client.host if request.client else "unknown"
|
||||
|
||||
|
||||
def upsert_pending_invite(db: Session, email: str) -> Invite | None:
|
||||
"""Idempotently record an access request. Returns the Invite if a *new* pending row
|
||||
was created (so the caller can notify admins), else None for already-known emails."""
|
||||
|
|
@ -224,6 +273,24 @@ async def request_access(
|
|||
return {"status": "pending"}
|
||||
|
||||
|
||||
@router.post("/demo")
|
||||
def demo_login(payload: dict, request: Request, db: Session = Depends(get_db)) -> dict:
|
||||
"""Hidden demo entry: a whitelisted email logs straight into the shared demo account,
|
||||
no Google OAuth. The login page fires this quietly (debounced) as the user types/pastes,
|
||||
so there is no visible 'demo login' button. Rate-limited per IP; when blocked it answers
|
||||
exactly like a non-match (authenticated=false) so the field can't be hammered as an
|
||||
enumeration oracle. On a match it sets the session and the client reloads into the app."""
|
||||
if not _demo_limiter.allow(_client_ip(request)):
|
||||
return {"authenticated": False}
|
||||
email = (payload.get("email") or "").strip().lower()
|
||||
if _EMAIL_RE.match(email) and is_demo_allowed(db, email):
|
||||
demo = get_or_create_demo_user(db)
|
||||
request.session["user_id"] = demo.id
|
||||
log.info("Demo login (key=%s, demo_id=%s)", email, demo.id)
|
||||
return {"authenticated": True}
|
||||
return {"authenticated": False}
|
||||
|
||||
|
||||
def current_user(request: Request, db: Session = Depends(get_db)) -> User:
|
||||
user_id = request.session.get("user_id")
|
||||
if not user_id:
|
||||
|
|
@ -241,6 +308,16 @@ def current_user(request: Request, db: Session = Depends(get_db)) -> User:
|
|||
return user
|
||||
|
||||
|
||||
def require_human(user: User = Depends(current_user)) -> User:
|
||||
"""Reject the shared demo account from actions that need a real Google/YouTube identity
|
||||
or spend the shared quota. Most YouTube paths are already closed to it implicitly (it has
|
||||
no token, so has_read_scope/has_write_scope are False); this makes the boundary explicit
|
||||
where there's no scope check to lean on."""
|
||||
if user.is_demo:
|
||||
raise HTTPException(status_code=403, detail="Not available in the demo account.")
|
||||
return user
|
||||
|
||||
|
||||
@router.get("/upgrade")
|
||||
async def upgrade(
|
||||
request: Request, access: str = "read", user: User = Depends(current_user)
|
||||
|
|
@ -250,6 +327,10 @@ async def upgrade(
|
|||
(unsubscribe). The shared callback stores whatever Google returns, so `can_read` /
|
||||
`can_write` flip on once granted. Anything other than an explicit `write` defaults to
|
||||
the least-privileged read scope."""
|
||||
# This is the browser-facing redirect entry point, so the shared demo account is bounced
|
||||
# straight home (no YouTube link is ever attached to it) rather than shown a raw 403.
|
||||
if user.is_demo:
|
||||
return RedirectResponse(url="/")
|
||||
scope = WRITE_SCOPES if access == "write" else READ_SCOPES
|
||||
return await oauth.google.authorize_redirect(
|
||||
request,
|
||||
|
|
|
|||
|
|
@ -28,6 +28,12 @@ class User(Base):
|
|||
display_name: Mapped[str | None] = mapped_column(String(255))
|
||||
avatar_url: Mapped[str | None] = mapped_column(String(1024))
|
||||
role: Mapped[str] = mapped_column(String(16), default="user", server_default="user")
|
||||
# The single shared "demo" account: signed into without Google OAuth (via a whitelisted
|
||||
# email on the login page), has no OAuth token / YouTube scope, and its per-user state is
|
||||
# shared by everyone who enters this way. Exactly one such row is expected.
|
||||
is_demo: Mapped[bool] = mapped_column(
|
||||
Boolean, default=False, server_default="false", index=True
|
||||
)
|
||||
# Free-form UI preferences (theme, color scheme, font scale, default filters…).
|
||||
preferences: Mapped[dict | None] = mapped_column(JSON)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
|
|
@ -81,6 +87,22 @@ class Invite(Base):
|
|||
decided_by: Mapped[str | None] = mapped_column(String(320)) # admin email
|
||||
|
||||
|
||||
class DemoWhitelist(Base):
|
||||
"""Emails that may enter the shared demo account from the login page (no Google OAuth).
|
||||
A hidden, admin-curated list of "keys to the same door" — every entry logs into the one
|
||||
shared demo user. Distinct from Invite (which gates real Google sign-in)."""
|
||||
|
||||
__tablename__ = "demo_whitelist"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
email: Mapped[str] = mapped_column(String(320), unique=True, index=True)
|
||||
note: Mapped[str | None] = mapped_column(Text)
|
||||
added_by: Mapped[str | None] = mapped_column(String(320)) # admin email
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), server_default=func.now()
|
||||
)
|
||||
|
||||
|
||||
class Channel(Base):
|
||||
"""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."""
|
||||
|
|
|
|||
43
backend/app/ratelimit.py
Normal file
43
backend/app/ratelimit.py
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
"""A tiny in-process sliding-window rate limiter.
|
||||
|
||||
Generic groundwork: keyed by an arbitrary string (e.g. a client IP), so it can throttle any
|
||||
endpoint. Single-worker uvicorn → in-memory state is sufficient; it resets on restart, which
|
||||
is fine for abuse throttling (not for anything that must survive a deploy). Not shared across
|
||||
processes — if we ever run multiple workers, swap the backing store for Redis behind the same
|
||||
``allow()`` interface.
|
||||
"""
|
||||
import threading
|
||||
import time
|
||||
|
||||
|
||||
class RateLimiter:
|
||||
def __init__(self, max_events: int, window_seconds: float):
|
||||
self.max_events = max_events
|
||||
self.window = window_seconds
|
||||
self._hits: dict[str, list[float]] = {}
|
||||
self._lock = threading.Lock()
|
||||
|
||||
def allow(self, key: str) -> bool:
|
||||
"""Record an attempt for ``key`` and return whether it is within the limit.
|
||||
|
||||
True -> under the cap (the attempt is counted).
|
||||
False -> the cap for the current window is already reached (attempt NOT counted, so a
|
||||
blocked caller can't keep pushing the window forward)."""
|
||||
now = time.monotonic()
|
||||
cutoff = now - self.window
|
||||
with self._lock:
|
||||
hits = [t for t in self._hits.get(key, []) if t > cutoff]
|
||||
if len(hits) >= self.max_events:
|
||||
self._hits[key] = hits
|
||||
return False
|
||||
hits.append(now)
|
||||
self._hits[key] = hits
|
||||
# Opportunistic cleanup so abandoned keys don't accumulate unboundedly.
|
||||
if len(self._hits) > 4096:
|
||||
for k in list(self._hits):
|
||||
fresh = [t for t in self._hits[k] if t > cutoff]
|
||||
if fresh:
|
||||
self._hits[k] = fresh
|
||||
else:
|
||||
del self._hits[k]
|
||||
return True
|
||||
|
|
@ -1,17 +1,29 @@
|
|||
"""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."""
|
||||
import re
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy import delete, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app import email as email_mod
|
||||
from app.auth import current_user
|
||||
from app.auth import current_user, get_or_create_demo_user
|
||||
from app.db import get_db
|
||||
from app.models import Invite, User
|
||||
from app.models import (
|
||||
DemoWhitelist,
|
||||
Invite,
|
||||
Playlist,
|
||||
PlaylistItem,
|
||||
User,
|
||||
Video,
|
||||
VideoState,
|
||||
)
|
||||
|
||||
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":
|
||||
|
|
@ -98,3 +110,117 @@ def add_invite(
|
|||
inv.decided_by = admin.email
|
||||
db.commit()
|
||||
return _serialize(inv)
|
||||
|
||||
|
||||
# --- Demo account: email whitelist + state reset -------------------------------------
|
||||
|
||||
def _serialize_demo(row: DemoWhitelist) -> dict:
|
||||
return {
|
||||
"id": row.id,
|
||||
"email": row.email,
|
||||
"note": row.note,
|
||||
"added_by": row.added_by,
|
||||
"created_at": row.created_at.isoformat() if row.created_at else None,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/demo/whitelist")
|
||||
def list_demo_whitelist(
|
||||
_: User = Depends(admin_user), db: Session = Depends(get_db)
|
||||
) -> list[dict]:
|
||||
rows = (
|
||||
db.execute(select(DemoWhitelist).order_by(DemoWhitelist.created_at.desc()))
|
||||
.scalars()
|
||||
.all()
|
||||
)
|
||||
return [_serialize_demo(r) for r in rows]
|
||||
|
||||
|
||||
@router.post("/demo/whitelist")
|
||||
def add_demo_whitelist(
|
||||
payload: dict,
|
||||
admin: User = Depends(admin_user),
|
||||
db: Session = Depends(get_db),
|
||||
) -> dict:
|
||||
"""Add an email that may enter the shared demo account from the login page (no Google
|
||||
sign-in). Idempotent — re-adding an existing email just returns it."""
|
||||
email = (payload.get("email") or "").strip().lower()
|
||||
if not _EMAIL_RE.match(email):
|
||||
raise HTTPException(status_code=400, detail="Enter a valid email address.")
|
||||
row = db.execute(
|
||||
select(DemoWhitelist).where(DemoWhitelist.email == email)
|
||||
).scalar_one_or_none()
|
||||
if row is None:
|
||||
row = DemoWhitelist(
|
||||
email=email,
|
||||
note=(payload.get("note") or "").strip() or None,
|
||||
added_by=admin.email,
|
||||
)
|
||||
db.add(row)
|
||||
db.commit()
|
||||
return _serialize_demo(row)
|
||||
|
||||
|
||||
@router.delete("/demo/whitelist/{entry_id}")
|
||||
def remove_demo_whitelist(
|
||||
entry_id: int,
|
||||
_: User = Depends(admin_user),
|
||||
db: Session = Depends(get_db),
|
||||
) -> dict:
|
||||
row = db.get(DemoWhitelist, entry_id)
|
||||
if row is not None:
|
||||
db.delete(row)
|
||||
db.commit()
|
||||
return {"deleted": entry_id}
|
||||
|
||||
|
||||
def _seed_demo_playlists(db: Session, demo: User) -> int:
|
||||
"""Seed a few sample playlists from the shared catalog so a freshly-reset demo isn't
|
||||
empty — gives visitors something to play with. Best-effort: skips silently if the
|
||||
catalog is too small."""
|
||||
recent = (
|
||||
db.execute(
|
||||
select(Video.id)
|
||||
.where(Video.is_short.is_(False), Video.live_status == "none")
|
||||
.order_by(Video.published_at.desc().nullslast())
|
||||
.limit(60)
|
||||
)
|
||||
.scalars()
|
||||
.all()
|
||||
)
|
||||
if not recent:
|
||||
return 0
|
||||
samples = [
|
||||
("Fresh picks", recent[0:10]),
|
||||
("Quick watch", recent[10:18]),
|
||||
("Saved for later", recent[18:26]),
|
||||
]
|
||||
created = 0
|
||||
for pos, (name, vids) in enumerate(samples):
|
||||
if not vids:
|
||||
continue
|
||||
pl = Playlist(user_id=demo.id, name=name, position=pos)
|
||||
db.add(pl)
|
||||
db.flush()
|
||||
for ipos, vid in enumerate(vids):
|
||||
db.add(PlaylistItem(playlist_id=pl.id, video_id=vid, position=ipos))
|
||||
created += 1
|
||||
return created
|
||||
|
||||
|
||||
@router.post("/demo/reset")
|
||||
def reset_demo(
|
||||
_: User = Depends(admin_user), db: Session = Depends(get_db)
|
||||
) -> dict:
|
||||
"""Wipe the shared demo account's per-user state (watch/save/hide states, playlists,
|
||||
preferences) back to a clean baseline and re-seed a few sample playlists. There's no
|
||||
automatic reset — this is the admin's manual 'clean up the sandbox' button."""
|
||||
demo = get_or_create_demo_user(db)
|
||||
db.execute(delete(VideoState).where(VideoState.user_id == demo.id))
|
||||
# Playlist items cascade on playlist delete (ondelete="CASCADE").
|
||||
db.execute(delete(Playlist).where(Playlist.user_id == demo.id))
|
||||
demo.preferences = {"language": "en"}
|
||||
db.commit()
|
||||
seeded = _seed_demo_playlists(db, demo)
|
||||
db.commit()
|
||||
return {"reset": True, "playlists_seeded": seeded}
|
||||
|
|
|
|||
|
|
@ -77,6 +77,7 @@ def get_me(
|
|||
"display_name": user.display_name,
|
||||
"avatar_url": user.avatar_url,
|
||||
"role": user.role,
|
||||
"is_demo": user.is_demo,
|
||||
"can_read": has_read_scope(user),
|
||||
"can_write": has_write_scope(user),
|
||||
"pending_invites": pending_invites,
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ from sqlalchemy import and_, case, func, select, update
|
|||
from sqlalchemy.orm import Session
|
||||
|
||||
from app import quota, state
|
||||
from app.auth import current_user, has_read_scope
|
||||
from app.auth import current_user, has_read_scope, require_human
|
||||
from app.db import get_db
|
||||
from app.models import Channel, Subscription, User, Video
|
||||
from app.sync.runner import (
|
||||
|
|
@ -31,7 +31,7 @@ def _user_channels(db: Session, user: User) -> list[Channel]:
|
|||
|
||||
@router.post("/subscriptions")
|
||||
def sync_subscriptions(
|
||||
user: User = Depends(current_user), db: Session = Depends(get_db)
|
||||
user: User = Depends(require_human), db: Session = Depends(get_db)
|
||||
) -> dict:
|
||||
if not has_read_scope(user):
|
||||
# No YouTube read grant yet — the onboarding wizard hasn't been completed.
|
||||
|
|
@ -49,7 +49,7 @@ def sync_subscriptions(
|
|||
|
||||
@router.post("/rss")
|
||||
def sync_rss(
|
||||
user: User = Depends(current_user), db: Session = Depends(get_db)
|
||||
user: User = Depends(require_human), db: Session = Depends(get_db)
|
||||
) -> dict:
|
||||
new = run_rss_poll(db, _user_channels(db, user))
|
||||
return {"new_videos": new}
|
||||
|
|
@ -57,7 +57,7 @@ def sync_rss(
|
|||
|
||||
@router.post("/backfill")
|
||||
def sync_backfill(
|
||||
user: User = Depends(current_user),
|
||||
user: User = Depends(require_human),
|
||||
db: Session = Depends(get_db),
|
||||
max_channels: int = 25,
|
||||
) -> dict:
|
||||
|
|
@ -70,7 +70,7 @@ def sync_backfill(
|
|||
|
||||
@router.post("/enrich")
|
||||
def sync_enrich(
|
||||
user: User = Depends(current_user), db: Session = Depends(get_db)
|
||||
user: User = Depends(require_human), db: Session = Depends(get_db)
|
||||
) -> dict:
|
||||
before = quota.units_used_today(db)
|
||||
with quota.attribute(user.id, "enrich"):
|
||||
|
|
@ -175,7 +175,7 @@ def my_status(
|
|||
|
||||
@router.post("/deep-all")
|
||||
def deep_all(
|
||||
user: User = Depends(current_user),
|
||||
user: User = Depends(require_human),
|
||||
db: Session = Depends(get_db),
|
||||
on: bool = True,
|
||||
) -> dict:
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ from app.sync.videos import (
|
|||
enrich_pending,
|
||||
poll_rss_channel,
|
||||
reconcile_full_history,
|
||||
refresh_live,
|
||||
run_shorts_classification,
|
||||
)
|
||||
from app.youtube.client import YouTubeClient
|
||||
|
|
@ -65,6 +66,10 @@ def run_enrich(db: Session, max_batches: int = 200, floor: int = 200) -> int:
|
|||
total += n
|
||||
if n == 0:
|
||||
break
|
||||
# Revisit transient live/upcoming videos (enrich_pending is one-shot) so an ended
|
||||
# stream gains its final duration + was_live status instead of staying "live" forever.
|
||||
if quota.remaining_today(db) > floor:
|
||||
total += refresh_live(db, yt)
|
||||
return total
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ import re
|
|||
from concurrent.futures import ThreadPoolExecutor
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from sqlalchemy import func, or_, select, update
|
||||
from sqlalchemy import and_, func, or_, select, update
|
||||
from sqlalchemy.dialects.postgresql import insert as pg_insert
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
|
|
@ -260,6 +260,52 @@ def enrich_pending(db: Session, yt: YouTubeClient, limit: int | None = None) ->
|
|||
return enriched
|
||||
|
||||
|
||||
def refresh_live(db: Session, yt: YouTubeClient, limit: int | None = None) -> int:
|
||||
"""Re-fetch videos in a transient live state so they pick up their final shape.
|
||||
|
||||
`enrich_pending` is one-shot (it only touches enriched_at IS NULL), but a live broadcast
|
||||
is not a final state: a stream ends and becomes a normal VOD — gaining a real duration
|
||||
and flipping live_status to was_live — and an upcoming premiere eventually goes live then
|
||||
ends. So we revisit anything still live/upcoming, plus a just-ended stream that hasn't got
|
||||
its duration yet (YouTube can lag a bit after the broadcast), until it settles. Cheap:
|
||||
videos.list is 1 unit per 50 ids, and the live/upcoming set is normally tiny."""
|
||||
limit = limit or settings.enrich_batch_size
|
||||
videos = (
|
||||
db.execute(
|
||||
select(Video)
|
||||
.where(
|
||||
or_(
|
||||
Video.live_status.in_(("live", "upcoming")),
|
||||
and_(
|
||||
Video.live_status == "was_live",
|
||||
Video.duration_seconds.is_(None),
|
||||
),
|
||||
)
|
||||
)
|
||||
.limit(limit)
|
||||
)
|
||||
.scalars()
|
||||
.all()
|
||||
)
|
||||
if not videos:
|
||||
return 0
|
||||
items = {it["id"]: it for it in yt.get_videos([v.id for v in videos])}
|
||||
now = _now()
|
||||
updated = 0
|
||||
for video in videos:
|
||||
item = items.get(video.id)
|
||||
if item is None:
|
||||
# The broadcast vanished (deleted/private). Stop treating it as live so it
|
||||
# doesn't get refreshed forever; it just becomes an ordinary (dead) row.
|
||||
video.live_status = "none"
|
||||
continue
|
||||
apply_video_details(video, item)
|
||||
video.enriched_at = now
|
||||
updated += 1
|
||||
db.commit()
|
||||
return updated
|
||||
|
||||
|
||||
def run_shorts_classification(db: Session, limit: int | None = None) -> dict:
|
||||
"""Finalize Shorts classification: cheaply mark non-candidates, then probe the
|
||||
youtube.com/shorts URL for short-enough, non-live, enriched videos."""
|
||||
|
|
|
|||
|
|
@ -34,6 +34,7 @@ import Toaster from "./components/Toaster";
|
|||
import About from "./components/About";
|
||||
import ReleaseNotes from "./components/ReleaseNotes";
|
||||
import VersionBanner from "./components/VersionBanner";
|
||||
import DemoBanner from "./components/DemoBanner";
|
||||
import { CURRENT_VERSION } from "./lib/releaseNotes";
|
||||
|
||||
const DEFAULT_FILTERS: FeedFilters = {
|
||||
|
|
@ -189,6 +190,12 @@ export default function App() {
|
|||
message: t("common.accessRequestsMessage", { count: pending }),
|
||||
});
|
||||
}
|
||||
// The demo account is shared: there are no subscriptions, so default it to the whole
|
||||
// library (the "my" feed would be empty). The communal-state warning is a permanent
|
||||
// banner (see DemoBanner below), not a toast that re-pops on every reload.
|
||||
if (meQuery.data?.is_demo) {
|
||||
setFilters({ ...loadStoredFilters(), scope: "all" });
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [meQuery.data?.id]);
|
||||
|
||||
|
|
@ -239,6 +246,7 @@ export default function App() {
|
|||
setPage("channels");
|
||||
}}
|
||||
/>
|
||||
{meQuery.data!.is_demo && <DemoBanner />}
|
||||
<VersionBanner onOpen={() => openReleaseNotes(CURRENT_VERSION)} />
|
||||
<div className="flex flex-1 min-h-0">
|
||||
{page === "feed" && (
|
||||
|
|
@ -280,13 +288,14 @@ export default function App() {
|
|||
setFilters={setFilters}
|
||||
view={view}
|
||||
canRead={meQuery.data!.can_read}
|
||||
isDemo={meQuery.data!.is_demo}
|
||||
onOpenWizard={() => setWizardOpen(true)}
|
||||
/>
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
{wizardOpen && meQuery.data && (
|
||||
{wizardOpen && meQuery.data && !meQuery.data.is_demo && (
|
||||
<OnboardingWizard me={meQuery.data} onClose={() => setWizardOpen(false)} />
|
||||
)}
|
||||
{aboutOpen && (
|
||||
|
|
|
|||
17
frontend/src/components/DemoBanner.tsx
Normal file
17
frontend/src/components/DemoBanner.tsx
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
import { useTranslation } from "react-i18next";
|
||||
import { AlertTriangle } from "lucide-react";
|
||||
|
||||
// Permanent, non-dismissible bar shown to the shared demo account so it's always clear the
|
||||
// state is communal. (Replaces the old login-time toast, which re-popped on every reload.)
|
||||
export default function DemoBanner() {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className="shrink-0 flex items-center gap-2 px-4 py-2 text-sm bg-amber-500/15 border-b border-amber-500/30 text-fg">
|
||||
<AlertTriangle className="w-4 h-4 text-amber-500 shrink-0" />
|
||||
<span className="min-w-0">
|
||||
<span className="font-semibold">{t("common.demoTitle")}</span>
|
||||
<span className="text-muted"> — {t("common.demoSharedNotice")}</span>
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -68,12 +68,14 @@ export default function Feed({
|
|||
setFilters,
|
||||
view,
|
||||
canRead,
|
||||
isDemo = false,
|
||||
onOpenWizard,
|
||||
}: {
|
||||
filters: FeedFilters;
|
||||
setFilters: (f: FeedFilters) => void;
|
||||
view: "grid" | "list";
|
||||
canRead: boolean;
|
||||
isDemo?: boolean;
|
||||
onOpenWizard: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
|
|
@ -204,26 +206,42 @@ export default function Feed({
|
|||
|
||||
if (query.isLoading) return <div className="p-8 text-muted">{t("feed.loading")}</div>;
|
||||
if (query.isError) return <div className="p-8 text-muted">{t("feed.loadError")}</div>;
|
||||
// Onboarding empty state (no YouTube + own subscriptions): a dedicated full-page prompt,
|
||||
// with no toolbar (content-type/sort would be meaningless here).
|
||||
// Empty "my" feed with no YouTube access. The shared demo account can never connect
|
||||
// YouTube, so it gets a gentle nudge into the shared library instead of the connect
|
||||
// wizard; real users get the onboarding prompt. No toolbar (sort/type are meaningless).
|
||||
if (items.length === 0 && !canRead && filters.scope === "my")
|
||||
return (
|
||||
<div className="p-8 grid place-items-center text-center">
|
||||
<div className="max-w-sm">
|
||||
<h2 className="text-lg font-semibold">{t("feed.emptyTitle")}</h2>
|
||||
<p className="text-sm text-muted mt-2 mb-4">{t("feed.emptyBody")}</p>
|
||||
<button
|
||||
onClick={onOpenWizard}
|
||||
className="inline-flex items-center justify-center gap-2 px-5 py-2.5 rounded-xl font-semibold bg-accent text-accent-fg hover:opacity-90 transition"
|
||||
>
|
||||
{t("feed.setUp")}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setFilters({ ...filters, scope: "all" })}
|
||||
className="block mx-auto mt-3 text-sm text-muted hover:text-accent transition"
|
||||
>
|
||||
{t("feed.browseShared")}
|
||||
</button>
|
||||
<h2 className="text-lg font-semibold">
|
||||
{isDemo ? t("feed.demoEmptyTitle") : t("feed.emptyTitle")}
|
||||
</h2>
|
||||
<p className="text-sm text-muted mt-2 mb-4">
|
||||
{isDemo ? t("feed.demoEmptyBody") : t("feed.emptyBody")}
|
||||
</p>
|
||||
{isDemo ? (
|
||||
<button
|
||||
onClick={() => setFilters({ ...filters, scope: "all" })}
|
||||
className="inline-flex items-center justify-center gap-2 px-5 py-2.5 rounded-xl font-semibold bg-accent text-accent-fg hover:opacity-90 transition"
|
||||
>
|
||||
{t("feed.browseLibrary")}
|
||||
</button>
|
||||
) : (
|
||||
<>
|
||||
<button
|
||||
onClick={onOpenWizard}
|
||||
className="inline-flex items-center justify-center gap-2 px-5 py-2.5 rounded-xl font-semibold bg-accent text-accent-fg hover:opacity-90 transition"
|
||||
>
|
||||
{t("feed.setUp")}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setFilters({ ...filters, scope: "all" })}
|
||||
className="block mx-auto mt-3 text-sm text-muted hover:text-accent transition"
|
||||
>
|
||||
{t("feed.browseShared")}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { useState } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { api } from "../lib/api";
|
||||
import { setLanguage, type LangCode } from "../i18n";
|
||||
|
|
@ -6,6 +6,8 @@ import LanguageSwitcher from "./LanguageSwitcher";
|
|||
|
||||
type Phase = "idle" | "sending" | "requested" | "approved" | "error";
|
||||
|
||||
const EMAIL_RE = /^[^@\s]+@[^@\s]+\.[^@\s]+$/;
|
||||
|
||||
export default function Login() {
|
||||
const { t, i18n } = useTranslation();
|
||||
|
||||
|
|
@ -18,6 +20,28 @@ export default function Login() {
|
|||
const [phase, setPhase] = useState<Phase>(bounced === "requested" ? "requested" : "idle");
|
||||
const [error, setError] = useState("");
|
||||
|
||||
// Hidden demo entry: a whitelisted email signs straight into the shared demo account, with
|
||||
// no button to give it away. We watch the field and — once it holds a syntactically valid
|
||||
// email and typing has settled — quietly probe /auth/demo. A match sets the session, so we
|
||||
// reload into the app; a non-match does nothing visible (the normal "request access" flow
|
||||
// stays available). The server is rate-limited and answers uniformly, so this can't be
|
||||
// hammered as an oracle. Skipped while the request-access form is mid-submit/done.
|
||||
useEffect(() => {
|
||||
const candidate = email.trim().toLowerCase();
|
||||
const settled = phase === "requested" || phase === "approved";
|
||||
if (phase === "sending" || settled || !EMAIL_RE.test(candidate)) return;
|
||||
const timer = setTimeout(() => {
|
||||
api
|
||||
.demoLogin(candidate)
|
||||
.then((r) => {
|
||||
if (r.authenticated) window.location.reload();
|
||||
})
|
||||
.catch(() => {});
|
||||
}, 700);
|
||||
return () => clearTimeout(timer);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [email, phase]);
|
||||
|
||||
async function submit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setError("");
|
||||
|
|
|
|||
|
|
@ -110,6 +110,7 @@ function Row({
|
|||
onPlay: () => void;
|
||||
onRemove: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const { attributes, listeners, setNodeRef, transform, transition, isDragging } =
|
||||
useSortable({ id: video.id, disabled: readOnly });
|
||||
const style = {
|
||||
|
|
@ -151,11 +152,21 @@ function Row({
|
|||
<div className="text-[13px] text-fg truncate">{video.title}</div>
|
||||
<div className="text-[11px] text-muted truncate">{video.channel_title}</div>
|
||||
</button>
|
||||
{video.duration_seconds != null && (
|
||||
{video.duration_seconds != null ? (
|
||||
<span className="text-[11px] text-muted tabular-nums">
|
||||
{formatDuration(video.duration_seconds)}
|
||||
</span>
|
||||
)}
|
||||
) : video.live_status === "live" || video.live_status === "upcoming" ? (
|
||||
<span
|
||||
className={`text-[10px] font-semibold uppercase tracking-wide px-1.5 py-0.5 rounded ${
|
||||
video.live_status === "live"
|
||||
? "bg-red-500/90 text-white"
|
||||
: "bg-card border border-border text-muted"
|
||||
}`}
|
||||
>
|
||||
{t(`card.${video.live_status}`)}
|
||||
</span>
|
||||
) : null}
|
||||
{!readOnly && (
|
||||
<button
|
||||
onClick={onRemove}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { useCallback, useEffect, useState, useSyncExternalStore } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { Bell, Check, History, Monitor, Pause, Play, RefreshCw, User, UserPlus, X } from "lucide-react";
|
||||
import { Bell, Check, History, Monitor, Pause, Play, RefreshCw, RotateCcw, Trash2, User, UserPlus, X } from "lucide-react";
|
||||
import { SCHEMES, type Scheme, type ThemePrefs } from "../lib/theme";
|
||||
import { api, type Invite, type Me } from "../lib/api";
|
||||
import { formatEta, quotaActionLabel } from "../lib/format";
|
||||
|
|
@ -14,6 +14,7 @@ import {
|
|||
} from "../lib/notifications";
|
||||
import { hintsEnabled, setHintsEnabled, subscribeHints } from "../lib/hints";
|
||||
import Tooltip from "./Tooltip";
|
||||
import { useConfirm } from "./ConfirmProvider";
|
||||
|
||||
type TabId = "appearance" | "notifications" | "sync" | "account";
|
||||
const TABS: { id: TabId; icon: typeof Monitor }[] = [
|
||||
|
|
@ -397,28 +398,30 @@ function Sync({ me }: { me: Me }) {
|
|||
)}
|
||||
</Section>
|
||||
|
||||
<Section title={t("settings.sync.actions")}>
|
||||
<Tooltip hint={t("settings.sync.syncSubscriptionsHint")}>
|
||||
<button
|
||||
onClick={() => syncSubs.mutate()}
|
||||
disabled={syncSubs.isPending}
|
||||
className="glass-card glass-hover flex items-center gap-2 px-3 py-2 rounded-xl text-sm disabled:opacity-50 transition"
|
||||
>
|
||||
<RefreshCw className={`w-4 h-4 ${syncSubs.isPending ? "animate-spin" : ""}`} />
|
||||
{t("settings.sync.syncSubscriptions")}
|
||||
</button>
|
||||
</Tooltip>
|
||||
<Tooltip hint={t("settings.sync.backfillEverythingHint")}>
|
||||
<button
|
||||
onClick={() => deepAll.mutate()}
|
||||
disabled={deepAll.isPending}
|
||||
className="glass-card glass-hover flex items-center gap-2 px-3 py-2 rounded-xl text-sm disabled:opacity-50 transition mt-2"
|
||||
>
|
||||
<History className={`w-4 h-4 ${deepAll.isPending ? "animate-pulse" : ""}`} />
|
||||
{t("settings.sync.backfillEverything")}
|
||||
</button>
|
||||
</Tooltip>
|
||||
</Section>
|
||||
{!me.is_demo && (
|
||||
<Section title={t("settings.sync.actions")}>
|
||||
<Tooltip hint={t("settings.sync.syncSubscriptionsHint")}>
|
||||
<button
|
||||
onClick={() => syncSubs.mutate()}
|
||||
disabled={syncSubs.isPending}
|
||||
className="glass-card glass-hover flex items-center gap-2 px-3 py-2 rounded-xl text-sm disabled:opacity-50 transition"
|
||||
>
|
||||
<RefreshCw className={`w-4 h-4 ${syncSubs.isPending ? "animate-spin" : ""}`} />
|
||||
{t("settings.sync.syncSubscriptions")}
|
||||
</button>
|
||||
</Tooltip>
|
||||
<Tooltip hint={t("settings.sync.backfillEverythingHint")}>
|
||||
<button
|
||||
onClick={() => deepAll.mutate()}
|
||||
disabled={deepAll.isPending}
|
||||
className="glass-card glass-hover flex items-center gap-2 px-3 py-2 rounded-xl text-sm disabled:opacity-50 transition mt-2"
|
||||
>
|
||||
<History className={`w-4 h-4 ${deepAll.isPending ? "animate-pulse" : ""}`} />
|
||||
{t("settings.sync.backfillEverything")}
|
||||
</button>
|
||||
</Tooltip>
|
||||
</Section>
|
||||
)}
|
||||
|
||||
{me.role === "admin" && s && (
|
||||
<Section title={t("settings.sync.admin")}>
|
||||
|
|
@ -498,38 +501,47 @@ function Account({ me, onOpenWizard }: { me: Me; onOpenWizard: () => void }) {
|
|||
</div>
|
||||
</Section>
|
||||
|
||||
<Section title={t("settings.account.youtubeAccess")}>
|
||||
<p className="text-xs text-muted leading-relaxed mb-1">
|
||||
{t("settings.account.youtubeAccessIntro")}
|
||||
</p>
|
||||
<div className="divide-y divide-border">
|
||||
<AccessRow
|
||||
title={t("settings.account.readTitle")}
|
||||
granted={me.can_read}
|
||||
grantedHint={t("settings.account.readGrantedHint")}
|
||||
enableHint={t("settings.account.readEnableHint")}
|
||||
onEnable={() => {
|
||||
window.location.href = "/auth/upgrade?access=read";
|
||||
}}
|
||||
/>
|
||||
<AccessRow
|
||||
title={t("settings.account.writeTitle")}
|
||||
granted={me.can_write}
|
||||
grantedHint={t("settings.account.writeGrantedHint")}
|
||||
enableHint={t("settings.account.writeEnableHint")}
|
||||
onEnable={() => {
|
||||
window.location.href = "/auth/upgrade?access=write";
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
onClick={onOpenWizard}
|
||||
className="mt-2 text-sm text-accent hover:underline"
|
||||
>
|
||||
{t("settings.account.walkMeThrough")}
|
||||
</button>
|
||||
</Section>
|
||||
{me.is_demo ? (
|
||||
<Section title={t("settings.account.youtubeAccess")}>
|
||||
<p className="text-xs text-muted leading-relaxed">
|
||||
{t("settings.account.demoNotice")}
|
||||
</p>
|
||||
</Section>
|
||||
) : (
|
||||
<Section title={t("settings.account.youtubeAccess")}>
|
||||
<p className="text-xs text-muted leading-relaxed mb-1">
|
||||
{t("settings.account.youtubeAccessIntro")}
|
||||
</p>
|
||||
<div className="divide-y divide-border">
|
||||
<AccessRow
|
||||
title={t("settings.account.readTitle")}
|
||||
granted={me.can_read}
|
||||
grantedHint={t("settings.account.readGrantedHint")}
|
||||
enableHint={t("settings.account.readEnableHint")}
|
||||
onEnable={() => {
|
||||
window.location.href = "/auth/upgrade?access=read";
|
||||
}}
|
||||
/>
|
||||
<AccessRow
|
||||
title={t("settings.account.writeTitle")}
|
||||
granted={me.can_write}
|
||||
grantedHint={t("settings.account.writeGrantedHint")}
|
||||
enableHint={t("settings.account.writeEnableHint")}
|
||||
onEnable={() => {
|
||||
window.location.href = "/auth/upgrade?access=write";
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
onClick={onOpenWizard}
|
||||
className="mt-2 text-sm text-accent hover:underline"
|
||||
>
|
||||
{t("settings.account.walkMeThrough")}
|
||||
</button>
|
||||
</Section>
|
||||
)}
|
||||
{me.role === "admin" && <AdminInvites />}
|
||||
{me.role === "admin" && <AdminDemo />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -631,6 +643,118 @@ function AdminInvites() {
|
|||
);
|
||||
}
|
||||
|
||||
function AdminDemo() {
|
||||
const { t } = useTranslation();
|
||||
const qc = useQueryClient();
|
||||
const confirm = useConfirm();
|
||||
const list = useQuery({ queryKey: ["demo-whitelist"], queryFn: () => api.adminDemoWhitelist() });
|
||||
const [newEmail, setNewEmail] = useState("");
|
||||
|
||||
const add = useMutation({
|
||||
mutationFn: (email: string) => api.addDemoWhitelist(email),
|
||||
onSuccess: () => {
|
||||
setNewEmail("");
|
||||
qc.invalidateQueries({ queryKey: ["demo-whitelist"] });
|
||||
notify({ level: "success", message: t("settings.demo.added") });
|
||||
},
|
||||
onError: () => notify({ level: "error", message: t("settings.demo.addFailed") }),
|
||||
});
|
||||
const remove = useMutation({
|
||||
mutationFn: (id: number) => api.removeDemoWhitelist(id),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ["demo-whitelist"] }),
|
||||
onError: () => notify({ level: "error", message: t("settings.demo.removeFailed") }),
|
||||
});
|
||||
const reset = useMutation({
|
||||
mutationFn: () => api.resetDemo(),
|
||||
onSuccess: (r: { playlists_seeded: number }) =>
|
||||
notify({
|
||||
level: "success",
|
||||
message: t("settings.demo.resetDone", { count: r.playlists_seeded }),
|
||||
}),
|
||||
onError: () => notify({ level: "error", message: t("settings.demo.resetFailed") }),
|
||||
});
|
||||
|
||||
const rows = list.data ?? [];
|
||||
|
||||
return (
|
||||
<Section title={t("settings.demo.title")}>
|
||||
<p className="text-xs text-muted leading-relaxed mb-2">{t("settings.demo.intro")}</p>
|
||||
|
||||
{rows.length > 0 && (
|
||||
<div className="space-y-1.5 mb-3">
|
||||
{rows.map((r) => (
|
||||
<div key={r.id} className="glass-card flex items-center gap-2 p-2.5 rounded-xl">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-sm truncate">{r.email}</div>
|
||||
{r.added_by && (
|
||||
<div className="text-[11px] text-muted truncate">
|
||||
{t("settings.demo.addedBy", { who: r.added_by })}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<Tooltip hint={t("settings.demo.removeHint")}>
|
||||
<button
|
||||
onClick={() => remove.mutate(r.id)}
|
||||
disabled={remove.isPending}
|
||||
className="shrink-0 p-1.5 rounded-lg border border-border text-muted hover:text-red-400 disabled:opacity-50 transition"
|
||||
aria-label={t("settings.demo.remove")}
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
if (newEmail.trim()) add.mutate(newEmail.trim());
|
||||
}}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<input
|
||||
type="email"
|
||||
value={newEmail}
|
||||
onChange={(e) => setNewEmail(e.target.value)}
|
||||
placeholder={t("settings.demo.addPlaceholder")}
|
||||
className="flex-1 min-w-0 bg-card border border-border rounded-xl px-3 py-2 text-sm outline-none focus:border-accent"
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
className="shrink-0 glass-card glass-hover flex items-center gap-1.5 px-3 py-2 rounded-xl text-sm transition"
|
||||
>
|
||||
<UserPlus className="w-4 h-4" /> {t("settings.demo.add")}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div className="mt-4 pt-3 border-t border-border">
|
||||
<Tooltip hint={t("settings.demo.resetHint")}>
|
||||
<button
|
||||
onClick={async () => {
|
||||
if (
|
||||
await confirm({
|
||||
title: t("settings.demo.resetTitle"),
|
||||
message: t("settings.demo.resetConfirm"),
|
||||
confirmLabel: t("settings.demo.reset"),
|
||||
danger: true,
|
||||
})
|
||||
)
|
||||
reset.mutate();
|
||||
}}
|
||||
disabled={reset.isPending}
|
||||
className="glass-card glass-hover flex items-center gap-2 px-3 py-2 rounded-xl text-sm disabled:opacity-50 transition"
|
||||
>
|
||||
<RotateCcw className={`w-4 h-4 ${reset.isPending ? "animate-spin" : ""}`} />
|
||||
{t("settings.demo.reset")}
|
||||
</button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
|
||||
function InviteRow({
|
||||
inv,
|
||||
onApprove,
|
||||
|
|
|
|||
|
|
@ -154,11 +154,19 @@ function Thumb({
|
|||
) : (
|
||||
<div className="w-full h-full" />
|
||||
)}
|
||||
{video.duration_seconds != null && (
|
||||
{video.duration_seconds != null ? (
|
||||
<span className="absolute bottom-1.5 right-1.5 bg-black/80 text-white text-[11px] font-medium px-1.5 py-0.5 rounded">
|
||||
{formatDuration(video.duration_seconds)}
|
||||
</span>
|
||||
)}
|
||||
) : video.live_status === "live" || video.live_status === "upcoming" ? (
|
||||
<span
|
||||
className={`absolute bottom-1.5 right-1.5 text-[10px] font-semibold uppercase tracking-wide px-1.5 py-0.5 rounded ${
|
||||
video.live_status === "live" ? "bg-red-500/90 text-white" : "bg-black/80 text-white"
|
||||
}`}
|
||||
>
|
||||
{t(`card.${video.live_status}`)}
|
||||
</span>
|
||||
) : null}
|
||||
{video.live_status === "was_live" && (
|
||||
<span className="absolute top-1.5 left-1.5 bg-accent text-accent-fg text-[10px] font-semibold uppercase tracking-wide px-1.5 py-0.5 rounded">
|
||||
{t("card.stream")}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,6 @@
|
|||
{
|
||||
"live": "live",
|
||||
"upcoming": "demnächst",
|
||||
"watchedUnmark": "Angesehen — zum Aufheben klicken",
|
||||
"markWatched": "Als angesehen markieren",
|
||||
"savedRemove": "Gespeichert — zum Entfernen klicken",
|
||||
|
|
|
|||
|
|
@ -12,5 +12,7 @@
|
|||
"language": "Sprache",
|
||||
"somethingWrong": "Etwas ist schiefgelaufen.",
|
||||
"accessRequestsTitle": "Zugangsanfragen",
|
||||
"accessRequestsMessage": "{{count}} ausstehend — prüfe sie unter Einstellungen → Konto."
|
||||
"accessRequestsMessage": "{{count}} ausstehend — prüfe sie unter Einstellungen → Konto.",
|
||||
"demoTitle": "Demo-Konto",
|
||||
"demoSharedNotice": "Du bist im gemeinsamen Demo-Konto. Alles, was du hier tust — Playlists, ausgeblendete Videos, Einstellungen — ist gemeinsam und kann von anderen Demo-Besuchern gleichzeitig geändert werden."
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,6 +5,9 @@
|
|||
"emptyBody": "Verbinde dein YouTube-Konto, um deine Abos zu importieren und deinen Feed aufzubauen.",
|
||||
"setUp": "Meinen Feed einrichten",
|
||||
"browseShared": "Oder einfach die gemeinsame Bibliothek durchsuchen",
|
||||
"demoEmptyTitle": "Hier in der Demo ist nichts",
|
||||
"demoEmptyBody": "Das Demo-Konto hat keine eigenen Abos — aber die gesamte gemeinsame Bibliothek steht dir offen: durchsuchen, filtern und sortieren.",
|
||||
"browseLibrary": "Bibliothek durchsuchen",
|
||||
"noMatches": "Keine Videos entsprechen diesen Filtern.",
|
||||
"videoCount_one": "{{formattedCount}} Video",
|
||||
"videoCount_other": "{{formattedCount}} Videos",
|
||||
|
|
|
|||
|
|
@ -79,7 +79,26 @@
|
|||
"granted": "Erteilt",
|
||||
"enable": "Aktivieren",
|
||||
"enableHint": "Leitet zu Google weiter, um die Berechtigung zu erteilen. Google zeigt einen Hinweis „nicht verifizierte App“ — das ist zu erwarten; klicke auf Erweitert → Weiter zu Siftlode.",
|
||||
"walkMeThrough": "Führe mich durch die Einrichtung"
|
||||
"walkMeThrough": "Führe mich durch die Einrichtung",
|
||||
"demoNotice": "Dies ist das gemeinsame Demo-Konto. Es hat keine YouTube-Verbindung — du kannst die gesamte gemeinsame Bibliothek durchsuchen, filtern und sortieren, Playlists erstellen und Dinge ausprobieren, aber nichts berührt ein echtes YouTube-Konto."
|
||||
},
|
||||
"demo": {
|
||||
"title": "Demo-Zugang",
|
||||
"intro": "E-Mails hier können das gemeinsame Demo-Konto direkt von der Anmeldeseite aus betreten — ohne Google-Anmeldung. Sie tippen die Adresse einfach ins Feld und werden nach einem Moment eingelassen.",
|
||||
"addPlaceholder": "E-Mail für die Demo auf die Whitelist…",
|
||||
"add": "Hinzufügen",
|
||||
"added": "Zur Demo-Whitelist hinzugefügt",
|
||||
"addFailed": "Diese E-Mail konnte nicht hinzugefügt werden",
|
||||
"removeFailed": "Diese E-Mail konnte nicht entfernt werden",
|
||||
"remove": "Entfernen",
|
||||
"removeHint": "Diese E-Mail von der Demo-Whitelist entfernen.",
|
||||
"addedBy": "hinzugefügt von {{who}}",
|
||||
"reset": "Demo-Zustand zurücksetzen",
|
||||
"resetHint": "Löscht die Playlists, ausgeblendeten/angesehenen/gespeicherten Videos und Einstellungen des Demo-Kontos und legt dann ein paar Beispiel-Playlists neu an.",
|
||||
"resetTitle": "Demo-Zustand zurücksetzen?",
|
||||
"resetConfirm": "Dies löscht alles, was das Demo-Konto angesammelt hat — Playlists, ausgeblendete/angesehene/gespeicherte Videos und Einstellungen — und legt ein paar Beispiel-Playlists neu an. Es kann nicht rückgängig gemacht werden.",
|
||||
"resetDone": "Demo zurückgesetzt — {{count}} Beispiel-Playlist(s) angelegt",
|
||||
"resetFailed": "Das Demo-Konto konnte nicht zurückgesetzt werden"
|
||||
},
|
||||
"invites": {
|
||||
"title": "Zugriffsanfragen",
|
||||
|
|
|
|||
|
|
@ -1,4 +1,6 @@
|
|||
{
|
||||
"live": "live",
|
||||
"upcoming": "upcoming",
|
||||
"watchedUnmark": "Watched — click to unmark",
|
||||
"markWatched": "Mark watched",
|
||||
"savedRemove": "Saved — click to remove",
|
||||
|
|
|
|||
|
|
@ -12,5 +12,7 @@
|
|||
"language": "Language",
|
||||
"somethingWrong": "Something went wrong.",
|
||||
"accessRequestsTitle": "Access requests",
|
||||
"accessRequestsMessage": "{{count}} pending — review in Settings → Account."
|
||||
"accessRequestsMessage": "{{count}} pending — review in Settings → Account.",
|
||||
"demoTitle": "Demo account",
|
||||
"demoSharedNotice": "You're in the shared demo account. Everything you do here — playlists, hidden videos, settings — is communal and may be changed by other demo visitors at the same time."
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,6 +5,9 @@
|
|||
"emptyBody": "Connect your YouTube account to import your subscriptions and build your feed.",
|
||||
"setUp": "Set up my feed",
|
||||
"browseShared": "Or just browse the shared library",
|
||||
"demoEmptyTitle": "Nothing here in the demo",
|
||||
"demoEmptyBody": "The demo account has no subscriptions of its own — but the whole shared library is yours to explore, filter and sort.",
|
||||
"browseLibrary": "Browse the library",
|
||||
"noMatches": "No videos match these filters.",
|
||||
"videoCount_one": "{{formattedCount}} video",
|
||||
"videoCount_other": "{{formattedCount}} videos",
|
||||
|
|
|
|||
|
|
@ -79,7 +79,26 @@
|
|||
"granted": "Granted",
|
||||
"enable": "Enable",
|
||||
"enableHint": "Redirects to Google to grant the permission. Google shows an 'unverified app' notice — that's expected; click Advanced → Go to Siftlode.",
|
||||
"walkMeThrough": "Walk me through setup"
|
||||
"walkMeThrough": "Walk me through setup",
|
||||
"demoNotice": "This is the shared demo account. It has no YouTube connection — you can browse the whole shared library, filter and sort it, build playlists and try things out, but nothing touches a real YouTube account."
|
||||
},
|
||||
"demo": {
|
||||
"title": "Demo access",
|
||||
"intro": "Emails here can enter the shared demo account straight from the login page — no Google sign-in. They just type the address into the field and are let in after a moment.",
|
||||
"addPlaceholder": "Whitelist an email for demo…",
|
||||
"add": "Add",
|
||||
"added": "Added to the demo whitelist",
|
||||
"addFailed": "Couldn't add that email",
|
||||
"removeFailed": "Couldn't remove that email",
|
||||
"remove": "Remove",
|
||||
"removeHint": "Remove this email from the demo whitelist.",
|
||||
"addedBy": "added by {{who}}",
|
||||
"reset": "Reset demo state",
|
||||
"resetHint": "Wipe the demo account's playlists, hidden/watched/saved videos and settings, then re-seed a few sample playlists.",
|
||||
"resetTitle": "Reset demo state?",
|
||||
"resetConfirm": "This clears everything the demo account has accumulated — playlists, hidden/watched/saved videos and settings — and re-seeds a few sample playlists. It can't be undone.",
|
||||
"resetDone": "Demo reset — {{count}} sample playlist(s) seeded",
|
||||
"resetFailed": "Couldn't reset the demo account"
|
||||
},
|
||||
"invites": {
|
||||
"title": "Access requests",
|
||||
|
|
|
|||
|
|
@ -1,4 +1,6 @@
|
|||
{
|
||||
"live": "élő",
|
||||
"upcoming": "hamarosan",
|
||||
"watchedUnmark": "Megnézve — kattints a visszavonáshoz",
|
||||
"markWatched": "Megnézettnek jelöl",
|
||||
"savedRemove": "Mentve — kattints az eltávolításhoz",
|
||||
|
|
|
|||
|
|
@ -12,5 +12,7 @@
|
|||
"language": "Nyelv",
|
||||
"somethingWrong": "Hiba történt.",
|
||||
"accessRequestsTitle": "Hozzáférési kérések",
|
||||
"accessRequestsMessage": "{{count}} függőben — nézd át a Beállítások → Fiók menüben."
|
||||
"accessRequestsMessage": "{{count}} függőben — nézd át a Beállítások → Fiók menüben.",
|
||||
"demoTitle": "Demo fiók",
|
||||
"demoSharedNotice": "Egy közös demo fiókban vagy. Minden, amit itt csinálsz — lejátszási listák, elrejtett videók, beállítások — közös, és más demo látogatók egyszerre módosíthatják."
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,6 +5,9 @@
|
|||
"emptyBody": "Kösd össze a YouTube-fiókodat, hogy importáld a feliratkozásaidat, és felépítsd a hírfolyamodat.",
|
||||
"setUp": "Hírfolyam beállítása",
|
||||
"browseShared": "Vagy csak böngészd a közös könyvtárat",
|
||||
"demoEmptyTitle": "Itt a demóban nincs semmi",
|
||||
"demoEmptyBody": "A demo fióknak nincsenek saját feliratkozásai — de a teljes közös könyvtár a tiéd: böngészd, szűrd és rendezd kedvedre.",
|
||||
"browseLibrary": "Könyvtár böngészése",
|
||||
"noMatches": "Egy videó sem felel meg ezeknek a szűrőknek.",
|
||||
"videoCount_one": "{{formattedCount}} videó",
|
||||
"videoCount_other": "{{formattedCount}} videó",
|
||||
|
|
|
|||
|
|
@ -79,7 +79,26 @@
|
|||
"granted": "Megadva",
|
||||
"enable": "Engedélyezés",
|
||||
"enableHint": "Átirányít a Google-höz az engedély megadásához. A Google „nem ellenőrzött alkalmazás” figyelmeztetést mutat — ez normális; kattints a Speciális → Tovább a Siftlode-ra lehetőségre.",
|
||||
"walkMeThrough": "Vezess végig a beállításon"
|
||||
"walkMeThrough": "Vezess végig a beállításon",
|
||||
"demoNotice": "Ez a közös demo fiók. Nincs YouTube-kapcsolata — böngészheted a teljes közös könyvtárat, szűrheted és rendezheted, listákat építhetsz és kipróbálhatsz dolgokat, de semmi nem nyúl valódi YouTube-fiókhoz."
|
||||
},
|
||||
"demo": {
|
||||
"title": "Demo hozzáférés",
|
||||
"intro": "Az itt szereplő e-mailek a bejelentkező oldalról közvetlenül beléphetnek a közös demo fiókba — Google-bejelentkezés nélkül. Csak beírják a címet a mezőbe, és pár pillanat múlva bekerülnek.",
|
||||
"addPlaceholder": "E-mail fehérlistára a demóhoz…",
|
||||
"add": "Hozzáadás",
|
||||
"added": "Hozzáadva a demo fehérlistához",
|
||||
"addFailed": "Nem sikerült hozzáadni ezt az e-mailt",
|
||||
"removeFailed": "Nem sikerült eltávolítani ezt az e-mailt",
|
||||
"remove": "Eltávolítás",
|
||||
"removeHint": "E-mail eltávolítása a demo fehérlistáról.",
|
||||
"addedBy": "hozzáadta: {{who}}",
|
||||
"reset": "Demo állapot visszaállítása",
|
||||
"resetHint": "Törli a demo fiók lejátszási listáit, elrejtett/megnézett/mentett videóit és beállításait, majd újra létrehoz pár minta lejátszási listát.",
|
||||
"resetTitle": "Visszaállítod a demo állapotot?",
|
||||
"resetConfirm": "Ez törli mindazt, amit a demo fiók összegyűjtött — lejátszási listák, elrejtett/megnézett/mentett videók és beállítások —, és újra létrehoz pár minta lejátszási listát. Nem vonható vissza.",
|
||||
"resetDone": "Demo visszaállítva — {{count}} minta lejátszási lista létrehozva",
|
||||
"resetFailed": "Nem sikerült visszaállítani a demo fiókot"
|
||||
},
|
||||
"invites": {
|
||||
"title": "Hozzáférési kérelmek",
|
||||
|
|
|
|||
|
|
@ -6,12 +6,21 @@ export interface Me {
|
|||
display_name: string | null;
|
||||
avatar_url: string | null;
|
||||
role: string;
|
||||
is_demo: boolean;
|
||||
can_read: boolean;
|
||||
can_write: boolean;
|
||||
pending_invites: number;
|
||||
preferences: Record<string, any>;
|
||||
}
|
||||
|
||||
export interface DemoWhitelistEntry {
|
||||
id: number;
|
||||
email: string;
|
||||
note: string | null;
|
||||
added_by: string | null;
|
||||
created_at: string | null;
|
||||
}
|
||||
|
||||
export interface Invite {
|
||||
id: number;
|
||||
email: string;
|
||||
|
|
@ -395,6 +404,18 @@ export const api = {
|
|||
// --- onboarding / admin ---
|
||||
requestAccess: (email: string): Promise<{ status: string }> =>
|
||||
req("/auth/request-access", { method: "POST", body: JSON.stringify({ email }) }),
|
||||
// Hidden demo entry: a whitelisted email logs into the shared demo account, no Google
|
||||
// sign-in. Returns whether a session was established.
|
||||
demoLogin: (email: string): Promise<{ authenticated: boolean }> =>
|
||||
req("/auth/demo", { method: "POST", body: JSON.stringify({ email }) }),
|
||||
adminDemoWhitelist: (): Promise<DemoWhitelistEntry[]> =>
|
||||
req("/api/admin/demo/whitelist"),
|
||||
addDemoWhitelist: (email: string): Promise<DemoWhitelistEntry> =>
|
||||
req("/api/admin/demo/whitelist", { method: "POST", body: JSON.stringify({ email }) }),
|
||||
removeDemoWhitelist: (id: number) =>
|
||||
req(`/api/admin/demo/whitelist/${id}`, { method: "DELETE" }),
|
||||
resetDemo: (): Promise<{ reset: boolean; playlists_seeded: number }> =>
|
||||
req("/api/admin/demo/reset", { method: "POST" }),
|
||||
adminInvites: (status?: string): Promise<Invite[]> =>
|
||||
req(`/api/admin/invites${status ? `?status=${status}` : ""}`),
|
||||
approveInvite: (id: number) =>
|
||||
|
|
|
|||
|
|
@ -15,7 +15,9 @@
|
|||
export const ONBOARD_ACTIVE = "subfeed.onboarding.active";
|
||||
export const ONBOARD_DISMISSED = "subfeed.onboarding.dismissed";
|
||||
|
||||
export function shouldAutoOpenOnboarding(me: { can_read: boolean }): boolean {
|
||||
export function shouldAutoOpenOnboarding(me: { can_read: boolean; is_demo?: boolean }): boolean {
|
||||
// The shared demo account can never connect YouTube, so never nudge it to.
|
||||
if (me.is_demo) return false;
|
||||
if (sessionStorage.getItem(ONBOARD_ACTIVE)) return true;
|
||||
return !me.can_read && !localStorage.getItem(ONBOARD_DISMISSED);
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue