fix: address reader-UI feedback (shorts, search, tags, login, UX)
- Shorts: confirm via youtube.com/shorts/<id> probe (SOCS cookie bypasses the consent redirect) instead of a 60s heuristic; concurrent probing, shorts_probed flag, scheduled refinement (migration 0005) - Search: match title + channel name only (descriptions caused noisy results) - Faceted tag filtering: AND across categories (language AND topic narrows), OR within a category; any/all toggle applies to topics - Language detection: majority vote over individual titles (fixes misdetections like multipoleguy -> English; drops bogus Polish/Romanian) - Login: drop forced consent so returning sign-in is quick (select_account) - Feed cards: clickable channel name (opens channel), persistent saved badge, undo toast on hide, Hidden view to restore; tag chips show counts in tooltip
This commit is contained in:
parent
e56502789f
commit
8c245e986f
17 changed files with 306 additions and 35 deletions
28
backend/alembic/versions/0005_shorts_probed.py
Normal file
28
backend/alembic/versions/0005_shorts_probed.py
Normal file
|
|
@ -0,0 +1,28 @@
|
||||||
|
"""add videos.shorts_probed
|
||||||
|
|
||||||
|
Revision ID: 0005_shorts_probed
|
||||||
|
Revises: 0004_feed_state_prefs
|
||||||
|
Create Date: 2026-06-11
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
revision: str = "0005_shorts_probed"
|
||||||
|
down_revision: Union[str, None] = "0004_feed_state_prefs"
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.add_column(
|
||||||
|
"videos",
|
||||||
|
sa.Column("shorts_probed", sa.Boolean(), nullable=False, server_default="false"),
|
||||||
|
)
|
||||||
|
op.create_index("ix_videos_shorts_probed", "videos", ["shorts_probed"])
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_index("ix_videos_shorts_probed", table_name="videos")
|
||||||
|
op.drop_column("videos", "shorts_probed")
|
||||||
|
|
@ -28,13 +28,14 @@ oauth.register(
|
||||||
|
|
||||||
@router.get("/login")
|
@router.get("/login")
|
||||||
async def login(request: Request):
|
async def login(request: Request):
|
||||||
# access_type=offline + prompt=consent must be on the authorization URL so Google
|
# access_type=offline ensures a refresh_token on first authorization. We avoid
|
||||||
# returns a refresh_token (required for background sync).
|
# prompt=consent so returning users get a quick sign-in; the stored refresh token
|
||||||
|
# is kept when Google doesn't re-issue one (see the callback).
|
||||||
return await oauth.google.authorize_redirect(
|
return await oauth.google.authorize_redirect(
|
||||||
request,
|
request,
|
||||||
settings.oauth_redirect_url,
|
settings.oauth_redirect_url,
|
||||||
access_type="offline",
|
access_type="offline",
|
||||||
prompt="consent",
|
prompt="select_account",
|
||||||
include_granted_scopes="true",
|
include_granted_scopes="true",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -34,8 +34,11 @@ class Settings(BaseSettings):
|
||||||
backfill_recent_max_videos: int = 100
|
backfill_recent_max_videos: int = 100
|
||||||
backfill_recent_max_days: int = 365
|
backfill_recent_max_days: int = 365
|
||||||
|
|
||||||
# Videos at or below this duration (seconds) are treated as Shorts.
|
# Shorts are confirmed by probing youtube.com/shorts/<id>. Only videos at or below
|
||||||
shorts_max_seconds: int = 60
|
# this duration (and not livestreams) are probed; longer videos are never Shorts.
|
||||||
|
shorts_probe_max_seconds: int = 180
|
||||||
|
shorts_probe_batch: int = 150
|
||||||
|
shorts_probe_interval_minutes: int = 2
|
||||||
# videos.list accepts up to 50 ids per call.
|
# videos.list accepts up to 50 ids per call.
|
||||||
enrich_batch_size: int = 50
|
enrich_batch_size: int = 50
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -146,6 +146,10 @@ class Video(Base):
|
||||||
is_short: Mapped[bool] = mapped_column(
|
is_short: Mapped[bool] = mapped_column(
|
||||||
Boolean, default=False, server_default="false", index=True
|
Boolean, default=False, server_default="false", index=True
|
||||||
)
|
)
|
||||||
|
# Whether the youtube.com/shorts/<id> probe has run for this video.
|
||||||
|
shorts_probed: Mapped[bool] = mapped_column(
|
||||||
|
Boolean, default=False, server_default="false", index=True
|
||||||
|
)
|
||||||
# none | live | upcoming | premiere | was_live
|
# none | live | upcoming | premiere | was_live
|
||||||
live_status: Mapped[str] = mapped_column(
|
live_status: Mapped[str] = mapped_column(
|
||||||
String(16), default="none", server_default="none", index=True
|
String(16), default="none", server_default="none", index=True
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@ from sqlalchemy.orm import Session, aliased
|
||||||
|
|
||||||
from app.auth import current_user
|
from app.auth import current_user
|
||||||
from app.db import get_db
|
from app.db import get_db
|
||||||
from app.models import Channel, ChannelTag, User, Video, VideoState
|
from app.models import Channel, ChannelTag, Tag, User, Video, VideoState
|
||||||
|
|
||||||
router = APIRouter(prefix="/api", tags=["feed"])
|
router = APIRouter(prefix="/api", tags=["feed"])
|
||||||
|
|
||||||
|
|
@ -14,6 +14,12 @@ VALID_STATES = {"new", "watched", "saved", "hidden"}
|
||||||
HIDDEN_LIVE = ("live", "upcoming")
|
HIDDEN_LIVE = ("live", "upcoming")
|
||||||
|
|
||||||
|
|
||||||
|
def _channel_url(channel_id: str, handle: str | None) -> str:
|
||||||
|
if handle and handle.startswith("@"):
|
||||||
|
return f"https://www.youtube.com/{handle}"
|
||||||
|
return f"https://www.youtube.com/channel/{channel_id}"
|
||||||
|
|
||||||
|
|
||||||
def _serialize(row) -> dict:
|
def _serialize(row) -> dict:
|
||||||
return {
|
return {
|
||||||
"id": row.id,
|
"id": row.id,
|
||||||
|
|
@ -21,6 +27,7 @@ def _serialize(row) -> dict:
|
||||||
"channel_id": row.channel_id,
|
"channel_id": row.channel_id,
|
||||||
"channel_title": row.channel_title,
|
"channel_title": row.channel_title,
|
||||||
"channel_thumbnail": row.channel_thumbnail,
|
"channel_thumbnail": row.channel_thumbnail,
|
||||||
|
"channel_url": _channel_url(row.channel_id, row.channel_handle),
|
||||||
"published_at": row.published_at.isoformat() if row.published_at else None,
|
"published_at": row.published_at.isoformat() if row.published_at else None,
|
||||||
"thumbnail_url": row.thumbnail_url,
|
"thumbnail_url": row.thumbnail_url,
|
||||||
"duration_seconds": row.duration_seconds,
|
"duration_seconds": row.duration_seconds,
|
||||||
|
|
@ -61,6 +68,7 @@ def get_feed(
|
||||||
Video.channel_id,
|
Video.channel_id,
|
||||||
Channel.title.label("channel_title"),
|
Channel.title.label("channel_title"),
|
||||||
Channel.thumbnail_url.label("channel_thumbnail"),
|
Channel.thumbnail_url.label("channel_thumbnail"),
|
||||||
|
Channel.handle.label("channel_handle"),
|
||||||
Video.published_at,
|
Video.published_at,
|
||||||
Video.thumbnail_url,
|
Video.thumbnail_url,
|
||||||
Video.duration_seconds,
|
Video.duration_seconds,
|
||||||
|
|
@ -91,21 +99,33 @@ def get_feed(
|
||||||
Video.published_at >= datetime.fromtimestamp(cutoff, tz=timezone.utc)
|
Video.published_at >= datetime.fromtimestamp(cutoff, tz=timezone.utc)
|
||||||
)
|
)
|
||||||
if q:
|
if q:
|
||||||
|
# Title (and channel name) only — searching descriptions produced noisy matches.
|
||||||
like = f"%{q}%"
|
like = f"%{q}%"
|
||||||
query = query.where(or_(Video.title.ilike(like), Video.description.ilike(like)))
|
query = query.where(or_(Video.title.ilike(like), Channel.title.ilike(like)))
|
||||||
|
|
||||||
if tags:
|
if tags:
|
||||||
|
# Group selected tags by category: AND across categories (e.g. language AND
|
||||||
|
# topic narrows results), OR within a category. The any/all toggle controls
|
||||||
|
# whether multiple topic tags are OR'd (any) or AND'd (all).
|
||||||
|
cat_rows = db.execute(
|
||||||
|
select(Tag.id, Tag.category).where(Tag.id.in_(tags))
|
||||||
|
).all()
|
||||||
|
by_category: dict[str, list[int]] = {}
|
||||||
|
for tag_id, category in cat_rows:
|
||||||
|
by_category.setdefault(category, []).append(tag_id)
|
||||||
|
|
||||||
visible = or_(ChannelTag.user_id.is_(None), ChannelTag.user_id == user.id)
|
visible = or_(ChannelTag.user_id.is_(None), ChannelTag.user_id == user.id)
|
||||||
if tag_mode == "and":
|
for category, ids in by_category.items():
|
||||||
|
if category == "topic" and tag_mode == "and" and len(ids) > 1:
|
||||||
sub = (
|
sub = (
|
||||||
select(ChannelTag.channel_id)
|
select(ChannelTag.channel_id)
|
||||||
.where(ChannelTag.tag_id.in_(tags), visible)
|
.where(ChannelTag.tag_id.in_(ids), visible)
|
||||||
.group_by(ChannelTag.channel_id)
|
.group_by(ChannelTag.channel_id)
|
||||||
.having(func.count(func.distinct(ChannelTag.tag_id)) == len(set(tags)))
|
.having(func.count(func.distinct(ChannelTag.tag_id)) == len(set(ids)))
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
sub = select(ChannelTag.channel_id).where(
|
sub = select(ChannelTag.channel_id).where(
|
||||||
ChannelTag.tag_id.in_(tags), visible
|
ChannelTag.tag_id.in_(ids), visible
|
||||||
)
|
)
|
||||||
query = query.where(Video.channel_id.in_(sub))
|
query = query.where(Video.channel_id.in_(sub))
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@ from app.sync.runner import (
|
||||||
run_enrich,
|
run_enrich,
|
||||||
run_recent_backfill,
|
run_recent_backfill,
|
||||||
run_rss_poll,
|
run_rss_poll,
|
||||||
|
run_shorts,
|
||||||
)
|
)
|
||||||
|
|
||||||
logger = logging.getLogger("subfeed.scheduler")
|
logger = logging.getLogger("subfeed.scheduler")
|
||||||
|
|
@ -53,6 +54,10 @@ def _autotag_job() -> None:
|
||||||
_job("autotag", lambda db: run_autotag_all(db, only_missing=True))
|
_job("autotag", lambda db: run_autotag_all(db, only_missing=True))
|
||||||
|
|
||||||
|
|
||||||
|
def _shorts_job() -> None:
|
||||||
|
_job("shorts", run_shorts)
|
||||||
|
|
||||||
|
|
||||||
def start_scheduler() -> None:
|
def start_scheduler() -> None:
|
||||||
global _scheduler
|
global _scheduler
|
||||||
if not settings.scheduler_enabled or _scheduler is not None:
|
if not settings.scheduler_enabled or _scheduler is not None:
|
||||||
|
|
@ -76,6 +81,12 @@ def start_scheduler() -> None:
|
||||||
minutes=settings.autotag_interval_minutes,
|
minutes=settings.autotag_interval_minutes,
|
||||||
id="autotag",
|
id="autotag",
|
||||||
)
|
)
|
||||||
|
scheduler.add_job(
|
||||||
|
_shorts_job,
|
||||||
|
"interval",
|
||||||
|
minutes=settings.shorts_probe_interval_minutes,
|
||||||
|
id="shorts",
|
||||||
|
)
|
||||||
scheduler.start()
|
scheduler.start()
|
||||||
_scheduler = scheduler
|
_scheduler = scheduler
|
||||||
logger.info("scheduler started")
|
logger.info("scheduler started")
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,8 @@ detection over a sample of recent video titles. Topics are mapped from YouTube's
|
||||||
topicDetails categories and the channel's dominant video category. System tags are
|
topicDetails categories and the channel's dominant video category. System tags are
|
||||||
regenerated freely; user tags are never touched here.
|
regenerated freely; user tags are never touched here.
|
||||||
"""
|
"""
|
||||||
|
from collections import Counter
|
||||||
|
|
||||||
from sqlalchemy import and_, exists, func, select
|
from sqlalchemy import and_, exists, func, select
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
|
@ -156,11 +158,20 @@ def detect_channel_language(db: Session, channel: Channel) -> tuple[str | None,
|
||||||
.scalars()
|
.scalars()
|
||||||
.all()
|
.all()
|
||||||
)
|
)
|
||||||
text = " . ".join(t for t in titles if t).strip()
|
# Majority vote over individual titles is more robust than one concatenated blob
|
||||||
if len(text) < 15:
|
# (short/technical titles otherwise skew the detector).
|
||||||
|
votes: Counter[str] = Counter()
|
||||||
|
for title in titles:
|
||||||
|
cleaned = (title or "").strip()
|
||||||
|
if len(cleaned) < 8:
|
||||||
|
continue
|
||||||
|
lang, _conf = _classify(cleaned)
|
||||||
|
votes[lang] += 1
|
||||||
|
if not votes:
|
||||||
return None, 0.0
|
return None, 0.0
|
||||||
lang, confidence = _classify(text)
|
lang, count = votes.most_common(1)[0]
|
||||||
return lang, float(confidence)
|
total = sum(votes.values())
|
||||||
|
return lang, count / total
|
||||||
|
|
||||||
|
|
||||||
def compute_channel_topics(db: Session, channel: Channel) -> set[str]:
|
def compute_channel_topics(db: Session, channel: Channel) -> set[str]:
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,7 @@ from app.sync.videos import (
|
||||||
backfill_channel_recent,
|
backfill_channel_recent,
|
||||||
enrich_pending,
|
enrich_pending,
|
||||||
poll_rss_channel,
|
poll_rss_channel,
|
||||||
|
run_shorts_classification,
|
||||||
)
|
)
|
||||||
from app.youtube.client import YouTubeClient
|
from app.youtube.client import YouTubeClient
|
||||||
|
|
||||||
|
|
@ -60,6 +61,10 @@ def run_enrich(db: Session, max_batches: int = 200, floor: int = 200) -> int:
|
||||||
return total
|
return total
|
||||||
|
|
||||||
|
|
||||||
|
def run_shorts(db: Session) -> dict:
|
||||||
|
return run_shorts_classification(db)
|
||||||
|
|
||||||
|
|
||||||
def run_recent_backfill(
|
def run_recent_backfill(
|
||||||
db: Session, channels: list[Channel] | None = None, max_channels: int | None = None
|
db: Session, channels: list[Channel] | None = None, max_channels: int | None = None
|
||||||
) -> dict:
|
) -> dict:
|
||||||
|
|
|
||||||
|
|
@ -2,9 +2,10 @@
|
||||||
uploads playlist, and enrichment via videos.list (duration, stats, category, Shorts
|
uploads playlist, and enrichment via videos.list (duration, stats, category, Shorts
|
||||||
and livestream classification)."""
|
and livestream classification)."""
|
||||||
import re
|
import re
|
||||||
|
from concurrent.futures import ThreadPoolExecutor
|
||||||
from datetime import datetime, timedelta, timezone
|
from datetime import datetime, timedelta, timezone
|
||||||
|
|
||||||
from sqlalchemy import select
|
from sqlalchemy import or_, select, update
|
||||||
from sqlalchemy.dialects.postgresql import insert as pg_insert
|
from sqlalchemy.dialects.postgresql import insert as pg_insert
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
|
@ -12,6 +13,7 @@ from app.config import settings
|
||||||
from app.models import Channel, Video
|
from app.models import Channel, Video
|
||||||
from app.youtube.client import YouTubeClient, best_thumbnail
|
from app.youtube.client import YouTubeClient, best_thumbnail
|
||||||
from app.youtube.rss import fetch_channel_feed
|
from app.youtube.rss import fetch_channel_feed
|
||||||
|
from app.youtube.shorts import make_client, probe_is_short
|
||||||
|
|
||||||
_DURATION_RE = re.compile(
|
_DURATION_RE = re.compile(
|
||||||
r"P(?:(\d+)D)?T(?:(\d+)H)?(?:(\d+)M)?(?:(\d+)S)?", re.IGNORECASE
|
r"P(?:(\d+)D)?T(?:(\d+)H)?(?:(\d+)M)?(?:(\d+)S)?", re.IGNORECASE
|
||||||
|
|
@ -195,11 +197,7 @@ def apply_video_details(video: Video, item: dict) -> None:
|
||||||
"defaultAudioLanguage"
|
"defaultAudioLanguage"
|
||||||
)
|
)
|
||||||
video.live_status = _live_status(snippet, live)
|
video.live_status = _live_status(snippet, live)
|
||||||
video.is_short = bool(
|
# is_short is decided separately by the youtube.com/shorts probe (run_shorts_classification).
|
||||||
video.live_status == "none"
|
|
||||||
and video.duration_seconds
|
|
||||||
and 0 < video.duration_seconds <= settings.shorts_max_seconds
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def enrich_pending(db: Session, yt: YouTubeClient, limit: int | None = None) -> int:
|
def enrich_pending(db: Session, yt: YouTubeClient, limit: int | None = None) -> int:
|
||||||
|
|
@ -225,3 +223,60 @@ def enrich_pending(db: Session, yt: YouTubeClient, limit: int | None = None) ->
|
||||||
enriched += 1
|
enriched += 1
|
||||||
db.commit()
|
db.commit()
|
||||||
return enriched
|
return enriched
|
||||||
|
|
||||||
|
|
||||||
|
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."""
|
||||||
|
limit = limit or settings.shorts_probe_batch
|
||||||
|
probe_max = settings.shorts_probe_max_seconds
|
||||||
|
|
||||||
|
# 1) Anything enriched that can't be a Short -> finalize without a probe.
|
||||||
|
db.execute(
|
||||||
|
update(Video)
|
||||||
|
.where(
|
||||||
|
Video.shorts_probed.is_(False),
|
||||||
|
Video.enriched_at.is_not(None),
|
||||||
|
or_(
|
||||||
|
Video.duration_seconds.is_(None),
|
||||||
|
Video.duration_seconds > probe_max,
|
||||||
|
Video.live_status != "none",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.values(is_short=False, shorts_probed=True)
|
||||||
|
)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
# 2) Probe the remaining candidates.
|
||||||
|
candidates = (
|
||||||
|
db.execute(
|
||||||
|
select(Video).where(
|
||||||
|
Video.shorts_probed.is_(False),
|
||||||
|
Video.enriched_at.is_not(None),
|
||||||
|
Video.duration_seconds <= probe_max,
|
||||||
|
Video.live_status == "none",
|
||||||
|
).limit(limit)
|
||||||
|
)
|
||||||
|
.scalars()
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
if not candidates:
|
||||||
|
return {"probed": 0, "shorts": 0}
|
||||||
|
|
||||||
|
probed = 0
|
||||||
|
shorts = 0
|
||||||
|
with make_client() as client:
|
||||||
|
def work(video: Video):
|
||||||
|
return video, probe_is_short(client, video.id)
|
||||||
|
|
||||||
|
with ThreadPoolExecutor(max_workers=16) as pool:
|
||||||
|
for video, result in pool.map(work, candidates):
|
||||||
|
if result is None:
|
||||||
|
continue # leave unprobed; retry on a later run
|
||||||
|
video.is_short = result
|
||||||
|
video.shorts_probed = True
|
||||||
|
probed += 1
|
||||||
|
if result:
|
||||||
|
shorts += 1
|
||||||
|
db.commit()
|
||||||
|
return {"probed": probed, "shorts": shorts}
|
||||||
|
|
|
||||||
32
backend/app/youtube/shorts.py
Normal file
32
backend/app/youtube/shorts.py
Normal file
|
|
@ -0,0 +1,32 @@
|
||||||
|
"""Confirm whether a video is a Short by probing youtube.com/shorts/<id>.
|
||||||
|
|
||||||
|
A real Short returns HTTP 200 at that URL; a regular video redirects to /watch.
|
||||||
|
This uses no API quota."""
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
SHORTS_URL = "https://www.youtube.com/shorts/{video_id}"
|
||||||
|
# The SOCS cookie skips YouTube's cookie-consent interstitial, which otherwise
|
||||||
|
# redirects every server-side request to consent.youtube.com.
|
||||||
|
_HEADERS = {
|
||||||
|
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64)",
|
||||||
|
"Cookie": "SOCS=CAI",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def probe_is_short(client: httpx.Client, video_id: str) -> bool | None:
|
||||||
|
"""True if a Short, False if a regular video, None if undetermined (retry later)."""
|
||||||
|
try:
|
||||||
|
resp = client.get(
|
||||||
|
SHORTS_URL.format(video_id=video_id), follow_redirects=False
|
||||||
|
)
|
||||||
|
except httpx.HTTPError:
|
||||||
|
return None
|
||||||
|
if resp.status_code == 200:
|
||||||
|
return True
|
||||||
|
if resp.status_code in (301, 302, 303, 307, 308):
|
||||||
|
return False
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def make_client() -> httpx.Client:
|
||||||
|
return httpx.Client(timeout=10.0, headers=_HEADERS)
|
||||||
|
|
@ -12,6 +12,7 @@ import Login from "./components/Login";
|
||||||
import Header from "./components/Header";
|
import Header from "./components/Header";
|
||||||
import Sidebar from "./components/Sidebar";
|
import Sidebar from "./components/Sidebar";
|
||||||
import Feed from "./components/Feed";
|
import Feed from "./components/Feed";
|
||||||
|
import Toaster from "./components/Toaster";
|
||||||
|
|
||||||
const DEFAULT_FILTERS: FeedFilters = {
|
const DEFAULT_FILTERS: FeedFilters = {
|
||||||
tags: [],
|
tags: [],
|
||||||
|
|
@ -83,6 +84,7 @@ export default function App() {
|
||||||
<Feed filters={filters} view={view} />
|
<Feed filters={filters} view={view} />
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
<Toaster />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import { useInfiniteQuery } from "@tanstack/react-query";
|
import { useInfiniteQuery } from "@tanstack/react-query";
|
||||||
import { api, type FeedFilters, type Video } from "../lib/api";
|
import { api, type FeedFilters, type Video } from "../lib/api";
|
||||||
|
import { toast } from "../lib/toast";
|
||||||
import VideoCard from "./VideoCard";
|
import VideoCard from "./VideoCard";
|
||||||
|
|
||||||
const PAGE = 60;
|
const PAGE = 60;
|
||||||
|
|
@ -43,12 +44,19 @@ export default function Feed({
|
||||||
function onState(id: string, status: string) {
|
function onState(id: string, status: string) {
|
||||||
setOverrides((o) => ({ ...o, [id]: status }));
|
setOverrides((o) => ({ ...o, [id]: status }));
|
||||||
api.setState(id, status).catch(() => {});
|
api.setState(id, status).catch(() => {});
|
||||||
|
if (status === "hidden") {
|
||||||
|
toast("Video hidden", { label: "Undo", onClick: () => onState(id, "new") });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const items: Video[] = (query.data?.pages ?? [])
|
const items: Video[] = (query.data?.pages ?? [])
|
||||||
.flatMap((p) => p.items)
|
.flatMap((p) => p.items)
|
||||||
.map((v) => (overrides[v.id] ? { ...v, status: overrides[v.id] } : v))
|
.map((v) => (overrides[v.id] ? { ...v, status: overrides[v.id] } : v))
|
||||||
.filter((v) => overrides[v.id] !== "hidden");
|
.filter((v) => {
|
||||||
|
const ov = overrides[v.id];
|
||||||
|
// In the Hidden view, drop just-unhidden items; elsewhere drop just-hidden ones.
|
||||||
|
return filters.show === "hidden" ? ov !== "new" : ov !== "hidden";
|
||||||
|
});
|
||||||
|
|
||||||
if (query.isLoading) return <div className="p-8 text-muted">Loading feed…</div>;
|
if (query.isLoading) return <div className="p-8 text-muted">Loading feed…</div>;
|
||||||
if (query.isError) return <div className="p-8 text-muted">Couldn't load the feed.</div>;
|
if (query.isError) return <div className="p-8 text-muted">Couldn't load the feed.</div>;
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,7 @@ const SHOWS = [
|
||||||
{ id: "all", label: "All" },
|
{ id: "all", label: "All" },
|
||||||
{ id: "watched", label: "Watched" },
|
{ id: "watched", label: "Watched" },
|
||||||
{ id: "saved", label: "Saved" },
|
{ id: "saved", label: "Saved" },
|
||||||
|
{ id: "hidden", label: "Hidden" },
|
||||||
];
|
];
|
||||||
|
|
||||||
function TagChip({
|
function TagChip({
|
||||||
|
|
@ -29,6 +30,7 @@ function TagChip({
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
|
title={`${tag.channel_count} channel${tag.channel_count === 1 ? "" : "s"}`}
|
||||||
className={`text-xs px-2.5 py-1 rounded-full border transition ${
|
className={`text-xs px-2.5 py-1 rounded-full border transition ${
|
||||||
active
|
active
|
||||||
? "bg-accent text-accent-fg border-accent"
|
? "bg-accent text-accent-fg border-accent"
|
||||||
|
|
@ -36,9 +38,6 @@ function TagChip({
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{tag.name}
|
{tag.name}
|
||||||
<span className={active ? "opacity-80 ml-1" : "text-muted ml-1"}>
|
|
||||||
{tag.channel_count}
|
|
||||||
</span>
|
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
29
frontend/src/components/Toaster.tsx
Normal file
29
frontend/src/components/Toaster.tsx
Normal file
|
|
@ -0,0 +1,29 @@
|
||||||
|
import { useSyncExternalStore } from "react";
|
||||||
|
import { dismiss, getToasts, subscribe } from "../lib/toast";
|
||||||
|
|
||||||
|
export default function Toaster() {
|
||||||
|
const toasts = useSyncExternalStore(subscribe, getToasts, getToasts);
|
||||||
|
return (
|
||||||
|
<div className="fixed bottom-4 right-4 z-50 flex flex-col gap-2">
|
||||||
|
{toasts.map((t) => (
|
||||||
|
<div
|
||||||
|
key={t.id}
|
||||||
|
className="bg-surface border border-border rounded-xl shadow-2xl px-4 py-3 flex items-center gap-4 animate-[fadeIn_0.15s_ease]"
|
||||||
|
>
|
||||||
|
<span className="text-sm">{t.message}</span>
|
||||||
|
{t.action && (
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
t.action!.onClick();
|
||||||
|
dismiss(t.id);
|
||||||
|
}}
|
||||||
|
className="text-accent text-sm font-semibold hover:underline"
|
||||||
|
>
|
||||||
|
{t.action.label}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -80,6 +80,11 @@ function Thumb({ video, className }: { video: Video; className?: string }) {
|
||||||
stream
|
stream
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
{video.status === "saved" && (
|
||||||
|
<span className="absolute top-1.5 right-1.5 bg-accent text-accent-fg rounded-full p-1">
|
||||||
|
<Bookmark className="w-3.5 h-3.5" />
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</a>
|
</a>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -122,7 +127,15 @@ export default function VideoCard({
|
||||||
<Thumb video={video} className="w-44 aspect-video shrink-0" />
|
<Thumb video={video} className="w-44 aspect-video shrink-0" />
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
{title}
|
{title}
|
||||||
<div className="text-sm text-muted truncate mt-0.5">{video.channel_title}</div>
|
<a
|
||||||
|
href={video.channel_url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
className="text-sm text-muted truncate mt-0.5 block w-fit max-w-full hover:text-fg"
|
||||||
|
>
|
||||||
|
{video.channel_title}
|
||||||
|
</a>
|
||||||
<div className="text-xs text-muted mt-0.5">{meta}</div>
|
<div className="text-xs text-muted mt-0.5">{meta}</div>
|
||||||
</div>
|
</div>
|
||||||
<Actions video={video} onState={onState} />
|
<Actions video={video} onState={onState} />
|
||||||
|
|
@ -144,7 +157,15 @@ export default function VideoCard({
|
||||||
)}
|
)}
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
{title}
|
{title}
|
||||||
<div className="text-sm text-muted truncate mt-0.5">{video.channel_title}</div>
|
<a
|
||||||
|
href={video.channel_url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
className="text-sm text-muted truncate mt-0.5 block w-fit max-w-full hover:text-fg"
|
||||||
|
>
|
||||||
|
{video.channel_title}
|
||||||
|
</a>
|
||||||
<div className="text-xs text-muted mt-0.5">{meta}</div>
|
<div className="text-xs text-muted mt-0.5">{meta}</div>
|
||||||
<Actions video={video} onState={onState} />
|
<Actions video={video} onState={onState} />
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,7 @@ export interface Video {
|
||||||
channel_id: string;
|
channel_id: string;
|
||||||
channel_title: string | null;
|
channel_title: string | null;
|
||||||
channel_thumbnail: string | null;
|
channel_thumbnail: string | null;
|
||||||
|
channel_url: string;
|
||||||
published_at: string | null;
|
published_at: string | null;
|
||||||
thumbnail_url: string | null;
|
thumbnail_url: string | null;
|
||||||
duration_seconds: number | null;
|
duration_seconds: number | null;
|
||||||
|
|
|
||||||
41
frontend/src/lib/toast.ts
Normal file
41
frontend/src/lib/toast.ts
Normal file
|
|
@ -0,0 +1,41 @@
|
||||||
|
export interface ToastAction {
|
||||||
|
label: string;
|
||||||
|
onClick: () => void;
|
||||||
|
}
|
||||||
|
export interface ToastItem {
|
||||||
|
id: number;
|
||||||
|
message: string;
|
||||||
|
action?: ToastAction;
|
||||||
|
}
|
||||||
|
|
||||||
|
let items: ToastItem[] = [];
|
||||||
|
let listeners: Array<() => void> = [];
|
||||||
|
let counter = 1;
|
||||||
|
|
||||||
|
function emit() {
|
||||||
|
listeners.forEach((l) => l());
|
||||||
|
}
|
||||||
|
|
||||||
|
export function toast(message: string, action?: ToastAction): number {
|
||||||
|
const id = counter++;
|
||||||
|
items = [...items, { id, message, action }];
|
||||||
|
emit();
|
||||||
|
setTimeout(() => dismiss(id), 6000);
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function dismiss(id: number) {
|
||||||
|
items = items.filter((i) => i.id !== id);
|
||||||
|
emit();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getToasts(): ToastItem[] {
|
||||||
|
return items;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function subscribe(listener: () => void): () => void {
|
||||||
|
listeners.push(listener);
|
||||||
|
return () => {
|
||||||
|
listeners = listeners.filter((l) => l !== listener);
|
||||||
|
};
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue