Merge feature/s4a-playlists-foundation: local playlists foundation
S4a (local only; YouTube sync in later phases): - feat(playlists): backend model + migration 0011 + CRUD API - feat(playlists): Playlists page (rail + detail, drag-reorder, rename, delete, remove item, play) + AddToPlaylist popover (cards + player) + nav - feat(ui): reusable app-styled confirm dialog (ConfirmProvider/useConfirm), replacing window.confirm - fixes: popover overflow flip, post-delete refresh/reselect, page persistence across reloads, header title on the Playlists page
This commit is contained in:
commit
52d8ce14e7
24 changed files with 1191 additions and 15 deletions
88
backend/alembic/versions/0011_playlists.py
Normal file
88
backend/alembic/versions/0011_playlists.py
Normal file
|
|
@ -0,0 +1,88 @@
|
||||||
|
"""local playlists: playlists + playlist_items
|
||||||
|
|
||||||
|
Revision ID: 0011_playlists
|
||||||
|
Revises: 0010_watch_progress
|
||||||
|
Create Date: 2026-06-15
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
revision: str = "0011_playlists"
|
||||||
|
down_revision: Union[str, None] = "0010_watch_progress"
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.create_table(
|
||||||
|
"playlists",
|
||||||
|
sa.Column("id", sa.Integer(), primary_key=True),
|
||||||
|
sa.Column(
|
||||||
|
"user_id",
|
||||||
|
sa.Integer(),
|
||||||
|
sa.ForeignKey("users.id", ondelete="CASCADE"),
|
||||||
|
nullable=False,
|
||||||
|
),
|
||||||
|
sa.Column("name", sa.String(length=255), nullable=False),
|
||||||
|
sa.Column(
|
||||||
|
"kind", sa.String(length=16), nullable=False, server_default="user"
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"source", sa.String(length=16), nullable=False, server_default="local"
|
||||||
|
),
|
||||||
|
sa.Column("yt_playlist_id", sa.String(length=64), nullable=True),
|
||||||
|
sa.Column(
|
||||||
|
"dirty", sa.Boolean(), nullable=False, server_default="false"
|
||||||
|
),
|
||||||
|
sa.Column("position", sa.Integer(), nullable=False, server_default="0"),
|
||||||
|
sa.Column(
|
||||||
|
"created_at",
|
||||||
|
sa.DateTime(timezone=True),
|
||||||
|
server_default=sa.func.now(),
|
||||||
|
nullable=False,
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"updated_at",
|
||||||
|
sa.DateTime(timezone=True),
|
||||||
|
server_default=sa.func.now(),
|
||||||
|
nullable=False,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
op.create_index("ix_playlists_user_id", "playlists", ["user_id"])
|
||||||
|
|
||||||
|
op.create_table(
|
||||||
|
"playlist_items",
|
||||||
|
sa.Column("id", sa.Integer(), primary_key=True),
|
||||||
|
sa.Column(
|
||||||
|
"playlist_id",
|
||||||
|
sa.Integer(),
|
||||||
|
sa.ForeignKey("playlists.id", ondelete="CASCADE"),
|
||||||
|
nullable=False,
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"video_id",
|
||||||
|
sa.String(length=16),
|
||||||
|
sa.ForeignKey("videos.id", ondelete="CASCADE"),
|
||||||
|
nullable=False,
|
||||||
|
),
|
||||||
|
sa.Column("position", sa.Integer(), nullable=False, server_default="0"),
|
||||||
|
sa.Column(
|
||||||
|
"added_at",
|
||||||
|
sa.DateTime(timezone=True),
|
||||||
|
server_default=sa.func.now(),
|
||||||
|
nullable=False,
|
||||||
|
),
|
||||||
|
sa.UniqueConstraint("playlist_id", "video_id", name="uq_playlist_video"),
|
||||||
|
)
|
||||||
|
op.create_index("ix_playlist_items_playlist_id", "playlist_items", ["playlist_id"])
|
||||||
|
op.create_index("ix_playlist_items_video_id", "playlist_items", ["video_id"])
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_index("ix_playlist_items_video_id", table_name="playlist_items")
|
||||||
|
op.drop_index("ix_playlist_items_playlist_id", table_name="playlist_items")
|
||||||
|
op.drop_table("playlist_items")
|
||||||
|
op.drop_index("ix_playlists_user_id", table_name="playlists")
|
||||||
|
op.drop_table("playlists")
|
||||||
|
|
@ -26,7 +26,7 @@ from starlette.middleware.sessions import SessionMiddleware
|
||||||
|
|
||||||
from app import auth
|
from app import auth
|
||||||
from app.config import settings
|
from app.config import settings
|
||||||
from app.routes import admin, channels, feed, health, me, quota, sync, tags, version
|
from app.routes import admin, channels, feed, health, me, playlists, quota, sync, tags, version
|
||||||
from app.scheduler import shutdown_scheduler, start_scheduler
|
from app.scheduler import shutdown_scheduler, start_scheduler
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -66,6 +66,7 @@ app.include_router(tags.router)
|
||||||
app.include_router(feed.router)
|
app.include_router(feed.router)
|
||||||
app.include_router(me.router)
|
app.include_router(me.router)
|
||||||
app.include_router(channels.router)
|
app.include_router(channels.router)
|
||||||
|
app.include_router(playlists.router)
|
||||||
app.include_router(admin.router)
|
app.include_router(admin.router)
|
||||||
app.include_router(quota.router)
|
app.include_router(quota.router)
|
||||||
app.include_router(version.router)
|
app.include_router(version.router)
|
||||||
|
|
|
||||||
|
|
@ -301,3 +301,64 @@ class AppState(Base):
|
||||||
sync_paused: Mapped[bool] = mapped_column(
|
sync_paused: Mapped[bool] = mapped_column(
|
||||||
Boolean, default=False, server_default="false"
|
Boolean, default=False, server_default="false"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class Playlist(Base):
|
||||||
|
"""A per-user named, ordered collection of videos from the shared catalog.
|
||||||
|
|
||||||
|
`kind`: 'user' (normal) or 'watch_later' (built-in, undeletable, one per user).
|
||||||
|
`source`: 'local' (created here) or 'youtube' (mirrored from the user's YouTube
|
||||||
|
playlists). `yt_playlist_id` links a mirrored/exported playlist to its YouTube id.
|
||||||
|
`dirty` marks a YouTube-linked playlist with local edits not yet pushed, so the read
|
||||||
|
sync won't clobber them. (source/yt_playlist_id/dirty are forward-looking for the YT
|
||||||
|
sync phases; the foundation phase only uses local playlists.)"""
|
||||||
|
|
||||||
|
__tablename__ = "playlists"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(primary_key=True)
|
||||||
|
user_id: Mapped[int] = mapped_column(
|
||||||
|
ForeignKey("users.id", ondelete="CASCADE"), index=True
|
||||||
|
)
|
||||||
|
name: Mapped[str] = mapped_column(String(255))
|
||||||
|
kind: Mapped[str] = mapped_column(String(16), default="user", server_default="user")
|
||||||
|
source: Mapped[str] = mapped_column(
|
||||||
|
String(16), default="local", server_default="local"
|
||||||
|
)
|
||||||
|
yt_playlist_id: Mapped[str | None] = mapped_column(String(64))
|
||||||
|
dirty: Mapped[bool] = mapped_column(Boolean, default=False, server_default="false")
|
||||||
|
position: Mapped[int] = mapped_column(Integer, default=0, server_default="0")
|
||||||
|
created_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True), server_default=func.now()
|
||||||
|
)
|
||||||
|
updated_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
|
||||||
|
)
|
||||||
|
|
||||||
|
items: Mapped[list["PlaylistItem"]] = relationship(
|
||||||
|
back_populates="playlist",
|
||||||
|
cascade="all, delete-orphan",
|
||||||
|
order_by="PlaylistItem.position",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class PlaylistItem(Base):
|
||||||
|
"""A video's membership in a playlist, with its order within that playlist."""
|
||||||
|
|
||||||
|
__tablename__ = "playlist_items"
|
||||||
|
__table_args__ = (
|
||||||
|
UniqueConstraint("playlist_id", "video_id", name="uq_playlist_video"),
|
||||||
|
)
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(primary_key=True)
|
||||||
|
playlist_id: Mapped[int] = mapped_column(
|
||||||
|
ForeignKey("playlists.id", ondelete="CASCADE"), index=True
|
||||||
|
)
|
||||||
|
video_id: Mapped[str] = mapped_column(
|
||||||
|
ForeignKey("videos.id", ondelete="CASCADE"), index=True
|
||||||
|
)
|
||||||
|
position: Mapped[int] = mapped_column(Integer, default=0, server_default="0")
|
||||||
|
added_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True), server_default=func.now()
|
||||||
|
)
|
||||||
|
|
||||||
|
playlist: Mapped["Playlist"] = relationship(back_populates="items")
|
||||||
|
|
|
||||||
261
backend/app/routes/playlists.py
Normal file
261
backend/app/routes/playlists.py
Normal file
|
|
@ -0,0 +1,261 @@
|
||||||
|
"""Local playlists: per-user named, ordered collections of videos from the shared
|
||||||
|
catalog. Foundation phase = local only (create / rename / delete, add / remove / reorder
|
||||||
|
items). YouTube two-way sync (mirror existing playlists, push edits) lands in later phases."""
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
|
from sqlalchemy import func, select, and_
|
||||||
|
from sqlalchemy.orm import Session, aliased
|
||||||
|
|
||||||
|
from app.auth import current_user
|
||||||
|
from app.db import get_db
|
||||||
|
from app.models import Channel, Playlist, PlaylistItem, User, Video, VideoState
|
||||||
|
from app.routes.feed import _serialize
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/playlists", tags=["playlists"])
|
||||||
|
|
||||||
|
|
||||||
|
def _own_playlist(db: Session, user: User, playlist_id: int) -> Playlist:
|
||||||
|
pl = db.get(Playlist, playlist_id)
|
||||||
|
if pl is None or pl.user_id != user.id:
|
||||||
|
raise HTTPException(status_code=404, detail="Unknown playlist")
|
||||||
|
return pl
|
||||||
|
|
||||||
|
|
||||||
|
def _summary(db: Session, pl: Playlist, count: int, has_video: bool | None = None) -> dict:
|
||||||
|
cover = db.scalar(
|
||||||
|
select(Video.thumbnail_url)
|
||||||
|
.join(PlaylistItem, PlaylistItem.video_id == Video.id)
|
||||||
|
.where(PlaylistItem.playlist_id == pl.id)
|
||||||
|
.order_by(PlaylistItem.position)
|
||||||
|
.limit(1)
|
||||||
|
)
|
||||||
|
out = {
|
||||||
|
"id": pl.id,
|
||||||
|
"name": pl.name,
|
||||||
|
"kind": pl.kind,
|
||||||
|
"source": pl.source,
|
||||||
|
"yt_playlist_id": pl.yt_playlist_id,
|
||||||
|
"item_count": count,
|
||||||
|
"cover_thumbnail": cover,
|
||||||
|
}
|
||||||
|
if has_video is not None:
|
||||||
|
out["has_video"] = has_video
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("")
|
||||||
|
def list_playlists(
|
||||||
|
contains: str | None = None,
|
||||||
|
user: User = Depends(current_user),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
) -> list[dict]:
|
||||||
|
"""The user's playlists (newest custom order). With `contains=<video_id>`, each entry
|
||||||
|
also reports whether that video is already in it — used by the add-to-playlist popover."""
|
||||||
|
pls = (
|
||||||
|
db.execute(
|
||||||
|
select(Playlist)
|
||||||
|
.where(Playlist.user_id == user.id)
|
||||||
|
.order_by(Playlist.position, Playlist.created_at)
|
||||||
|
)
|
||||||
|
.scalars()
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
if not pls:
|
||||||
|
return []
|
||||||
|
ids = [p.id for p in pls]
|
||||||
|
counts = dict(
|
||||||
|
db.execute(
|
||||||
|
select(PlaylistItem.playlist_id, func.count())
|
||||||
|
.where(PlaylistItem.playlist_id.in_(ids))
|
||||||
|
.group_by(PlaylistItem.playlist_id)
|
||||||
|
).all()
|
||||||
|
)
|
||||||
|
member: set[int] = set()
|
||||||
|
if contains:
|
||||||
|
member = set(
|
||||||
|
db.execute(
|
||||||
|
select(PlaylistItem.playlist_id).where(
|
||||||
|
PlaylistItem.playlist_id.in_(ids),
|
||||||
|
PlaylistItem.video_id == contains,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.scalars()
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
return [
|
||||||
|
_summary(db, p, counts.get(p.id, 0), (p.id in member) if contains else None)
|
||||||
|
for p in pls
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("")
|
||||||
|
def create_playlist(
|
||||||
|
payload: dict,
|
||||||
|
user: User = Depends(current_user),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
) -> dict:
|
||||||
|
name = (payload.get("name") or "").strip()
|
||||||
|
if not name:
|
||||||
|
raise HTTPException(status_code=400, detail="name is required")
|
||||||
|
next_pos = (
|
||||||
|
db.scalar(
|
||||||
|
select(func.coalesce(func.max(Playlist.position), -1)).where(
|
||||||
|
Playlist.user_id == user.id
|
||||||
|
)
|
||||||
|
)
|
||||||
|
+ 1
|
||||||
|
)
|
||||||
|
pl = Playlist(user_id=user.id, name=name, position=next_pos)
|
||||||
|
db.add(pl)
|
||||||
|
db.commit()
|
||||||
|
return _summary(db, pl, 0)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{playlist_id}")
|
||||||
|
def get_playlist(
|
||||||
|
playlist_id: int,
|
||||||
|
user: User = Depends(current_user),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
) -> dict:
|
||||||
|
pl = _own_playlist(db, user, playlist_id)
|
||||||
|
state = aliased(VideoState)
|
||||||
|
rows = db.execute(
|
||||||
|
select(
|
||||||
|
Video.id,
|
||||||
|
Video.title,
|
||||||
|
Video.channel_id,
|
||||||
|
Channel.title.label("channel_title"),
|
||||||
|
Channel.thumbnail_url.label("channel_thumbnail"),
|
||||||
|
Channel.handle.label("channel_handle"),
|
||||||
|
Video.published_at,
|
||||||
|
Video.thumbnail_url,
|
||||||
|
Video.duration_seconds,
|
||||||
|
Video.view_count,
|
||||||
|
Video.is_short,
|
||||||
|
Video.live_status,
|
||||||
|
func.coalesce(state.status, "new").label("status"),
|
||||||
|
func.coalesce(state.position_seconds, 0).label("position_seconds"),
|
||||||
|
)
|
||||||
|
.join(PlaylistItem, PlaylistItem.video_id == Video.id)
|
||||||
|
.join(Channel, Channel.id == Video.channel_id)
|
||||||
|
.outerjoin(state, and_(state.video_id == Video.id, state.user_id == user.id))
|
||||||
|
.where(PlaylistItem.playlist_id == pl.id)
|
||||||
|
.order_by(PlaylistItem.position)
|
||||||
|
).all()
|
||||||
|
return {
|
||||||
|
"id": pl.id,
|
||||||
|
"name": pl.name,
|
||||||
|
"kind": pl.kind,
|
||||||
|
"source": pl.source,
|
||||||
|
"yt_playlist_id": pl.yt_playlist_id,
|
||||||
|
"items": [_serialize(r) for r in rows],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/{playlist_id}")
|
||||||
|
def rename_playlist(
|
||||||
|
playlist_id: int,
|
||||||
|
payload: dict,
|
||||||
|
user: User = Depends(current_user),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
) -> dict:
|
||||||
|
pl = _own_playlist(db, user, playlist_id)
|
||||||
|
if "name" in payload:
|
||||||
|
name = (payload.get("name") or "").strip()
|
||||||
|
if not name:
|
||||||
|
raise HTTPException(status_code=400, detail="name cannot be empty")
|
||||||
|
pl.name = name
|
||||||
|
db.commit()
|
||||||
|
count = db.scalar(
|
||||||
|
select(func.count()).where(PlaylistItem.playlist_id == pl.id)
|
||||||
|
)
|
||||||
|
return _summary(db, pl, count or 0)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{playlist_id}")
|
||||||
|
def delete_playlist(
|
||||||
|
playlist_id: int,
|
||||||
|
user: User = Depends(current_user),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
) -> dict:
|
||||||
|
pl = _own_playlist(db, user, playlist_id)
|
||||||
|
if pl.kind == "watch_later":
|
||||||
|
raise HTTPException(status_code=400, detail="The Watch later list can't be deleted.")
|
||||||
|
db.delete(pl) # items cascade
|
||||||
|
db.commit()
|
||||||
|
return {"deleted": playlist_id}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{playlist_id}/items")
|
||||||
|
def add_item(
|
||||||
|
playlist_id: int,
|
||||||
|
payload: dict,
|
||||||
|
user: User = Depends(current_user),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
) -> dict:
|
||||||
|
pl = _own_playlist(db, user, playlist_id)
|
||||||
|
video_id = payload.get("video_id")
|
||||||
|
if not video_id or db.get(Video, video_id) is None:
|
||||||
|
raise HTTPException(status_code=404, detail="Unknown video")
|
||||||
|
exists = db.scalar(
|
||||||
|
select(PlaylistItem).where(
|
||||||
|
PlaylistItem.playlist_id == pl.id, PlaylistItem.video_id == video_id
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if exists is None:
|
||||||
|
next_pos = (
|
||||||
|
db.scalar(
|
||||||
|
select(func.coalesce(func.max(PlaylistItem.position), -1)).where(
|
||||||
|
PlaylistItem.playlist_id == pl.id
|
||||||
|
)
|
||||||
|
)
|
||||||
|
+ 1
|
||||||
|
)
|
||||||
|
db.add(
|
||||||
|
PlaylistItem(playlist_id=pl.id, video_id=video_id, position=next_pos)
|
||||||
|
)
|
||||||
|
db.commit()
|
||||||
|
return {"playlist_id": pl.id, "video_id": video_id}
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{playlist_id}/items/{video_id}")
|
||||||
|
def remove_item(
|
||||||
|
playlist_id: int,
|
||||||
|
video_id: str,
|
||||||
|
user: User = Depends(current_user),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
) -> dict:
|
||||||
|
pl = _own_playlist(db, user, playlist_id)
|
||||||
|
item = db.scalar(
|
||||||
|
select(PlaylistItem).where(
|
||||||
|
PlaylistItem.playlist_id == pl.id, PlaylistItem.video_id == video_id
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if item is not None:
|
||||||
|
db.delete(item)
|
||||||
|
db.commit()
|
||||||
|
return {"playlist_id": pl.id, "video_id": video_id}
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/{playlist_id}/order")
|
||||||
|
def reorder_items(
|
||||||
|
playlist_id: int,
|
||||||
|
payload: dict,
|
||||||
|
user: User = Depends(current_user),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
) -> dict:
|
||||||
|
pl = _own_playlist(db, user, playlist_id)
|
||||||
|
order = payload.get("video_ids") or []
|
||||||
|
items = {
|
||||||
|
it.video_id: it
|
||||||
|
for it in db.execute(
|
||||||
|
select(PlaylistItem).where(PlaylistItem.playlist_id == pl.id)
|
||||||
|
).scalars()
|
||||||
|
}
|
||||||
|
pos = 0
|
||||||
|
for vid in order:
|
||||||
|
it = items.get(vid)
|
||||||
|
if it is not None:
|
||||||
|
it.position = pos
|
||||||
|
pos += 1
|
||||||
|
db.commit()
|
||||||
|
return {"playlist_id": pl.id, "count": pos}
|
||||||
|
|
@ -24,6 +24,7 @@ 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 Channels, { type ChannelStatusFilter } from "./components/Channels";
|
import Channels, { type ChannelStatusFilter } from "./components/Channels";
|
||||||
|
import Playlists from "./components/Playlists";
|
||||||
import Stats from "./components/Stats";
|
import Stats from "./components/Stats";
|
||||||
import SettingsPanel from "./components/SettingsPanel";
|
import SettingsPanel from "./components/SettingsPanel";
|
||||||
import OnboardingWizard from "./components/OnboardingWizard";
|
import OnboardingWizard from "./components/OnboardingWizard";
|
||||||
|
|
@ -47,6 +48,17 @@ const DEFAULT_FILTERS: FeedFilters = {
|
||||||
};
|
};
|
||||||
|
|
||||||
const FILTERS_KEY = "subfeed.filters";
|
const FILTERS_KEY = "subfeed.filters";
|
||||||
|
const PAGE_KEY = "siftlode.page";
|
||||||
|
|
||||||
|
// Page is navigation state, not a filter, but it's also kept out of the address bar — so
|
||||||
|
// persist it to localStorage to survive a reload. A share link's ?page= still wins.
|
||||||
|
function loadInitialPage(): Page {
|
||||||
|
const params = new URLSearchParams(window.location.search);
|
||||||
|
if (params.has("page")) return readPage();
|
||||||
|
const stored = localStorage.getItem(PAGE_KEY);
|
||||||
|
if (stored === "channels" || stored === "stats" || stored === "playlists") return stored;
|
||||||
|
return "feed";
|
||||||
|
}
|
||||||
|
|
||||||
function loadStoredFilters(): FeedFilters {
|
function loadStoredFilters(): FeedFilters {
|
||||||
try {
|
try {
|
||||||
|
|
@ -69,7 +81,7 @@ export default function App() {
|
||||||
const [filters, setFiltersState] = useState<FeedFilters>(loadInitialFilters);
|
const [filters, setFiltersState] = useState<FeedFilters>(loadInitialFilters);
|
||||||
const [view, setView] = useState<"grid" | "list">("grid");
|
const [view, setView] = useState<"grid" | "list">("grid");
|
||||||
const [sidebarLayout, setSidebarLayoutState] = useState<SidebarLayout>(loadLayout);
|
const [sidebarLayout, setSidebarLayoutState] = useState<SidebarLayout>(loadLayout);
|
||||||
const [page, setPageState] = useState<Page>(readPage);
|
const [page, setPageState] = useState<Page>(loadInitialPage);
|
||||||
const [settingsOpen, setSettingsOpen] = useState(false);
|
const [settingsOpen, setSettingsOpen] = useState(false);
|
||||||
const [wizardOpen, setWizardOpen] = useState(false);
|
const [wizardOpen, setWizardOpen] = useState(false);
|
||||||
const [channelFilter, setChannelFilter] = useState<ChannelStatusFilter>("all");
|
const [channelFilter, setChannelFilter] = useState<ChannelStatusFilter>("all");
|
||||||
|
|
@ -89,6 +101,7 @@ export default function App() {
|
||||||
|
|
||||||
function setPage(next: Page) {
|
function setPage(next: Page) {
|
||||||
setPageState(next);
|
setPageState(next);
|
||||||
|
localStorage.setItem(PAGE_KEY, next);
|
||||||
}
|
}
|
||||||
|
|
||||||
function setSidebarLayout(next: SidebarLayout) {
|
function setSidebarLayout(next: SidebarLayout) {
|
||||||
|
|
@ -218,6 +231,8 @@ export default function App() {
|
||||||
/>
|
/>
|
||||||
) : page === "stats" && meQuery.data!.role === "admin" ? (
|
) : page === "stats" && meQuery.data!.role === "admin" ? (
|
||||||
<Stats />
|
<Stats />
|
||||||
|
) : page === "playlists" ? (
|
||||||
|
<Playlists />
|
||||||
) : (
|
) : (
|
||||||
<Feed
|
<Feed
|
||||||
filters={filters}
|
filters={filters}
|
||||||
|
|
|
||||||
186
frontend/src/components/AddToPlaylist.tsx
Normal file
186
frontend/src/components/AddToPlaylist.tsx
Normal file
|
|
@ -0,0 +1,186 @@
|
||||||
|
import { useEffect, useLayoutEffect, useRef, useState } from "react";
|
||||||
|
import { createPortal } from "react-dom";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { Check, ListPlus, Plus } from "lucide-react";
|
||||||
|
import { api, type Playlist } from "../lib/api";
|
||||||
|
|
||||||
|
// 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.
|
||||||
|
export default function AddToPlaylist({
|
||||||
|
videoId,
|
||||||
|
className,
|
||||||
|
}: {
|
||||||
|
videoId: string;
|
||||||
|
className?: string;
|
||||||
|
}) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const qc = useQueryClient();
|
||||||
|
const triggerRef = useRef<HTMLButtonElement | null>(null);
|
||||||
|
const panelRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [coords, setCoords] = useState<{ top: number; left: number }>({ top: 0, left: 0 });
|
||||||
|
const [newName, setNewName] = useState("");
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
|
||||||
|
const membership = useQuery({
|
||||||
|
queryKey: ["playlist-membership", videoId],
|
||||||
|
queryFn: () => api.playlists(videoId),
|
||||||
|
enabled: open,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Re-place once the panel is actually rendered (so we know its real height) and whenever
|
||||||
|
// its content — hence height — changes, so the flip-above decision uses the true size.
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
if (open) place();
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [open, membership.data]);
|
||||||
|
|
||||||
|
function place() {
|
||||||
|
const r = triggerRef.current?.getBoundingClientRect();
|
||||||
|
if (!r) return;
|
||||||
|
const width = 240;
|
||||||
|
const margin = 8;
|
||||||
|
// Use the panel's real height once it's mounted; estimate on the first open.
|
||||||
|
const h = panelRef.current?.offsetHeight ?? 300;
|
||||||
|
let top = r.bottom + 6;
|
||||||
|
if (top + h > window.innerHeight - margin) {
|
||||||
|
// Not enough room below — open upward, clamped to the viewport.
|
||||||
|
top = Math.max(margin, r.top - h - 6);
|
||||||
|
}
|
||||||
|
const left = Math.min(Math.max(margin, r.left), window.innerWidth - width - margin);
|
||||||
|
setCoords({ top: Math.round(top), left: Math.round(left) });
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleOpen(e: React.MouseEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
if (!open) place();
|
||||||
|
setOpen((o) => !o);
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
function onDoc(e: MouseEvent) {
|
||||||
|
if (
|
||||||
|
!panelRef.current?.contains(e.target as Node) &&
|
||||||
|
!triggerRef.current?.contains(e.target as Node)
|
||||||
|
)
|
||||||
|
setOpen(false);
|
||||||
|
}
|
||||||
|
function onKey(e: KeyboardEvent) {
|
||||||
|
if (e.key === "Escape") setOpen(false);
|
||||||
|
}
|
||||||
|
document.addEventListener("mousedown", onDoc);
|
||||||
|
document.addEventListener("keydown", onKey);
|
||||||
|
window.addEventListener("resize", place);
|
||||||
|
window.addEventListener("scroll", place, true);
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener("mousedown", onDoc);
|
||||||
|
document.removeEventListener("keydown", onKey);
|
||||||
|
window.removeEventListener("resize", place);
|
||||||
|
window.removeEventListener("scroll", place, true);
|
||||||
|
};
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
function refresh() {
|
||||||
|
qc.invalidateQueries({ queryKey: ["playlist-membership", videoId] });
|
||||||
|
qc.invalidateQueries({ queryKey: ["playlists"] });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function toggle(pl: Playlist) {
|
||||||
|
if (busy) return;
|
||||||
|
setBusy(true);
|
||||||
|
try {
|
||||||
|
if (pl.has_video) await api.removeFromPlaylist(pl.id, videoId);
|
||||||
|
else await api.addToPlaylist(pl.id, videoId);
|
||||||
|
refresh();
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createAndAdd(e: React.FormEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
const name = newName.trim();
|
||||||
|
if (!name || busy) return;
|
||||||
|
setBusy(true);
|
||||||
|
try {
|
||||||
|
const pl = await api.createPlaylist(name);
|
||||||
|
await api.addToPlaylist(pl.id, videoId);
|
||||||
|
setNewName("");
|
||||||
|
refresh();
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const lists = membership.data ?? [];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
ref={triggerRef}
|
||||||
|
onClick={toggleOpen}
|
||||||
|
title={t("playlists.addToPlaylist")}
|
||||||
|
aria-label={t("playlists.addToPlaylist")}
|
||||||
|
className={className ?? "p-1.5 rounded-md hover:bg-surface text-muted hover:text-fg"}
|
||||||
|
>
|
||||||
|
<ListPlus className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{open &&
|
||||||
|
createPortal(
|
||||||
|
<div
|
||||||
|
ref={panelRef}
|
||||||
|
style={{ position: "fixed", top: coords.top, left: coords.left, width: 240 }}
|
||||||
|
className="z-50 glass rounded-xl p-2 shadow-2xl"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<div className="text-xs text-muted px-1.5 pb-1.5">
|
||||||
|
{t("playlists.addToPlaylist")}
|
||||||
|
</div>
|
||||||
|
<div className="max-h-56 overflow-y-auto">
|
||||||
|
{lists.length === 0 && !membership.isLoading && (
|
||||||
|
<div className="text-xs text-muted px-1.5 py-2">
|
||||||
|
{t("playlists.noneYet")}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{lists.map((pl) => (
|
||||||
|
<button
|
||||||
|
key={pl.id}
|
||||||
|
onClick={() => toggle(pl)}
|
||||||
|
disabled={busy}
|
||||||
|
className="w-full flex items-center gap-2 text-sm px-1.5 py-1.5 rounded-lg text-fg hover:bg-card/60 transition disabled:opacity-50"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={`grid place-items-center w-4 h-4 rounded border ${
|
||||||
|
pl.has_video
|
||||||
|
? "bg-accent border-accent text-accent-fg"
|
||||||
|
: "border-border"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{pl.has_video && <Check className="w-3 h-3" />}
|
||||||
|
</span>
|
||||||
|
<span className="truncate">{pl.name}</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<form
|
||||||
|
onSubmit={createAndAdd}
|
||||||
|
className="flex items-center gap-1.5 border-t border-border mt-1.5 pt-1.5"
|
||||||
|
>
|
||||||
|
<Plus className="w-3.5 h-3.5 text-muted shrink-0" />
|
||||||
|
<input
|
||||||
|
value={newName}
|
||||||
|
onChange={(e) => setNewName(e.target.value)}
|
||||||
|
placeholder={t("playlists.newPlaylist")}
|
||||||
|
className="flex-1 min-w-0 bg-card border border-border rounded-md px-2 py-1 text-xs outline-none focus:border-accent"
|
||||||
|
/>
|
||||||
|
</form>
|
||||||
|
</div>,
|
||||||
|
document.body
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -18,6 +18,7 @@ import { formatEta } from "../lib/format";
|
||||||
import { notify } from "../lib/notifications";
|
import { notify } from "../lib/notifications";
|
||||||
import Tooltip from "./Tooltip";
|
import Tooltip from "./Tooltip";
|
||||||
import Avatar from "./Avatar";
|
import Avatar from "./Avatar";
|
||||||
|
import { useConfirm } from "./ConfirmProvider";
|
||||||
|
|
||||||
export type ChannelStatusFilter = "all" | "needs_full" | "fully_synced" | "hidden";
|
export type ChannelStatusFilter = "all" | "needs_full" | "fully_synced" | "hidden";
|
||||||
|
|
||||||
|
|
@ -43,6 +44,7 @@ export default function Channels({
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const qc = useQueryClient();
|
const qc = useQueryClient();
|
||||||
|
const confirm = useConfirm();
|
||||||
|
|
||||||
// A YouTube-gated action (sync, backfill, unsubscribe) that 403s means the user hasn't
|
// A YouTube-gated action (sync, backfill, unsubscribe) that 403s means the user hasn't
|
||||||
// granted the needed scope — surface that with a "Connect" action instead of a vague fail.
|
// granted the needed scope — surface that with a "Connect" action instead of a vague fail.
|
||||||
|
|
@ -309,13 +311,14 @@ export default function Channels({
|
||||||
c={c}
|
c={c}
|
||||||
userTags={userTags}
|
userTags={userTags}
|
||||||
canWrite={canWrite}
|
canWrite={canWrite}
|
||||||
onUnsubscribe={() => {
|
onUnsubscribe={async () => {
|
||||||
if (
|
const ok = await confirm({
|
||||||
window.confirm(
|
title: t("channels.row.unsubscribeOnYoutube"),
|
||||||
t("channels.confirmUnsubscribe", { name: c.title ?? c.id })
|
message: t("channels.confirmUnsubscribe", { name: c.title ?? c.id }),
|
||||||
)
|
confirmLabel: t("channels.row.unsubscribeOnYoutube"),
|
||||||
)
|
danger: true,
|
||||||
unsubscribe.mutate(c.id);
|
});
|
||||||
|
if (ok) unsubscribe.mutate(c.id);
|
||||||
}}
|
}}
|
||||||
onView={() => onViewChannel(c.id, c.title ?? t("channels.row.thisChannel"))}
|
onView={() => onViewChannel(c.id, c.title ?? t("channels.row.thisChannel"))}
|
||||||
onPriority={(d) => bumpPriority(c.id, c.priority, d)}
|
onPriority={(d) => bumpPriority(c.id, c.priority, d)}
|
||||||
|
|
|
||||||
74
frontend/src/components/ConfirmProvider.tsx
Normal file
74
frontend/src/components/ConfirmProvider.tsx
Normal file
|
|
@ -0,0 +1,74 @@
|
||||||
|
import { createContext, useCallback, useContext, useState, type ReactNode } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import Modal from "./Modal";
|
||||||
|
|
||||||
|
// App-styled, promise-based replacement for window.confirm. Wrap the tree in
|
||||||
|
// <ConfirmProvider> once, then anywhere: const confirm = useConfirm();
|
||||||
|
// if (await confirm({ message: "…", danger: true })) { … }
|
||||||
|
export interface ConfirmOptions {
|
||||||
|
title?: string;
|
||||||
|
message: ReactNode;
|
||||||
|
confirmLabel?: string;
|
||||||
|
cancelLabel?: string;
|
||||||
|
danger?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
type ConfirmFn = (opts: ConfirmOptions) => Promise<boolean>;
|
||||||
|
|
||||||
|
const ConfirmContext = createContext<ConfirmFn>(() => Promise.resolve(false));
|
||||||
|
|
||||||
|
export function useConfirm(): ConfirmFn {
|
||||||
|
return useContext(ConfirmContext);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ConfirmProvider({ children }: { children: ReactNode }) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [state, setState] = useState<{
|
||||||
|
opts: ConfirmOptions;
|
||||||
|
resolve: (ok: boolean) => void;
|
||||||
|
} | null>(null);
|
||||||
|
|
||||||
|
const confirm = useCallback<ConfirmFn>(
|
||||||
|
(opts) => new Promise<boolean>((resolve) => setState({ opts, resolve })),
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
|
function close(ok: boolean) {
|
||||||
|
state?.resolve(ok);
|
||||||
|
setState(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ConfirmContext.Provider value={confirm}>
|
||||||
|
{children}
|
||||||
|
{state && (
|
||||||
|
<Modal
|
||||||
|
title={state.opts.title ?? t("common.confirmTitle")}
|
||||||
|
onClose={() => close(false)}
|
||||||
|
maxWidth="max-w-sm"
|
||||||
|
>
|
||||||
|
<p className="text-sm text-muted leading-relaxed">{state.opts.message}</p>
|
||||||
|
<div className="flex justify-end gap-2 mt-5">
|
||||||
|
<button
|
||||||
|
onClick={() => close(false)}
|
||||||
|
className="px-4 py-2 rounded-lg text-sm border border-border text-muted hover:text-fg hover:bg-surface transition"
|
||||||
|
>
|
||||||
|
{state.opts.cancelLabel ?? t("common.cancel")}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
autoFocus
|
||||||
|
onClick={() => close(true)}
|
||||||
|
className={`px-4 py-2 rounded-lg text-sm font-semibold transition ${
|
||||||
|
state.opts.danger
|
||||||
|
? "bg-red-500 text-white hover:bg-red-600"
|
||||||
|
: "bg-accent text-accent-fg hover:opacity-90"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{state.opts.confirmLabel ?? t("common.confirm")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
)}
|
||||||
|
</ConfirmContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import { useRef, useState } from "react";
|
import { useRef, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { BarChart3, Home, Info, Library, LogOut, Search, Settings, Shield, Tv, User } from "lucide-react";
|
import { BarChart3, Home, Info, Library, ListVideo, LogOut, Search, Settings, Shield, Tv, User } from "lucide-react";
|
||||||
import type { FeedFilters, Me } from "../lib/api";
|
import type { FeedFilters, Me } from "../lib/api";
|
||||||
import type { Page } from "../lib/urlState";
|
import type { Page } from "../lib/urlState";
|
||||||
import { type LangCode } from "../i18n";
|
import { type LangCode } from "../i18n";
|
||||||
|
|
@ -90,7 +90,11 @@ export default function Header({
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex-1 text-center text-sm font-semibold">
|
<div className="flex-1 text-center text-sm font-semibold">
|
||||||
{page === "stats" ? t("header.usageStats") : t("header.channelManager")}
|
{page === "stats"
|
||||||
|
? t("header.usageStats")
|
||||||
|
: page === "playlists"
|
||||||
|
? t("header.account.playlists")
|
||||||
|
: t("header.channelManager")}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
@ -194,6 +198,12 @@ function AccountMenu({
|
||||||
{t("header.account.channels")}
|
{t("header.account.channels")}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
{page !== "playlists" && (
|
||||||
|
<button onClick={() => { setPage("playlists"); setOpen(false); }} className={itemClass}>
|
||||||
|
<ListVideo className="w-4 h-4" />
|
||||||
|
{t("header.account.playlists")}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
{me.role === "admin" && page !== "stats" && (
|
{me.role === "admin" && page !== "stats" && (
|
||||||
<button onClick={() => { setPage("stats"); setOpen(false); }} className={itemClass}>
|
<button onClick={() => { setPage("stats"); setOpen(false); }} className={itemClass}>
|
||||||
<BarChart3 className="w-4 h-4" />
|
<BarChart3 className="w-4 h-4" />
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ import { createPortal } from "react-dom";
|
||||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import { ArrowLeft, Check, CheckCheck, X } from "lucide-react";
|
import { ArrowLeft, Check, CheckCheck, X } from "lucide-react";
|
||||||
import Avatar from "./Avatar";
|
import Avatar from "./Avatar";
|
||||||
|
import AddToPlaylist from "./AddToPlaylist";
|
||||||
import { api, type Video } from "../lib/api";
|
import { api, type Video } from "../lib/api";
|
||||||
import { formatDuration, formatViews, relativeTime } from "../lib/format";
|
import { formatDuration, formatViews, relativeTime } from "../lib/format";
|
||||||
|
|
||||||
|
|
@ -518,12 +519,18 @@ export default function PlayerModal({
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{!navigated && (
|
||||||
|
<AddToPlaylist
|
||||||
|
videoId={video.id}
|
||||||
|
className="ml-auto shrink-0 inline-flex items-center justify-center w-9 h-9 rounded-lg text-muted hover:text-fg hover:bg-surface border border-border transition"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
{!navigated && (
|
{!navigated && (
|
||||||
<button
|
<button
|
||||||
onClick={() => setWatched(!watched)}
|
onClick={() => setWatched(!watched)}
|
||||||
title={watched ? t("player.watchedUnmark") : t("player.markWatched")}
|
title={watched ? t("player.watchedUnmark") : t("player.markWatched")}
|
||||||
className={
|
className={
|
||||||
"ml-auto shrink-0 inline-flex items-center gap-1.5 text-sm px-3 py-1.5 rounded-lg transition " +
|
"shrink-0 inline-flex items-center gap-1.5 text-sm px-3 py-1.5 rounded-lg transition " +
|
||||||
(watched
|
(watched
|
||||||
? "bg-accent text-accent-fg shadow-sm hover:opacity-90"
|
? "bg-accent text-accent-fg shadow-sm hover:opacity-90"
|
||||||
: "text-muted hover:text-fg hover:bg-surface border border-border")
|
: "text-muted hover:text-fg hover:bg-surface border border-border")
|
||||||
|
|
|
||||||
369
frontend/src/components/Playlists.tsx
Normal file
369
frontend/src/components/Playlists.tsx
Normal file
|
|
@ -0,0 +1,369 @@
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import {
|
||||||
|
DndContext,
|
||||||
|
PointerSensor,
|
||||||
|
closestCenter,
|
||||||
|
useSensor,
|
||||||
|
useSensors,
|
||||||
|
type DragEndEvent,
|
||||||
|
} from "@dnd-kit/core";
|
||||||
|
import {
|
||||||
|
SortableContext,
|
||||||
|
arrayMove,
|
||||||
|
useSortable,
|
||||||
|
verticalListSortingStrategy,
|
||||||
|
} from "@dnd-kit/sortable";
|
||||||
|
import { CSS } from "@dnd-kit/utilities";
|
||||||
|
import {
|
||||||
|
Check,
|
||||||
|
GripVertical,
|
||||||
|
ListPlus,
|
||||||
|
Pencil,
|
||||||
|
Play,
|
||||||
|
Plus,
|
||||||
|
Trash2,
|
||||||
|
X,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { api, type Playlist, type Video } from "../lib/api";
|
||||||
|
import { formatDuration } from "../lib/format";
|
||||||
|
import PlayerModal from "./PlayerModal";
|
||||||
|
import { useConfirm } from "./ConfirmProvider";
|
||||||
|
|
||||||
|
function Row({
|
||||||
|
video,
|
||||||
|
index,
|
||||||
|
onPlay,
|
||||||
|
onRemove,
|
||||||
|
}: {
|
||||||
|
video: Video;
|
||||||
|
index: number;
|
||||||
|
onPlay: () => void;
|
||||||
|
onRemove: () => void;
|
||||||
|
}) {
|
||||||
|
const { attributes, listeners, setNodeRef, transform, transition, isDragging } =
|
||||||
|
useSortable({ id: video.id });
|
||||||
|
const style = {
|
||||||
|
transform: CSS.Transform.toString(transform),
|
||||||
|
transition,
|
||||||
|
opacity: isDragging ? 0.6 : 1,
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={setNodeRef}
|
||||||
|
style={style}
|
||||||
|
className="flex items-center gap-2.5 p-2 rounded-lg border border-border bg-card"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
{...attributes}
|
||||||
|
{...listeners}
|
||||||
|
className="text-muted hover:text-fg cursor-grab active:cursor-grabbing"
|
||||||
|
aria-label="reorder"
|
||||||
|
>
|
||||||
|
<GripVertical className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
<span className="text-xs text-muted w-4 text-center tabular-nums">{index + 1}</span>
|
||||||
|
<button
|
||||||
|
onClick={onPlay}
|
||||||
|
className="relative w-[62px] h-[35px] rounded overflow-hidden bg-surface shrink-0 group"
|
||||||
|
>
|
||||||
|
{video.thumbnail_url && (
|
||||||
|
<img src={video.thumbnail_url} alt="" className="w-full h-full object-cover" />
|
||||||
|
)}
|
||||||
|
<span className="absolute inset-0 grid place-items-center bg-black/40 opacity-0 group-hover:opacity-100 transition">
|
||||||
|
<Play className="w-4 h-4 text-white fill-current" />
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
<button onClick={onPlay} className="min-w-0 flex-1 text-left">
|
||||||
|
<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 && (
|
||||||
|
<span className="text-[11px] text-muted tabular-nums">
|
||||||
|
{formatDuration(video.duration_seconds)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
onClick={onRemove}
|
||||||
|
title="remove"
|
||||||
|
className="text-muted hover:text-fg p-1"
|
||||||
|
aria-label="remove from playlist"
|
||||||
|
>
|
||||||
|
<X className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Playlists() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const qc = useQueryClient();
|
||||||
|
const confirm = useConfirm();
|
||||||
|
const [selectedId, setSelectedId] = useState<number | null>(null);
|
||||||
|
const [newName, setNewName] = useState("");
|
||||||
|
const [renaming, setRenaming] = useState(false);
|
||||||
|
const [renameValue, setRenameValue] = useState("");
|
||||||
|
const [items, setItems] = useState<Video[]>([]);
|
||||||
|
const [active, setActive] = useState<{ video: Video; startAt: number | null } | null>(null);
|
||||||
|
|
||||||
|
const listQuery = useQuery({ queryKey: ["playlists"], queryFn: () => api.playlists() });
|
||||||
|
const playlists = listQuery.data ?? [];
|
||||||
|
|
||||||
|
// Keep the selection valid: default to the first playlist, and if the selected one
|
||||||
|
// disappears (e.g. just deleted) drop to the next available, or clear when none remain.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!playlists.length) {
|
||||||
|
if (selectedId != null) setSelectedId(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (selectedId == null || !playlists.some((p) => p.id === selectedId)) {
|
||||||
|
setSelectedId(playlists[0].id);
|
||||||
|
}
|
||||||
|
}, [playlists, selectedId]);
|
||||||
|
|
||||||
|
const detailQuery = useQuery({
|
||||||
|
queryKey: ["playlist", selectedId],
|
||||||
|
queryFn: () => api.playlist(selectedId as number),
|
||||||
|
enabled: selectedId != null,
|
||||||
|
});
|
||||||
|
const detail = detailQuery.data;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (detail) setItems(detail.items);
|
||||||
|
}, [detail]);
|
||||||
|
|
||||||
|
const sensors = useSensors(
|
||||||
|
useSensor(PointerSensor, { activationConstraint: { distance: 4 } })
|
||||||
|
);
|
||||||
|
|
||||||
|
const createMut = useMutation({
|
||||||
|
mutationFn: (name: string) => api.createPlaylist(name),
|
||||||
|
onSuccess: (pl: Playlist) => {
|
||||||
|
setNewName("");
|
||||||
|
qc.invalidateQueries({ queryKey: ["playlists"] });
|
||||||
|
setSelectedId(pl.id);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
function refreshAll() {
|
||||||
|
qc.invalidateQueries({ queryKey: ["playlists"] });
|
||||||
|
qc.invalidateQueries({ queryKey: ["playlist", selectedId] });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onDragEnd(e: DragEndEvent) {
|
||||||
|
const { active: a, over } = e;
|
||||||
|
if (!over || a.id === over.id || selectedId == null) return;
|
||||||
|
const oldIndex = items.findIndex((v) => v.id === a.id);
|
||||||
|
const newIndex = items.findIndex((v) => v.id === over.id);
|
||||||
|
if (oldIndex < 0 || newIndex < 0) return;
|
||||||
|
const next = arrayMove(items, oldIndex, newIndex);
|
||||||
|
setItems(next);
|
||||||
|
await api.reorderPlaylist(selectedId, next.map((v) => v.id));
|
||||||
|
qc.invalidateQueries({ queryKey: ["playlist", selectedId] });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function removeItem(videoId: string) {
|
||||||
|
if (selectedId == null) return;
|
||||||
|
setItems((cur) => cur.filter((v) => v.id !== videoId));
|
||||||
|
await api.removeFromPlaylist(selectedId, videoId);
|
||||||
|
refreshAll();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveRename() {
|
||||||
|
if (selectedId == null) return;
|
||||||
|
const name = renameValue.trim();
|
||||||
|
setRenaming(false);
|
||||||
|
if (name && name !== detail?.name) {
|
||||||
|
await api.renamePlaylist(selectedId, name);
|
||||||
|
refreshAll();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deletePlaylist() {
|
||||||
|
if (selectedId == null || !detail) return;
|
||||||
|
const ok = await confirm({
|
||||||
|
title: t("playlists.delete"),
|
||||||
|
message: t("playlists.confirmDelete", { name: detail.name }),
|
||||||
|
confirmLabel: t("playlists.delete"),
|
||||||
|
danger: true,
|
||||||
|
});
|
||||||
|
if (!ok) return;
|
||||||
|
const deletedId = selectedId;
|
||||||
|
// Pick the next selection from what's left *now* (don't go via null, or the
|
||||||
|
// auto-select effect would re-pick the just-deleted id from the stale list).
|
||||||
|
const remaining = playlists.filter((p) => p.id !== deletedId);
|
||||||
|
setSelectedId(remaining[0]?.id ?? null);
|
||||||
|
await api.deletePlaylist(deletedId);
|
||||||
|
qc.removeQueries({ queryKey: ["playlist", deletedId] });
|
||||||
|
qc.invalidateQueries({ queryKey: ["playlists"] });
|
||||||
|
}
|
||||||
|
|
||||||
|
function playerState(id: string, status: string) {
|
||||||
|
api.setState(id, status).then(() => {
|
||||||
|
qc.invalidateQueries({ queryKey: ["playlist", selectedId] });
|
||||||
|
qc.invalidateQueries({ queryKey: ["feed"] });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-full min-h-0">
|
||||||
|
<aside className="w-64 shrink-0 border-r border-border bg-surface/40 overflow-y-auto p-3">
|
||||||
|
<div className="text-sm font-semibold mb-2">{t("playlists.title")}</div>
|
||||||
|
{playlists.length === 0 && !listQuery.isLoading && (
|
||||||
|
<div className="text-xs text-muted px-1 py-2">{t("playlists.noneYet")}</div>
|
||||||
|
)}
|
||||||
|
<div className="space-y-1">
|
||||||
|
{playlists.map((pl) => (
|
||||||
|
<button
|
||||||
|
key={pl.id}
|
||||||
|
onClick={() => setSelectedId(pl.id)}
|
||||||
|
className={`w-full flex items-center gap-2.5 p-2 rounded-lg border transition text-left ${
|
||||||
|
pl.id === selectedId
|
||||||
|
? "bg-accent/15 border-accent"
|
||||||
|
: "border-transparent hover:bg-card/60"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="w-[34px] h-[34px] rounded-md bg-card overflow-hidden shrink-0">
|
||||||
|
{pl.cover_thumbnail && (
|
||||||
|
<img src={pl.cover_thumbnail} alt="" className="w-full h-full object-cover" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="text-[13px] text-fg truncate">{pl.name}</div>
|
||||||
|
<div className="text-[11px] text-muted">
|
||||||
|
{t("playlists.itemCount", { count: pl.item_count })}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<form
|
||||||
|
onSubmit={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (newName.trim()) createMut.mutate(newName.trim());
|
||||||
|
}}
|
||||||
|
className="flex items-center gap-1.5 mt-3 pt-3 border-t border-border"
|
||||||
|
>
|
||||||
|
<Plus className="w-4 h-4 text-muted shrink-0" />
|
||||||
|
<input
|
||||||
|
value={newName}
|
||||||
|
onChange={(e) => setNewName(e.target.value)}
|
||||||
|
placeholder={t("playlists.newPlaylist")}
|
||||||
|
className="flex-1 min-w-0 bg-card border border-border rounded-md px-2 py-1 text-xs outline-none focus:border-accent"
|
||||||
|
/>
|
||||||
|
</form>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<main className="flex-1 min-w-0 overflow-y-auto p-4">
|
||||||
|
{selectedId == null || !detail ? (
|
||||||
|
<div className="text-muted text-sm p-4">
|
||||||
|
{selectedId == null ? t("playlists.pickOne") : t("playlists.loading")}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="flex gap-3 items-start mb-4">
|
||||||
|
<div className="w-24 h-[54px] rounded-lg bg-card overflow-hidden shrink-0">
|
||||||
|
{items[0]?.thumbnail_url && (
|
||||||
|
<img
|
||||||
|
src={items[0].thumbnail_url}
|
||||||
|
alt=""
|
||||||
|
className="w-full h-full object-cover"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
{renaming ? (
|
||||||
|
<input
|
||||||
|
autoFocus
|
||||||
|
value={renameValue}
|
||||||
|
onChange={(e) => setRenameValue(e.target.value)}
|
||||||
|
onBlur={saveRename}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter") saveRename();
|
||||||
|
if (e.key === "Escape") setRenaming(false);
|
||||||
|
}}
|
||||||
|
className="text-lg font-semibold bg-card border border-border rounded-md px-2 py-0.5 outline-none focus:border-accent"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<h2 className="text-lg font-semibold truncate">{detail.name}</h2>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setRenameValue(detail.name);
|
||||||
|
setRenaming(true);
|
||||||
|
}}
|
||||||
|
title={t("playlists.rename")}
|
||||||
|
className="text-muted hover:text-fg"
|
||||||
|
>
|
||||||
|
<Pencil className="w-3.5 h-3.5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="text-xs text-muted mt-0.5">
|
||||||
|
{t("playlists.itemCount", { count: items.length })}
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2 mt-3 flex-wrap">
|
||||||
|
<button
|
||||||
|
onClick={() => items[0] && setActive({ video: items[0], startAt: null })}
|
||||||
|
disabled={!items.length}
|
||||||
|
className="inline-flex items-center gap-1.5 text-xs font-semibold px-3 py-1.5 rounded-lg bg-accent text-accent-fg disabled:opacity-40 hover:opacity-90 transition"
|
||||||
|
>
|
||||||
|
<Play className="w-3.5 h-3.5 fill-current" /> {t("playlists.playAll")}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={deletePlaylist}
|
||||||
|
className="inline-flex items-center gap-1.5 text-xs px-3 py-1.5 rounded-lg border border-border text-muted hover:text-fg hover:border-accent transition"
|
||||||
|
>
|
||||||
|
<Trash2 className="w-3.5 h-3.5" /> {t("playlists.delete")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{items.length === 0 ? (
|
||||||
|
<div className="text-sm text-muted grid place-items-center text-center py-12">
|
||||||
|
<div>
|
||||||
|
<ListPlus className="w-6 h-6 mx-auto mb-2 opacity-60" />
|
||||||
|
{t("playlists.empty")}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<DndContext
|
||||||
|
sensors={sensors}
|
||||||
|
collisionDetection={closestCenter}
|
||||||
|
onDragEnd={onDragEnd}
|
||||||
|
>
|
||||||
|
<SortableContext
|
||||||
|
items={items.map((v) => v.id)}
|
||||||
|
strategy={verticalListSortingStrategy}
|
||||||
|
>
|
||||||
|
<div className="space-y-1.5 max-w-3xl">
|
||||||
|
{items.map((v, i) => (
|
||||||
|
<Row
|
||||||
|
key={v.id}
|
||||||
|
video={v}
|
||||||
|
index={i}
|
||||||
|
onPlay={() => setActive({ video: v, startAt: null })}
|
||||||
|
onRemove={() => removeItem(v.id)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</SortableContext>
|
||||||
|
</DndContext>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</main>
|
||||||
|
|
||||||
|
{active && (
|
||||||
|
<PlayerModal
|
||||||
|
video={active.video}
|
||||||
|
startAt={active.startAt}
|
||||||
|
onClose={() => setActive(null)}
|
||||||
|
onState={playerState}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -11,6 +11,7 @@ import {
|
||||||
RotateCcw,
|
RotateCcw,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import Avatar from "./Avatar";
|
import Avatar from "./Avatar";
|
||||||
|
import AddToPlaylist from "./AddToPlaylist";
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
import type { Video } from "../lib/api";
|
import type { Video } from "../lib/api";
|
||||||
import { formatDuration, formatViews, relativeTime } from "../lib/format";
|
import { formatDuration, formatViews, relativeTime } from "../lib/format";
|
||||||
|
|
@ -56,6 +57,7 @@ function Actions({
|
||||||
>
|
>
|
||||||
<Bookmark className="w-4 h-4" />
|
<Bookmark className="w-4 h-4" />
|
||||||
</button>
|
</button>
|
||||||
|
<AddToPlaylist videoId={video.id} />
|
||||||
<button
|
<button
|
||||||
onClick={act("hidden")}
|
onClick={act("hidden")}
|
||||||
title={video.status === "hidden" ? t("card.unhide") : t("card.hide")}
|
title={video.status === "hidden" ? t("card.unhide") : t("card.hide")}
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,8 @@
|
||||||
"termsOfService": "Nutzungsbedingungen",
|
"termsOfService": "Nutzungsbedingungen",
|
||||||
"close": "Schließen",
|
"close": "Schließen",
|
||||||
"cancel": "Abbrechen",
|
"cancel": "Abbrechen",
|
||||||
|
"confirm": "Bestätigen",
|
||||||
|
"confirmTitle": "Bist du sicher?",
|
||||||
"save": "Speichern",
|
"save": "Speichern",
|
||||||
"loading": "Wird geladen…",
|
"loading": "Wird geladen…",
|
||||||
"language": "Sprache",
|
"language": "Sprache",
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,7 @@
|
||||||
"admin": "Admin",
|
"admin": "Admin",
|
||||||
"feed": "Feed",
|
"feed": "Feed",
|
||||||
"channels": "Kanäle",
|
"channels": "Kanäle",
|
||||||
|
"playlists": "Wiedergabelisten",
|
||||||
"stats": "Statistik",
|
"stats": "Statistik",
|
||||||
"settings": "Einstellungen",
|
"settings": "Einstellungen",
|
||||||
"about": "Über",
|
"about": "Über",
|
||||||
|
|
|
||||||
15
frontend/src/i18n/locales/de/playlists.json
Normal file
15
frontend/src/i18n/locales/de/playlists.json
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
{
|
||||||
|
"title": "Wiedergabelisten",
|
||||||
|
"noneYet": "Noch keine Wiedergabelisten",
|
||||||
|
"newPlaylist": "Neue Liste…",
|
||||||
|
"itemCount_one": "{{count}} Video",
|
||||||
|
"itemCount_other": "{{count}} Videos",
|
||||||
|
"pickOne": "Wähle links eine Liste.",
|
||||||
|
"loading": "Wird geladen…",
|
||||||
|
"empty": "Diese Liste ist leer — füge Videos aus dem Feed hinzu.",
|
||||||
|
"playAll": "Alle abspielen",
|
||||||
|
"delete": "Löschen",
|
||||||
|
"rename": "Umbenennen",
|
||||||
|
"confirmDelete": "Die Wiedergabeliste „{{name}}“ löschen? Kann nicht rückgängig gemacht werden.",
|
||||||
|
"addToPlaylist": "Zur Wiedergabeliste hinzufügen"
|
||||||
|
}
|
||||||
|
|
@ -3,6 +3,8 @@
|
||||||
"termsOfService": "Terms of Service",
|
"termsOfService": "Terms of Service",
|
||||||
"close": "Close",
|
"close": "Close",
|
||||||
"cancel": "Cancel",
|
"cancel": "Cancel",
|
||||||
|
"confirm": "Confirm",
|
||||||
|
"confirmTitle": "Are you sure?",
|
||||||
"save": "Save",
|
"save": "Save",
|
||||||
"loading": "Loading…",
|
"loading": "Loading…",
|
||||||
"language": "Language",
|
"language": "Language",
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,7 @@
|
||||||
"admin": "Admin",
|
"admin": "Admin",
|
||||||
"feed": "Feed",
|
"feed": "Feed",
|
||||||
"channels": "Channels",
|
"channels": "Channels",
|
||||||
|
"playlists": "Playlists",
|
||||||
"stats": "Stats",
|
"stats": "Stats",
|
||||||
"settings": "Settings",
|
"settings": "Settings",
|
||||||
"about": "About",
|
"about": "About",
|
||||||
|
|
|
||||||
15
frontend/src/i18n/locales/en/playlists.json
Normal file
15
frontend/src/i18n/locales/en/playlists.json
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
{
|
||||||
|
"title": "Playlists",
|
||||||
|
"noneYet": "No playlists yet",
|
||||||
|
"newPlaylist": "New playlist…",
|
||||||
|
"itemCount_one": "{{count}} video",
|
||||||
|
"itemCount_other": "{{count}} videos",
|
||||||
|
"pickOne": "Pick a playlist on the left.",
|
||||||
|
"loading": "Loading…",
|
||||||
|
"empty": "This playlist is empty — add videos from the feed.",
|
||||||
|
"playAll": "Play all",
|
||||||
|
"delete": "Delete",
|
||||||
|
"rename": "Rename",
|
||||||
|
"confirmDelete": "Delete the playlist “{{name}}”? This can't be undone.",
|
||||||
|
"addToPlaylist": "Add to playlist"
|
||||||
|
}
|
||||||
|
|
@ -3,6 +3,8 @@
|
||||||
"termsOfService": "Felhasználási feltételek",
|
"termsOfService": "Felhasználási feltételek",
|
||||||
"close": "Bezárás",
|
"close": "Bezárás",
|
||||||
"cancel": "Mégse",
|
"cancel": "Mégse",
|
||||||
|
"confirm": "Megerősítés",
|
||||||
|
"confirmTitle": "Biztos vagy benne?",
|
||||||
"save": "Mentés",
|
"save": "Mentés",
|
||||||
"loading": "Betöltés…",
|
"loading": "Betöltés…",
|
||||||
"language": "Nyelv",
|
"language": "Nyelv",
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,7 @@
|
||||||
"admin": "Adminisztrátor",
|
"admin": "Adminisztrátor",
|
||||||
"feed": "Hírfolyam",
|
"feed": "Hírfolyam",
|
||||||
"channels": "Csatornák",
|
"channels": "Csatornák",
|
||||||
|
"playlists": "Lejátszási listák",
|
||||||
"stats": "Statisztika",
|
"stats": "Statisztika",
|
||||||
"settings": "Beállítások",
|
"settings": "Beállítások",
|
||||||
"about": "Névjegy",
|
"about": "Névjegy",
|
||||||
|
|
|
||||||
15
frontend/src/i18n/locales/hu/playlists.json
Normal file
15
frontend/src/i18n/locales/hu/playlists.json
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
{
|
||||||
|
"title": "Lejátszási listák",
|
||||||
|
"noneYet": "Még nincs lejátszási lista",
|
||||||
|
"newPlaylist": "Új lista…",
|
||||||
|
"itemCount_one": "{{count}} videó",
|
||||||
|
"itemCount_other": "{{count}} videó",
|
||||||
|
"pickOne": "Válassz egy listát a bal oldalon.",
|
||||||
|
"loading": "Betöltés…",
|
||||||
|
"empty": "Ez a lista üres — adj hozzá videókat a hírfolyamból.",
|
||||||
|
"playAll": "Összes lejátszása",
|
||||||
|
"delete": "Törlés",
|
||||||
|
"rename": "Átnevezés",
|
||||||
|
"confirmDelete": "Törlöd a(z) „{{name}}” listát? Nem vonható vissza.",
|
||||||
|
"addToPlaylist": "Hozzáadás listához"
|
||||||
|
}
|
||||||
|
|
@ -68,6 +68,26 @@ export interface FeedResponse {
|
||||||
limit: number;
|
limit: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface Playlist {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
kind: string; // "user" | "watch_later"
|
||||||
|
source: string; // "local" | "youtube"
|
||||||
|
yt_playlist_id: string | null;
|
||||||
|
item_count: number;
|
||||||
|
cover_thumbnail: string | null;
|
||||||
|
has_video?: boolean; // only set when listed with ?contains=<video_id>
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PlaylistDetail {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
kind: string;
|
||||||
|
source: string;
|
||||||
|
yt_playlist_id: string | null;
|
||||||
|
items: Video[];
|
||||||
|
}
|
||||||
|
|
||||||
export interface FeedFilters {
|
export interface FeedFilters {
|
||||||
tags: number[];
|
tags: number[];
|
||||||
tagMode: "or" | "and";
|
tagMode: "or" | "and";
|
||||||
|
|
@ -294,6 +314,28 @@ export const api = {
|
||||||
req(`/api/channels/${id}/subscription`, { method: "DELETE" }),
|
req(`/api/channels/${id}/subscription`, { method: "DELETE" }),
|
||||||
syncSubscriptions: () => req("/api/sync/subscriptions", { method: "POST" }),
|
syncSubscriptions: () => req("/api/sync/subscriptions", { method: "POST" }),
|
||||||
|
|
||||||
|
// --- playlists ---
|
||||||
|
playlists: (containsVideoId?: string): Promise<Playlist[]> =>
|
||||||
|
req(`/api/playlists${containsVideoId ? `?contains=${containsVideoId}` : ""}`),
|
||||||
|
playlist: (id: number): Promise<PlaylistDetail> => req(`/api/playlists/${id}`),
|
||||||
|
createPlaylist: (name: string): Promise<Playlist> =>
|
||||||
|
req("/api/playlists", { method: "POST", body: JSON.stringify({ name }) }),
|
||||||
|
renamePlaylist: (id: number, name: string): Promise<Playlist> =>
|
||||||
|
req(`/api/playlists/${id}`, { method: "PATCH", body: JSON.stringify({ name }) }),
|
||||||
|
deletePlaylist: (id: number) => req(`/api/playlists/${id}`, { method: "DELETE" }),
|
||||||
|
addToPlaylist: (id: number, videoId: string) =>
|
||||||
|
req(`/api/playlists/${id}/items`, {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({ video_id: videoId }),
|
||||||
|
}),
|
||||||
|
removeFromPlaylist: (id: number, videoId: string) =>
|
||||||
|
req(`/api/playlists/${id}/items/${videoId}`, { method: "DELETE" }),
|
||||||
|
reorderPlaylist: (id: number, videoIds: string[]) =>
|
||||||
|
req(`/api/playlists/${id}/order`, {
|
||||||
|
method: "PUT",
|
||||||
|
body: JSON.stringify({ video_ids: videoIds }),
|
||||||
|
}),
|
||||||
|
|
||||||
// --- quota usage ---
|
// --- quota usage ---
|
||||||
myUsage: (): Promise<MyUsage> => req("/api/quota/my-usage"),
|
myUsage: (): Promise<MyUsage> => req("/api/quota/my-usage"),
|
||||||
adminQuota: (days = 30): Promise<AdminQuota> => req(`/api/quota/admin?days=${days}`),
|
adminQuota: (days = 30): Promise<AdminQuota> => req(`/api/quota/admin?days=${days}`),
|
||||||
|
|
|
||||||
|
|
@ -78,11 +78,11 @@ export function hasFilterParams(params: URLSearchParams): boolean {
|
||||||
return KEYS.some((k) => params.has(k));
|
return KEYS.some((k) => params.has(k));
|
||||||
}
|
}
|
||||||
|
|
||||||
export type Page = "feed" | "channels" | "stats";
|
export type Page = "feed" | "channels" | "stats" | "playlists";
|
||||||
|
|
||||||
export function readPage(): Page {
|
export function readPage(): Page {
|
||||||
const p = new URLSearchParams(window.location.search).get("page");
|
const p = new URLSearchParams(window.location.search).get("page");
|
||||||
return p === "channels" || p === "stats" ? p : "feed";
|
return p === "channels" || p === "stats" || p === "playlists" ? p : "feed";
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Build a shareable absolute URL that reproduces the current filters (+ page). Used by the
|
/** Build a shareable absolute URL that reproduces the current filters (+ page). Used by the
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ import { createRoot } from "react-dom/client";
|
||||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||||
import App from "./App";
|
import App from "./App";
|
||||||
import ErrorBoundary from "./components/ErrorBoundary";
|
import ErrorBoundary from "./components/ErrorBoundary";
|
||||||
|
import { ConfirmProvider } from "./components/ConfirmProvider";
|
||||||
import PrivacyPolicy from "./components/legal/PrivacyPolicy";
|
import PrivacyPolicy from "./components/legal/PrivacyPolicy";
|
||||||
import Terms from "./components/legal/Terms";
|
import Terms from "./components/legal/Terms";
|
||||||
import "./i18n";
|
import "./i18n";
|
||||||
|
|
@ -21,7 +22,9 @@ const root =
|
||||||
path === "/terms" ? <Terms /> : (
|
path === "/terms" ? <Terms /> : (
|
||||||
<QueryClientProvider client={queryClient}>
|
<QueryClientProvider client={queryClient}>
|
||||||
<ErrorBoundary>
|
<ErrorBoundary>
|
||||||
<App />
|
<ConfirmProvider>
|
||||||
|
<App />
|
||||||
|
</ConfirmProvider>
|
||||||
</ErrorBoundary>
|
</ErrorBoundary>
|
||||||
</QueryClientProvider>
|
</QueryClientProvider>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue