feat(m5a): channel manager, tabbed settings panel, per-user sync status
Backend: /api/channels (list + PATCH priority/hidden + attach/detach user tags), user-tag CRUD on /api/tags, /api/sync/my-status (per-user channel sync counts). Frontend: feed|channels page nav (URL-synced) from the account menu; a slide-in tabbed Settings panel (Appearance, Notifications=6b sound+duration, Sync status + actions + admin pause/resume, Account); a channel manager with priority, hide, per-channel user tags, sync badges and 'view in feed'. Notifications now honor the configurable sound + auto-dismiss settings.
This commit is contained in:
parent
b8a8605797
commit
a8822d3935
11 changed files with 1063 additions and 45 deletions
|
|
@ -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 feed, health, me, sync, tags
|
from app.routes import channels, feed, health, me, sync, tags
|
||||||
from app.scheduler import shutdown_scheduler, start_scheduler
|
from app.scheduler import shutdown_scheduler, start_scheduler
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -65,6 +65,7 @@ app.include_router(sync.router)
|
||||||
app.include_router(tags.router)
|
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)
|
||||||
|
|
||||||
# The built SPA (populated by the Docker frontend build stage).
|
# The built SPA (populated by the Docker frontend build stage).
|
||||||
STATIC_DIR = Path(__file__).parent / "static_spa"
|
STATIC_DIR = Path(__file__).parent / "static_spa"
|
||||||
|
|
|
||||||
143
backend/app/routes/channels.py
Normal file
143
backend/app/routes/channels.py
Normal file
|
|
@ -0,0 +1,143 @@
|
||||||
|
"""Channel manager: the user's subscriptions with their personal overrides (priority,
|
||||||
|
hidden, tags) and per-channel sync state. Read + local-write only — YouTube-side writes
|
||||||
|
(unsubscribe) live behind the optional write scope and are added in a later phase."""
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
|
from sqlalchemy import and_, func, select
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.auth import current_user
|
||||||
|
from app.db import get_db
|
||||||
|
from app.models import Channel, ChannelTag, Subscription, Tag, User, Video
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/channels", tags=["channels"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("")
|
||||||
|
def list_channels(
|
||||||
|
user: User = Depends(current_user), db: Session = Depends(get_db)
|
||||||
|
) -> list[dict]:
|
||||||
|
rows = db.execute(
|
||||||
|
select(Subscription, Channel)
|
||||||
|
.join(Channel, Channel.id == Subscription.channel_id)
|
||||||
|
.where(Subscription.user_id == user.id)
|
||||||
|
.order_by(Subscription.priority.desc(), func.lower(Channel.title))
|
||||||
|
).all()
|
||||||
|
|
||||||
|
# Per-channel stored video count (shared data) in one grouped query.
|
||||||
|
channel_ids = [c.id for _, c in rows]
|
||||||
|
stored: dict[str, int] = {}
|
||||||
|
if channel_ids:
|
||||||
|
for cid, count in db.execute(
|
||||||
|
select(Video.channel_id, func.count(Video.id))
|
||||||
|
.where(Video.channel_id.in_(channel_ids))
|
||||||
|
.group_by(Video.channel_id)
|
||||||
|
).all():
|
||||||
|
stored[cid] = count
|
||||||
|
|
||||||
|
# The user's personal tag links, grouped by channel.
|
||||||
|
tags_by_channel: dict[str, list[int]] = {}
|
||||||
|
for cid, tag_id in db.execute(
|
||||||
|
select(ChannelTag.channel_id, ChannelTag.tag_id).where(
|
||||||
|
ChannelTag.user_id == user.id
|
||||||
|
)
|
||||||
|
).all():
|
||||||
|
tags_by_channel.setdefault(cid, []).append(tag_id)
|
||||||
|
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"id": ch.id,
|
||||||
|
"title": ch.title,
|
||||||
|
"handle": ch.handle,
|
||||||
|
"thumbnail_url": ch.thumbnail_url,
|
||||||
|
"subscriber_count": ch.subscriber_count,
|
||||||
|
"video_count": ch.video_count,
|
||||||
|
"stored_videos": stored.get(ch.id, 0),
|
||||||
|
"priority": sub.priority,
|
||||||
|
"hidden": sub.hidden,
|
||||||
|
"tag_ids": tags_by_channel.get(ch.id, []),
|
||||||
|
"details_synced": ch.details_synced_at is not None,
|
||||||
|
"recent_synced": ch.recent_synced_at is not None,
|
||||||
|
"backfill_done": ch.backfill_done,
|
||||||
|
}
|
||||||
|
for sub, ch in rows
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _user_subscription(db: Session, user: User, channel_id: str) -> Subscription:
|
||||||
|
sub = db.execute(
|
||||||
|
select(Subscription).where(
|
||||||
|
Subscription.user_id == user.id, Subscription.channel_id == channel_id
|
||||||
|
)
|
||||||
|
).scalar_one_or_none()
|
||||||
|
if sub is None:
|
||||||
|
raise HTTPException(status_code=404, detail="Not subscribed to this channel")
|
||||||
|
return sub
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/{channel_id}")
|
||||||
|
def update_channel(
|
||||||
|
channel_id: str,
|
||||||
|
payload: dict,
|
||||||
|
user: User = Depends(current_user),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
) -> dict:
|
||||||
|
sub = _user_subscription(db, user, channel_id)
|
||||||
|
if "priority" in payload:
|
||||||
|
sub.priority = int(payload["priority"])
|
||||||
|
if "hidden" in payload:
|
||||||
|
sub.hidden = bool(payload["hidden"])
|
||||||
|
db.commit()
|
||||||
|
return {"id": channel_id, "priority": sub.priority, "hidden": sub.hidden}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{channel_id}/tags")
|
||||||
|
def attach_tag(
|
||||||
|
channel_id: str,
|
||||||
|
payload: dict,
|
||||||
|
user: User = Depends(current_user),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
) -> dict:
|
||||||
|
_user_subscription(db, user, channel_id)
|
||||||
|
tag_id = payload.get("tag_id")
|
||||||
|
tag = db.get(Tag, tag_id) if tag_id is not None else None
|
||||||
|
# Only the user's own tags may be attached as personal links.
|
||||||
|
if tag is None or tag.user_id != user.id:
|
||||||
|
raise HTTPException(status_code=404, detail="Unknown tag")
|
||||||
|
exists = db.execute(
|
||||||
|
select(ChannelTag).where(
|
||||||
|
ChannelTag.channel_id == channel_id,
|
||||||
|
ChannelTag.tag_id == tag_id,
|
||||||
|
ChannelTag.user_id == user.id,
|
||||||
|
)
|
||||||
|
).scalar_one_or_none()
|
||||||
|
if exists is None:
|
||||||
|
db.add(
|
||||||
|
ChannelTag(
|
||||||
|
channel_id=channel_id,
|
||||||
|
tag_id=tag_id,
|
||||||
|
user_id=user.id,
|
||||||
|
source="user",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
db.commit()
|
||||||
|
return {"channel_id": channel_id, "tag_id": tag_id}
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{channel_id}/tags/{tag_id}")
|
||||||
|
def detach_tag(
|
||||||
|
channel_id: str,
|
||||||
|
tag_id: int,
|
||||||
|
user: User = Depends(current_user),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
) -> dict:
|
||||||
|
row = db.execute(
|
||||||
|
select(ChannelTag).where(
|
||||||
|
ChannelTag.channel_id == channel_id,
|
||||||
|
ChannelTag.tag_id == tag_id,
|
||||||
|
ChannelTag.user_id == user.id,
|
||||||
|
)
|
||||||
|
).scalar_one_or_none()
|
||||||
|
if row is not None:
|
||||||
|
db.delete(row)
|
||||||
|
db.commit()
|
||||||
|
return {"channel_id": channel_id, "tag_id": tag_id}
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
from fastapi import APIRouter, Depends, HTTPException
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
from sqlalchemy import func, select
|
from sqlalchemy import and_, case, func, select
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app import quota, state
|
from app import quota, state
|
||||||
|
|
@ -93,6 +93,50 @@ def sync_status(
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/my-status")
|
||||||
|
def my_status(
|
||||||
|
user: User = Depends(current_user), db: Session = Depends(get_db)
|
||||||
|
) -> dict:
|
||||||
|
"""Per-user sync progress: how far the channels *this* user subscribes to have synced."""
|
||||||
|
sub_join = and_(
|
||||||
|
Subscription.channel_id == Channel.id, Subscription.user_id == user.id
|
||||||
|
)
|
||||||
|
total, details_synced, recent_synced, deep_done = db.execute(
|
||||||
|
select(
|
||||||
|
func.count(Channel.id),
|
||||||
|
func.count(Channel.details_synced_at),
|
||||||
|
func.count(Channel.recent_synced_at),
|
||||||
|
func.sum(case((Channel.backfill_done.is_(True), 1), else_=0)),
|
||||||
|
)
|
||||||
|
.select_from(Channel)
|
||||||
|
.join(Subscription, sub_join)
|
||||||
|
).one()
|
||||||
|
total = total or 0
|
||||||
|
deep_done = int(deep_done or 0)
|
||||||
|
my_videos = db.scalar(
|
||||||
|
select(func.count(Video.id)).join(
|
||||||
|
Subscription,
|
||||||
|
and_(
|
||||||
|
Subscription.channel_id == Video.channel_id,
|
||||||
|
Subscription.user_id == user.id,
|
||||||
|
Subscription.hidden.is_(False),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"channels_total": total,
|
||||||
|
"channels_details_synced": details_synced,
|
||||||
|
"channels_recent_synced": recent_synced,
|
||||||
|
"channels_deep_done": deep_done,
|
||||||
|
"channels_recent_pending": total - recent_synced,
|
||||||
|
"channels_deep_pending": total - deep_done,
|
||||||
|
"my_videos": my_videos or 0,
|
||||||
|
"quota_used_today": quota.units_used_today(db),
|
||||||
|
"quota_remaining_today": quota.remaining_today(db),
|
||||||
|
"paused": state.is_sync_paused(db),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@router.post("/pause")
|
@router.post("/pause")
|
||||||
def pause_sync(
|
def pause_sync(
|
||||||
user: User = Depends(current_user), db: Session = Depends(get_db)
|
user: User = Depends(current_user), db: Session = Depends(get_db)
|
||||||
|
|
|
||||||
|
|
@ -32,6 +32,66 @@ def list_tags(user: User = Depends(current_user), db: Session = Depends(get_db))
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("")
|
||||||
|
def create_tag(
|
||||||
|
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")
|
||||||
|
category = payload.get("category") or "other"
|
||||||
|
tag = Tag(
|
||||||
|
user_id=user.id,
|
||||||
|
name=name,
|
||||||
|
color=payload.get("color"),
|
||||||
|
category=category if category in ("language", "topic", "other") else "other",
|
||||||
|
)
|
||||||
|
db.add(tag)
|
||||||
|
db.commit()
|
||||||
|
return {"id": tag.id, "name": tag.name, "color": tag.color, "category": tag.category}
|
||||||
|
|
||||||
|
|
||||||
|
def _own_tag(db: Session, user: User, tag_id: int) -> Tag:
|
||||||
|
tag = db.get(Tag, tag_id)
|
||||||
|
if tag is None or tag.user_id != user.id:
|
||||||
|
# System tags (user_id NULL) are not user-editable.
|
||||||
|
raise HTTPException(status_code=404, detail="Unknown tag")
|
||||||
|
return tag
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/{tag_id}")
|
||||||
|
def update_tag(
|
||||||
|
tag_id: int,
|
||||||
|
payload: dict,
|
||||||
|
user: User = Depends(current_user),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
) -> dict:
|
||||||
|
tag = _own_tag(db, user, tag_id)
|
||||||
|
if "name" in payload:
|
||||||
|
name = (payload.get("name") or "").strip()
|
||||||
|
if not name:
|
||||||
|
raise HTTPException(status_code=400, detail="name cannot be empty")
|
||||||
|
tag.name = name
|
||||||
|
if "color" in payload:
|
||||||
|
tag.color = payload.get("color")
|
||||||
|
db.commit()
|
||||||
|
return {"id": tag.id, "name": tag.name, "color": tag.color, "category": tag.category}
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{tag_id}")
|
||||||
|
def delete_tag(
|
||||||
|
tag_id: int,
|
||||||
|
user: User = Depends(current_user),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
) -> dict:
|
||||||
|
tag = _own_tag(db, user, tag_id)
|
||||||
|
db.delete(tag) # ChannelTag rows cascade on tag delete.
|
||||||
|
db.commit()
|
||||||
|
return {"deleted": tag_id}
|
||||||
|
|
||||||
|
|
||||||
@router.post("/recompute")
|
@router.post("/recompute")
|
||||||
def recompute(
|
def recompute(
|
||||||
user: User = Depends(current_user),
|
user: User = Depends(current_user),
|
||||||
|
|
|
||||||
|
|
@ -8,17 +8,20 @@ import {
|
||||||
saveLocalTheme,
|
saveLocalTheme,
|
||||||
type ThemePrefs,
|
type ThemePrefs,
|
||||||
} from "./lib/theme";
|
} from "./lib/theme";
|
||||||
import { hasFilterParams, paramsToFilters, syncUrl } from "./lib/urlState";
|
import { hasFilterParams, paramsToFilters, readPage, syncUrl, type Page } from "./lib/urlState";
|
||||||
import {
|
import {
|
||||||
loadLayout,
|
loadLayout,
|
||||||
normalizeLayout,
|
normalizeLayout,
|
||||||
saveLayoutLocal,
|
saveLayoutLocal,
|
||||||
type SidebarLayout,
|
type SidebarLayout,
|
||||||
} from "./lib/sidebarLayout";
|
} from "./lib/sidebarLayout";
|
||||||
|
import { configureNotifications } from "./lib/notifications";
|
||||||
import Login from "./components/Login";
|
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 Channels from "./components/Channels";
|
||||||
|
import SettingsPanel from "./components/SettingsPanel";
|
||||||
import Toaster from "./components/Toaster";
|
import Toaster from "./components/Toaster";
|
||||||
|
|
||||||
const DEFAULT_FILTERS: FeedFilters = {
|
const DEFAULT_FILTERS: FeedFilters = {
|
||||||
|
|
@ -54,11 +57,18 @@ 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 [settingsOpen, setSettingsOpen] = useState(false);
|
||||||
|
|
||||||
function setFilters(next: FeedFilters) {
|
function setFilters(next: FeedFilters) {
|
||||||
setFiltersState(next);
|
setFiltersState(next);
|
||||||
localStorage.setItem(FILTERS_KEY, JSON.stringify(next));
|
localStorage.setItem(FILTERS_KEY, JSON.stringify(next));
|
||||||
syncUrl(next);
|
syncUrl(next, page);
|
||||||
|
}
|
||||||
|
|
||||||
|
function setPage(next: Page) {
|
||||||
|
setPageState(next);
|
||||||
|
syncUrl(filters, next);
|
||||||
}
|
}
|
||||||
|
|
||||||
function setSidebarLayout(next: SidebarLayout) {
|
function setSidebarLayout(next: SidebarLayout) {
|
||||||
|
|
@ -69,9 +79,8 @@ export default function App() {
|
||||||
|
|
||||||
useEffect(() => applyTheme(theme), [theme]);
|
useEffect(() => applyTheme(theme), [theme]);
|
||||||
|
|
||||||
// Reflect the initial filters (from localStorage or URL) into the address bar
|
// Reflect the initial filters + page into the address bar so the URL is always shareable.
|
||||||
// so the URL is always shareable, even before the first filter change.
|
useEffect(() => syncUrl(filters, page), []); // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
useEffect(() => syncUrl(filters), []); // eslint-disable-line react-hooks/exhaustive-deps
|
|
||||||
|
|
||||||
const meQuery = useQuery({ queryKey: ["me"], queryFn: api.me });
|
const meQuery = useQuery({ queryKey: ["me"], queryFn: api.me });
|
||||||
|
|
||||||
|
|
@ -90,6 +99,7 @@ export default function App() {
|
||||||
setSidebarLayoutState(l);
|
setSidebarLayoutState(l);
|
||||||
saveLayoutLocal(l);
|
saveLayoutLocal(l);
|
||||||
}
|
}
|
||||||
|
if (prefs.notifications) configureNotifications(prefs.notifications);
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [meQuery.data?.id]);
|
}, [meQuery.data?.id]);
|
||||||
|
|
||||||
|
|
@ -124,18 +134,42 @@ export default function App() {
|
||||||
setFilters={setFilters}
|
setFilters={setFilters}
|
||||||
view={view}
|
view={view}
|
||||||
setView={changeView}
|
setView={changeView}
|
||||||
|
page={page}
|
||||||
|
setPage={setPage}
|
||||||
|
onOpenSettings={() => setSettingsOpen(true)}
|
||||||
/>
|
/>
|
||||||
<div className="flex flex-1 min-h-0">
|
<div className="flex flex-1 min-h-0">
|
||||||
<Sidebar
|
{page === "feed" && (
|
||||||
filters={filters}
|
<Sidebar
|
||||||
setFilters={setFilters}
|
filters={filters}
|
||||||
layout={sidebarLayout}
|
setFilters={setFilters}
|
||||||
setLayout={setSidebarLayout}
|
layout={sidebarLayout}
|
||||||
/>
|
setLayout={setSidebarLayout}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
<main className="flex-1 min-w-0 overflow-y-auto">
|
<main className="flex-1 min-w-0 overflow-y-auto">
|
||||||
<Feed filters={filters} setFilters={setFilters} view={view} />
|
{page === "feed" ? (
|
||||||
|
<Feed filters={filters} setFilters={setFilters} view={view} />
|
||||||
|
) : (
|
||||||
|
<Channels
|
||||||
|
onViewChannel={(id, name) => {
|
||||||
|
setFilters({ ...filters, channelId: id, channelName: name, show: "all" });
|
||||||
|
setPage("feed");
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
{settingsOpen && (
|
||||||
|
<SettingsPanel
|
||||||
|
me={meQuery.data!}
|
||||||
|
theme={theme}
|
||||||
|
setTheme={setTheme}
|
||||||
|
view={view}
|
||||||
|
setView={changeView}
|
||||||
|
onClose={() => setSettingsOpen(false)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
<Toaster />
|
<Toaster />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
261
frontend/src/components/Channels.tsx
Normal file
261
frontend/src/components/Channels.tsx
Normal file
|
|
@ -0,0 +1,261 @@
|
||||||
|
import { useState } from "react";
|
||||||
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import {
|
||||||
|
ArrowDown,
|
||||||
|
ArrowUp,
|
||||||
|
Eye,
|
||||||
|
EyeOff,
|
||||||
|
Plus,
|
||||||
|
RefreshCw,
|
||||||
|
Search,
|
||||||
|
X,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { api, type ManagedChannel, type Tag } from "../lib/api";
|
||||||
|
import { notify } from "../lib/notifications";
|
||||||
|
|
||||||
|
export default function Channels({
|
||||||
|
onViewChannel,
|
||||||
|
}: {
|
||||||
|
onViewChannel: (id: string, name: string) => void;
|
||||||
|
}) {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
const channelsQuery = useQuery({ queryKey: ["channels"], queryFn: api.channels });
|
||||||
|
const tagsQuery = useQuery({ queryKey: ["tags"], queryFn: api.tags });
|
||||||
|
const statusQuery = useQuery({ queryKey: ["my-status"], queryFn: api.myStatus });
|
||||||
|
const [q, setQ] = useState("");
|
||||||
|
const [newTag, setNewTag] = useState("");
|
||||||
|
|
||||||
|
const invalidate = () => {
|
||||||
|
qc.invalidateQueries({ queryKey: ["channels"] });
|
||||||
|
qc.invalidateQueries({ queryKey: ["tags"] });
|
||||||
|
qc.invalidateQueries({ queryKey: ["my-status"] });
|
||||||
|
};
|
||||||
|
|
||||||
|
const userTags = (tagsQuery.data ?? []).filter((t) => !t.system);
|
||||||
|
|
||||||
|
const patch = useMutation({
|
||||||
|
mutationFn: (v: { id: string; body: { priority?: number; hidden?: boolean } }) =>
|
||||||
|
api.updateChannel(v.id, v.body),
|
||||||
|
onSuccess: () => qc.invalidateQueries({ queryKey: ["channels"] }),
|
||||||
|
});
|
||||||
|
const attach = useMutation({
|
||||||
|
mutationFn: (v: { id: string; tagId: number }) => api.attachChannelTag(v.id, v.tagId),
|
||||||
|
onSuccess: () => qc.invalidateQueries({ queryKey: ["channels"] }),
|
||||||
|
});
|
||||||
|
const detach = useMutation({
|
||||||
|
mutationFn: (v: { id: string; tagId: number }) => api.detachChannelTag(v.id, v.tagId),
|
||||||
|
onSuccess: () => qc.invalidateQueries({ queryKey: ["channels"] }),
|
||||||
|
});
|
||||||
|
const syncSubs = useMutation({
|
||||||
|
mutationFn: () => api.syncSubscriptions(),
|
||||||
|
onSuccess: (r: { subscriptions?: number }) => {
|
||||||
|
invalidate();
|
||||||
|
notify({ level: "success", message: `Synced ${r.subscriptions ?? 0} subscriptions` });
|
||||||
|
},
|
||||||
|
onError: () => notify({ level: "error", message: "Subscription sync failed" }),
|
||||||
|
});
|
||||||
|
const createTag = useMutation({
|
||||||
|
mutationFn: (name: string) => api.createTag({ name, category: "other" }),
|
||||||
|
onSuccess: () => {
|
||||||
|
setNewTag("");
|
||||||
|
qc.invalidateQueries({ queryKey: ["tags"] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const deleteTag = useMutation({
|
||||||
|
mutationFn: (id: number) => api.deleteTag(id),
|
||||||
|
onSuccess: () => invalidate(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const channels = (channelsQuery.data ?? []).filter(
|
||||||
|
(c) => !q || (c.title ?? "").toLowerCase().includes(q.toLowerCase())
|
||||||
|
);
|
||||||
|
const s = statusQuery.data;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-4 max-w-4xl mx-auto">
|
||||||
|
{/* Per-user sync status */}
|
||||||
|
{s && (
|
||||||
|
<div className="flex flex-wrap items-center gap-x-5 gap-y-1 text-sm text-muted mb-4">
|
||||||
|
<Stat label="Channels" value={s.channels_total} />
|
||||||
|
<Stat label="Recent synced" value={`${s.channels_recent_synced}/${s.channels_total}`} />
|
||||||
|
<Stat label="Full history" value={`${s.channels_deep_done}/${s.channels_total}`} />
|
||||||
|
<Stat label="My videos" value={s.my_videos.toLocaleString()} />
|
||||||
|
<Stat label="Quota left" value={s.quota_remaining_today.toLocaleString()} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Toolbar */}
|
||||||
|
<div className="flex items-center gap-2 mb-4">
|
||||||
|
<div className="relative flex-1">
|
||||||
|
<Search className="w-4 h-4 absolute left-3 top-1/2 -translate-y-1/2 text-muted" />
|
||||||
|
<input
|
||||||
|
value={q}
|
||||||
|
onChange={(e) => setQ(e.target.value)}
|
||||||
|
placeholder="Filter channels…"
|
||||||
|
className="w-full bg-card border border-border rounded-full pl-9 pr-4 py-2 text-sm outline-none focus:border-accent"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => syncSubs.mutate()}
|
||||||
|
disabled={syncSubs.isPending}
|
||||||
|
className="flex items-center gap-2 px-3 py-2 rounded-lg border border-border text-sm hover:border-accent disabled:opacity-50 transition"
|
||||||
|
>
|
||||||
|
<RefreshCw className={`w-4 h-4 ${syncSubs.isPending ? "animate-spin" : ""}`} />
|
||||||
|
Sync subscriptions
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Your tags */}
|
||||||
|
<div className="flex flex-wrap items-center gap-1.5 mb-4">
|
||||||
|
<span className="text-xs uppercase tracking-wide text-muted mr-1">Your tags</span>
|
||||||
|
{userTags.map((t) => (
|
||||||
|
<span
|
||||||
|
key={t.id}
|
||||||
|
className="inline-flex items-center gap-1 text-xs px-2 py-1 rounded-full bg-card border border-border"
|
||||||
|
>
|
||||||
|
{t.name}
|
||||||
|
<button onClick={() => deleteTag.mutate(t.id)} className="text-muted hover:text-red-400">
|
||||||
|
<X className="w-3 h-3" />
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
<form
|
||||||
|
onSubmit={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (newTag.trim()) createTag.mutate(newTag.trim());
|
||||||
|
}}
|
||||||
|
className="inline-flex items-center gap-1"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
value={newTag}
|
||||||
|
onChange={(e) => setNewTag(e.target.value)}
|
||||||
|
placeholder="new tag"
|
||||||
|
className="w-24 bg-card border border-border rounded-full px-2.5 py-1 text-xs outline-none focus:border-accent"
|
||||||
|
/>
|
||||||
|
<button type="submit" className="text-muted hover:text-accent" title="Create tag">
|
||||||
|
<Plus className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Channel list */}
|
||||||
|
{channelsQuery.isLoading ? (
|
||||||
|
<div className="text-muted py-8">Loading channels…</div>
|
||||||
|
) : channels.length === 0 ? (
|
||||||
|
<div className="text-muted py-8">No channels.</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
{channels.map((c) => (
|
||||||
|
<ChannelRow
|
||||||
|
key={c.id}
|
||||||
|
c={c}
|
||||||
|
userTags={userTags}
|
||||||
|
onView={() => onViewChannel(c.id, c.title ?? "This channel")}
|
||||||
|
onPriority={(d) => patch.mutate({ id: c.id, body: { priority: c.priority + d } })}
|
||||||
|
onHide={() => patch.mutate({ id: c.id, body: { hidden: !c.hidden } })}
|
||||||
|
onToggleTag={(tagId) =>
|
||||||
|
c.tag_ids.includes(tagId)
|
||||||
|
? detach.mutate({ id: c.id, tagId })
|
||||||
|
: attach.mutate({ id: c.id, tagId })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Stat({ label, value }: { label: string; value: string | number }) {
|
||||||
|
return (
|
||||||
|
<span>
|
||||||
|
<span className="text-fg font-semibold">{value}</span> {label}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SyncBadge({ ok, label }: { ok: boolean; label: string }) {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={`text-[10px] px-1.5 py-0.5 rounded-full border ${
|
||||||
|
ok ? "border-accent/40 text-accent" : "border-border text-muted"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ChannelRow({
|
||||||
|
c,
|
||||||
|
userTags,
|
||||||
|
onView,
|
||||||
|
onPriority,
|
||||||
|
onHide,
|
||||||
|
onToggleTag,
|
||||||
|
}: {
|
||||||
|
c: ManagedChannel;
|
||||||
|
userTags: Tag[];
|
||||||
|
onView: () => void;
|
||||||
|
onPriority: (delta: number) => void;
|
||||||
|
onHide: () => void;
|
||||||
|
onToggleTag: (tagId: number) => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`flex items-center gap-3 p-2.5 rounded-xl border border-border bg-card/30 ${
|
||||||
|
c.hidden ? "opacity-60" : ""
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="flex flex-col items-center">
|
||||||
|
<button onClick={() => onPriority(1)} className="text-muted hover:text-accent" title="Raise priority">
|
||||||
|
<ArrowUp className="w-3.5 h-3.5" />
|
||||||
|
</button>
|
||||||
|
<span className="text-xs text-muted tabular-nums">{c.priority}</span>
|
||||||
|
<button onClick={() => onPriority(-1)} className="text-muted hover:text-accent" title="Lower priority">
|
||||||
|
<ArrowDown className="w-3.5 h-3.5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{c.thumbnail_url ? (
|
||||||
|
<img src={c.thumbnail_url} alt="" className="w-10 h-10 rounded-full shrink-0" />
|
||||||
|
) : (
|
||||||
|
<div className="w-10 h-10 rounded-full bg-card shrink-0" />
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<button onClick={onView} className="text-sm font-semibold truncate hover:text-accent block max-w-full text-left">
|
||||||
|
{c.title ?? c.id}
|
||||||
|
</button>
|
||||||
|
<div className="flex items-center gap-2 text-[11px] text-muted">
|
||||||
|
<span>{c.stored_videos.toLocaleString()} stored</span>
|
||||||
|
{c.subscriber_count != null && <span>· {c.subscriber_count.toLocaleString()} subs</span>}
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap items-center gap-1 mt-1">
|
||||||
|
<SyncBadge ok={c.recent_synced} label="recent" />
|
||||||
|
<SyncBadge ok={c.backfill_done} label="full" />
|
||||||
|
{userTags.map((t) => {
|
||||||
|
const on = c.tag_ids.includes(t.id);
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={t.id}
|
||||||
|
onClick={() => onToggleTag(t.id)}
|
||||||
|
className={`text-[10px] px-1.5 py-0.5 rounded-full border transition ${
|
||||||
|
on
|
||||||
|
? "bg-accent text-accent-fg border-accent"
|
||||||
|
: "bg-card border-border text-muted hover:border-accent"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{t.name}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button onClick={onHide} className="text-muted hover:text-fg shrink-0" title={c.hidden ? "Unhide" : "Hide from feed"}>
|
||||||
|
{c.hidden ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -1,16 +1,20 @@
|
||||||
import { useRef, useState } from "react";
|
import { useRef, useState } from "react";
|
||||||
import {
|
import {
|
||||||
|
Home,
|
||||||
LayoutGrid,
|
LayoutGrid,
|
||||||
List,
|
List,
|
||||||
LogOut,
|
LogOut,
|
||||||
Moon,
|
Moon,
|
||||||
Palette,
|
Palette,
|
||||||
Search,
|
Search,
|
||||||
|
Settings,
|
||||||
Shield,
|
Shield,
|
||||||
Sun,
|
Sun,
|
||||||
|
Tv,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { SCHEMES, type Scheme, type ThemePrefs } from "../lib/theme";
|
import { SCHEMES, type Scheme, type ThemePrefs } from "../lib/theme";
|
||||||
import type { FeedFilters, Me } from "../lib/api";
|
import type { FeedFilters, Me } from "../lib/api";
|
||||||
|
import type { Page } from "../lib/urlState";
|
||||||
import SyncStatus from "./SyncStatus";
|
import SyncStatus from "./SyncStatus";
|
||||||
import NotificationCenter from "./NotificationCenter";
|
import NotificationCenter from "./NotificationCenter";
|
||||||
|
|
||||||
|
|
@ -72,6 +76,9 @@ export default function Header({
|
||||||
setFilters,
|
setFilters,
|
||||||
view,
|
view,
|
||||||
setView,
|
setView,
|
||||||
|
page,
|
||||||
|
setPage,
|
||||||
|
onOpenSettings,
|
||||||
}: {
|
}: {
|
||||||
me: Me;
|
me: Me;
|
||||||
theme: ThemePrefs;
|
theme: ThemePrefs;
|
||||||
|
|
@ -80,6 +87,9 @@ export default function Header({
|
||||||
setFilters: (f: FeedFilters) => void;
|
setFilters: (f: FeedFilters) => void;
|
||||||
view: "grid" | "list";
|
view: "grid" | "list";
|
||||||
setView: (v: "grid" | "list") => void;
|
setView: (v: "grid" | "list") => void;
|
||||||
|
page: Page;
|
||||||
|
setPage: (p: Page) => void;
|
||||||
|
onOpenSettings: () => void;
|
||||||
}) {
|
}) {
|
||||||
const [menuOpen, setMenuOpen] = useState(false);
|
const [menuOpen, setMenuOpen] = useState(false);
|
||||||
|
|
||||||
|
|
@ -90,29 +100,39 @@ export default function Header({
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<header className="h-14 shrink-0 border-b border-border bg-surface/80 backdrop-blur flex items-center gap-3 px-4 z-20">
|
<header className="h-14 shrink-0 border-b border-border bg-surface/80 backdrop-blur flex items-center gap-3 px-4 z-20">
|
||||||
<div className="text-xl font-bold tracking-tight select-none">
|
<button
|
||||||
|
onClick={() => setPage("feed")}
|
||||||
|
className="text-xl font-bold tracking-tight select-none"
|
||||||
|
title="Feed"
|
||||||
|
>
|
||||||
Sub<span className="text-accent">feed</span>
|
Sub<span className="text-accent">feed</span>
|
||||||
</div>
|
</button>
|
||||||
|
|
||||||
<SyncStatus />
|
<SyncStatus />
|
||||||
|
|
||||||
<div className="flex-1 max-w-xl mx-auto relative">
|
{page === "feed" ? (
|
||||||
<Search className="w-4 h-4 absolute left-3 top-1/2 -translate-y-1/2 text-muted" />
|
<div className="flex-1 max-w-xl mx-auto relative">
|
||||||
<input
|
<Search className="w-4 h-4 absolute left-3 top-1/2 -translate-y-1/2 text-muted" />
|
||||||
value={filters.q}
|
<input
|
||||||
onChange={(e) => setFilters({ ...filters, q: e.target.value })}
|
value={filters.q}
|
||||||
placeholder="Search your subscriptions…"
|
onChange={(e) => setFilters({ ...filters, q: e.target.value })}
|
||||||
className="w-full bg-card border border-border rounded-full pl-9 pr-4 py-2 text-sm outline-none focus:border-accent"
|
placeholder="Search your subscriptions…"
|
||||||
/>
|
className="w-full bg-card border border-border rounded-full pl-9 pr-4 py-2 text-sm outline-none focus:border-accent"
|
||||||
</div>
|
/>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex-1 text-center text-sm font-semibold">Channel manager</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
<IconBtn
|
{page === "feed" && (
|
||||||
onClick={() => setView(view === "grid" ? "list" : "grid")}
|
<IconBtn
|
||||||
title={view === "grid" ? "List view" : "Grid view"}
|
onClick={() => setView(view === "grid" ? "list" : "grid")}
|
||||||
>
|
title={view === "grid" ? "List view" : "Grid view"}
|
||||||
{view === "grid" ? <List className="w-5 h-5" /> : <LayoutGrid className="w-5 h-5" />}
|
>
|
||||||
</IconBtn>
|
{view === "grid" ? <List className="w-5 h-5" /> : <LayoutGrid className="w-5 h-5" />}
|
||||||
|
</IconBtn>
|
||||||
|
)}
|
||||||
<IconBtn
|
<IconBtn
|
||||||
onClick={() => setTheme({ ...theme, mode: theme.mode === "dark" ? "light" : "dark" })}
|
onClick={() => setTheme({ ...theme, mode: theme.mode === "dark" ? "light" : "dark" })}
|
||||||
title="Toggle dark / light"
|
title="Toggle dark / light"
|
||||||
|
|
@ -134,7 +154,13 @@ export default function Header({
|
||||||
<NotificationCenter filters={filters} setFilters={setFilters} />
|
<NotificationCenter filters={filters} setFilters={setFilters} />
|
||||||
|
|
||||||
<div className="pl-1">
|
<div className="pl-1">
|
||||||
<AccountMenu me={me} logout={logout} />
|
<AccountMenu
|
||||||
|
me={me}
|
||||||
|
logout={logout}
|
||||||
|
page={page}
|
||||||
|
setPage={setPage}
|
||||||
|
onOpenSettings={onOpenSettings}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
@ -151,7 +177,19 @@ function Avatar({ me, className = "" }: { me: Me; className?: string }) {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function AccountMenu({ me, logout }: { me: Me; logout: () => void }) {
|
function AccountMenu({
|
||||||
|
me,
|
||||||
|
logout,
|
||||||
|
page,
|
||||||
|
setPage,
|
||||||
|
onOpenSettings,
|
||||||
|
}: {
|
||||||
|
me: Me;
|
||||||
|
logout: () => void;
|
||||||
|
page: Page;
|
||||||
|
setPage: (p: Page) => void;
|
||||||
|
onOpenSettings: () => void;
|
||||||
|
}) {
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const closeTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
const closeTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
|
||||||
|
|
@ -163,6 +201,9 @@ function AccountMenu({ me, logout }: { me: Me; logout: () => void }) {
|
||||||
closeTimer.current = setTimeout(() => setOpen(false), 220);
|
closeTimer.current = setTimeout(() => setOpen(false), 220);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const itemClass =
|
||||||
|
"w-full flex items-center gap-2 text-sm px-2 py-2 rounded-lg text-muted hover:text-fg hover:bg-card transition";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="relative"
|
className="relative"
|
||||||
|
|
@ -196,13 +237,27 @@ function AccountMenu({ me, logout }: { me: Me; logout: () => void }) {
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<button
|
<div className="mt-2 flex flex-col">
|
||||||
onClick={logout}
|
{page === "channels" ? (
|
||||||
className="mt-2 w-full flex items-center gap-2 text-sm px-2 py-2 rounded-lg text-muted hover:text-fg hover:bg-card transition"
|
<button onClick={() => { setPage("feed"); setOpen(false); }} className={itemClass}>
|
||||||
>
|
<Home className="w-4 h-4" />
|
||||||
<LogOut className="w-4 h-4" />
|
Feed
|
||||||
Sign out
|
</button>
|
||||||
</button>
|
) : (
|
||||||
|
<button onClick={() => { setPage("channels"); setOpen(false); }} className={itemClass}>
|
||||||
|
<Tv className="w-4 h-4" />
|
||||||
|
Channels
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button onClick={() => { onOpenSettings(); setOpen(false); }} className={itemClass}>
|
||||||
|
<Settings className="w-4 h-4" />
|
||||||
|
Settings
|
||||||
|
</button>
|
||||||
|
<button onClick={logout} className={itemClass}>
|
||||||
|
<LogOut className="w-4 h-4" />
|
||||||
|
Sign out
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
312
frontend/src/components/SettingsPanel.tsx
Normal file
312
frontend/src/components/SettingsPanel.tsx
Normal file
|
|
@ -0,0 +1,312 @@
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { Bell, Monitor, Pause, Play, RefreshCw, User, X } from "lucide-react";
|
||||||
|
import { SCHEMES, type Scheme, type ThemePrefs } from "../lib/theme";
|
||||||
|
import { api, type Me } from "../lib/api";
|
||||||
|
import {
|
||||||
|
configureNotifications,
|
||||||
|
getNotifSettings,
|
||||||
|
notify,
|
||||||
|
type NotifSettings,
|
||||||
|
} from "../lib/notifications";
|
||||||
|
|
||||||
|
type TabId = "appearance" | "notifications" | "sync" | "account";
|
||||||
|
const TABS: { id: TabId; label: string; icon: typeof Monitor }[] = [
|
||||||
|
{ id: "appearance", label: "Appearance", icon: Monitor },
|
||||||
|
{ id: "notifications", label: "Notifications", icon: Bell },
|
||||||
|
{ id: "sync", label: "Sync", icon: RefreshCw },
|
||||||
|
{ id: "account", label: "Account", icon: User },
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function SettingsPanel({
|
||||||
|
me,
|
||||||
|
theme,
|
||||||
|
setTheme,
|
||||||
|
view,
|
||||||
|
setView,
|
||||||
|
onClose,
|
||||||
|
}: {
|
||||||
|
me: Me;
|
||||||
|
theme: ThemePrefs;
|
||||||
|
setTheme: (t: ThemePrefs) => void;
|
||||||
|
view: "grid" | "list";
|
||||||
|
setView: (v: "grid" | "list") => void;
|
||||||
|
onClose: () => void;
|
||||||
|
}) {
|
||||||
|
const [tab, setTab] = useState<TabId>("appearance");
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
function onKey(e: KeyboardEvent) {
|
||||||
|
if (e.key === "Escape") onClose();
|
||||||
|
}
|
||||||
|
document.addEventListener("keydown", onKey);
|
||||||
|
return () => document.removeEventListener("keydown", onKey);
|
||||||
|
}, [onClose]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-40 flex justify-end">
|
||||||
|
<div className="absolute inset-0 bg-black/40" onClick={onClose} />
|
||||||
|
<div className="relative w-[min(92vw,440px)] h-full bg-surface border-l border-border shadow-2xl flex flex-col animate-[fadeIn_0.15s_ease]">
|
||||||
|
<div className="flex items-center justify-between px-4 h-14 border-b border-border shrink-0">
|
||||||
|
<div className="text-base font-semibold">Settings</div>
|
||||||
|
<button onClick={onClose} className="p-2 rounded-lg text-muted hover:text-fg hover:bg-card transition">
|
||||||
|
<X className="w-5 h-5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-1 px-3 py-2 border-b border-border shrink-0 overflow-x-auto">
|
||||||
|
{TABS.map((t) => (
|
||||||
|
<button
|
||||||
|
key={t.id}
|
||||||
|
onClick={() => setTab(t.id)}
|
||||||
|
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-sm whitespace-nowrap transition ${
|
||||||
|
tab === t.id ? "bg-card text-fg" : "text-muted hover:text-fg"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<t.icon className="w-4 h-4" />
|
||||||
|
{t.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1 overflow-y-auto p-4">
|
||||||
|
{tab === "appearance" && (
|
||||||
|
<Appearance theme={theme} setTheme={setTheme} view={view} setView={setView} />
|
||||||
|
)}
|
||||||
|
{tab === "notifications" && <Notifications />}
|
||||||
|
{tab === "sync" && <Sync me={me} />}
|
||||||
|
{tab === "account" && <Account me={me} />}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Section({ title, children }: { title: string; children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<div className="mb-6">
|
||||||
|
<div className="text-xs uppercase tracking-wide text-muted mb-2">{title}</div>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Row({ label, children }: { label: string; children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<label className="flex items-center justify-between gap-3 py-1.5 text-sm">
|
||||||
|
<span>{label}</span>
|
||||||
|
{children}
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Switch({ checked, onChange }: { checked: boolean; onChange: (v: boolean) => void }) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onChange(!checked)}
|
||||||
|
className={`w-9 h-5 rounded-full transition relative shrink-0 ${checked ? "bg-accent" : "bg-border"}`}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={`absolute top-0.5 w-4 h-4 rounded-full bg-white transition-all ${
|
||||||
|
checked ? "left-[18px]" : "left-0.5"
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const PERF_KEY = "subfeed.perfMode";
|
||||||
|
|
||||||
|
function Appearance({
|
||||||
|
theme,
|
||||||
|
setTheme,
|
||||||
|
view,
|
||||||
|
setView,
|
||||||
|
}: {
|
||||||
|
theme: ThemePrefs;
|
||||||
|
setTheme: (t: ThemePrefs) => void;
|
||||||
|
view: "grid" | "list";
|
||||||
|
setView: (v: "grid" | "list") => void;
|
||||||
|
}) {
|
||||||
|
const [perf, setPerf] = useState(() => localStorage.getItem(PERF_KEY) === "1");
|
||||||
|
useEffect(() => {
|
||||||
|
document.documentElement.dataset.perf = perf ? "1" : "";
|
||||||
|
}, [perf]);
|
||||||
|
function togglePerf(v: boolean) {
|
||||||
|
setPerf(v);
|
||||||
|
localStorage.setItem(PERF_KEY, v ? "1" : "0");
|
||||||
|
api.savePrefs({ performanceMode: v }).catch(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Section title="Color scheme">
|
||||||
|
<div className="grid grid-cols-4 gap-2">
|
||||||
|
{SCHEMES.map((s) => (
|
||||||
|
<button
|
||||||
|
key={s.id}
|
||||||
|
onClick={() => setTheme({ ...theme, scheme: s.id as Scheme })}
|
||||||
|
title={s.name}
|
||||||
|
className={`h-9 rounded-lg border-2 transition ${
|
||||||
|
theme.scheme === s.id ? "border-fg" : "border-transparent"
|
||||||
|
}`}
|
||||||
|
style={{ background: s.swatch }}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<Section title="Display">
|
||||||
|
<Row label="Dark mode">
|
||||||
|
<Switch
|
||||||
|
checked={theme.mode === "dark"}
|
||||||
|
onChange={(v) => setTheme({ ...theme, mode: v ? "dark" : "light" })}
|
||||||
|
/>
|
||||||
|
</Row>
|
||||||
|
<Row label="List view">
|
||||||
|
<Switch checked={view === "list"} onChange={(v) => setView(v ? "list" : "grid")} />
|
||||||
|
</Row>
|
||||||
|
<Row label="Performance mode">
|
||||||
|
<Switch checked={perf} onChange={togglePerf} />
|
||||||
|
</Row>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<Section title="Text size">
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min={0.9}
|
||||||
|
max={1.3}
|
||||||
|
step={0.02}
|
||||||
|
value={theme.fontScale}
|
||||||
|
onChange={(e) => setTheme({ ...theme, fontScale: Number(e.target.value) })}
|
||||||
|
className="w-full accent-accent"
|
||||||
|
/>
|
||||||
|
<div className="text-right text-xs text-muted">{Math.round(theme.fontScale * 100)}%</div>
|
||||||
|
</Section>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Notifications() {
|
||||||
|
const [cfg, setCfg] = useState<NotifSettings>(() => getNotifSettings());
|
||||||
|
function update(p: Partial<NotifSettings>) {
|
||||||
|
const next = { ...cfg, ...p };
|
||||||
|
setCfg(next);
|
||||||
|
configureNotifications(next);
|
||||||
|
api.savePrefs({ notifications: next }).catch(() => {});
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<Section title="Notifications">
|
||||||
|
<Row label="Sound on attention-needing alerts">
|
||||||
|
<Switch checked={cfg.sound} onChange={(v) => update({ sound: v })} />
|
||||||
|
</Row>
|
||||||
|
<div className="py-1.5 text-sm">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span>Auto-dismiss</span>
|
||||||
|
<span className="text-muted text-xs">{(cfg.durationMs / 1000).toFixed(0)}s</span>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min={3000}
|
||||||
|
max={15000}
|
||||||
|
step={1000}
|
||||||
|
value={cfg.durationMs}
|
||||||
|
onChange={(e) => update({ durationMs: Number(e.target.value) })}
|
||||||
|
className="w-full accent-accent mt-1"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => notify({ level: "info", message: "This is a test notification." })}
|
||||||
|
className="mt-2 text-sm text-accent hover:underline"
|
||||||
|
>
|
||||||
|
Send a test notification
|
||||||
|
</button>
|
||||||
|
</Section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Sync({ me }: { me: Me }) {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
const status = useQuery({ queryKey: ["my-status"], queryFn: api.myStatus });
|
||||||
|
const s = status.data;
|
||||||
|
const syncSubs = useMutation({
|
||||||
|
mutationFn: () => api.syncSubscriptions(),
|
||||||
|
onSuccess: (r: { subscriptions?: number }) => {
|
||||||
|
qc.invalidateQueries({ queryKey: ["my-status"] });
|
||||||
|
qc.invalidateQueries({ queryKey: ["channels"] });
|
||||||
|
notify({ level: "success", message: `Synced ${r.subscriptions ?? 0} subscriptions` });
|
||||||
|
},
|
||||||
|
onError: () => notify({ level: "error", message: "Sync failed" }),
|
||||||
|
});
|
||||||
|
const pauseResume = useMutation({
|
||||||
|
mutationFn: (paused: boolean) => (paused ? api.resumeSync() : api.pauseSync()),
|
||||||
|
onSuccess: () => qc.invalidateQueries({ queryKey: ["my-status"] }),
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Section title="My sync status">
|
||||||
|
{s ? (
|
||||||
|
<div className="space-y-1.5 text-sm">
|
||||||
|
<Row label="Channels">{s.channels_total}</Row>
|
||||||
|
<Row label="Recent synced">{`${s.channels_recent_synced}/${s.channels_total}`}</Row>
|
||||||
|
<Row label="Full history">{`${s.channels_deep_done}/${s.channels_total}`}</Row>
|
||||||
|
<Row label="My videos">{s.my_videos.toLocaleString()}</Row>
|
||||||
|
<Row label="Quota left today">{s.quota_remaining_today.toLocaleString()}</Row>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="text-muted text-sm">Loading…</div>
|
||||||
|
)}
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<Section title="Actions">
|
||||||
|
<button
|
||||||
|
onClick={() => syncSubs.mutate()}
|
||||||
|
disabled={syncSubs.isPending}
|
||||||
|
className="flex items-center gap-2 px-3 py-2 rounded-lg border border-border text-sm hover:border-accent disabled:opacity-50 transition"
|
||||||
|
>
|
||||||
|
<RefreshCw className={`w-4 h-4 ${syncSubs.isPending ? "animate-spin" : ""}`} />
|
||||||
|
Sync subscriptions
|
||||||
|
</button>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
{me.role === "admin" && s && (
|
||||||
|
<Section title="Admin">
|
||||||
|
<button
|
||||||
|
onClick={() => pauseResume.mutate(s.paused)}
|
||||||
|
className="flex items-center gap-2 px-3 py-2 rounded-lg border border-border text-sm hover:border-accent transition"
|
||||||
|
>
|
||||||
|
{s.paused ? <Play className="w-4 h-4" /> : <Pause className="w-4 h-4" />}
|
||||||
|
{s.paused ? "Resume background sync" : "Pause background sync"}
|
||||||
|
</button>
|
||||||
|
</Section>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Account({ me }: { me: Me }) {
|
||||||
|
return (
|
||||||
|
<Section title="Account">
|
||||||
|
<div className="flex items-center gap-3 mb-3">
|
||||||
|
{me.avatar_url ? (
|
||||||
|
<img src={me.avatar_url} alt="" className="w-12 h-12 rounded-full" />
|
||||||
|
) : (
|
||||||
|
<div className="w-12 h-12 rounded-full bg-card grid place-items-center font-semibold">
|
||||||
|
{(me.display_name?.[0] ?? me.email[0] ?? "?").toUpperCase()}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="font-semibold truncate">{me.display_name ?? me.email.split("@")[0]}</div>
|
||||||
|
<div className="text-xs text-muted truncate">{me.email}</div>
|
||||||
|
<div className="text-xs text-muted capitalize">{me.role}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted leading-relaxed">
|
||||||
|
Playlist editing & YouTube export, and the admin approval queue, arrive in the next
|
||||||
|
phases of this milestone.
|
||||||
|
</p>
|
||||||
|
</Section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -133,6 +133,35 @@ export interface SyncStatus {
|
||||||
is_admin: boolean;
|
is_admin: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface MyStatus {
|
||||||
|
channels_total: number;
|
||||||
|
channels_details_synced: number;
|
||||||
|
channels_recent_synced: number;
|
||||||
|
channels_deep_done: number;
|
||||||
|
channels_recent_pending: number;
|
||||||
|
channels_deep_pending: number;
|
||||||
|
my_videos: number;
|
||||||
|
quota_used_today: number;
|
||||||
|
quota_remaining_today: number;
|
||||||
|
paused: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ManagedChannel {
|
||||||
|
id: string;
|
||||||
|
title: string | null;
|
||||||
|
handle: string | null;
|
||||||
|
thumbnail_url: string | null;
|
||||||
|
subscriber_count: number | null;
|
||||||
|
video_count: number | null;
|
||||||
|
stored_videos: number;
|
||||||
|
priority: number;
|
||||||
|
hidden: boolean;
|
||||||
|
tag_ids: number[];
|
||||||
|
details_synced: boolean;
|
||||||
|
recent_synced: boolean;
|
||||||
|
backfill_done: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
export const api = {
|
export const api = {
|
||||||
me: (): Promise<Me> => req("/api/me"),
|
me: (): Promise<Me> => req("/api/me"),
|
||||||
tags: (): Promise<Tag[]> => req("/api/tags"),
|
tags: (): Promise<Tag[]> => req("/api/tags"),
|
||||||
|
|
@ -147,6 +176,24 @@ export const api = {
|
||||||
resumeSync: () => req("/api/sync/resume", { method: "POST" }),
|
resumeSync: () => req("/api/sync/resume", { method: "POST" }),
|
||||||
savePrefs: (p: Record<string, any>) =>
|
savePrefs: (p: Record<string, any>) =>
|
||||||
req("/api/me/preferences", { method: "PUT", body: JSON.stringify(p) }),
|
req("/api/me/preferences", { method: "PUT", body: JSON.stringify(p) }),
|
||||||
|
|
||||||
|
// --- channel manager ---
|
||||||
|
myStatus: (): Promise<MyStatus> => req("/api/sync/my-status"),
|
||||||
|
channels: (): Promise<ManagedChannel[]> => req("/api/channels"),
|
||||||
|
updateChannel: (id: string, patch: { priority?: number; hidden?: boolean }) =>
|
||||||
|
req(`/api/channels/${id}`, { method: "PATCH", body: JSON.stringify(patch) }),
|
||||||
|
attachChannelTag: (id: string, tagId: number) =>
|
||||||
|
req(`/api/channels/${id}/tags`, { method: "POST", body: JSON.stringify({ tag_id: tagId }) }),
|
||||||
|
detachChannelTag: (id: string, tagId: number) =>
|
||||||
|
req(`/api/channels/${id}/tags/${tagId}`, { method: "DELETE" }),
|
||||||
|
syncSubscriptions: () => req("/api/sync/subscriptions", { method: "POST" }),
|
||||||
|
|
||||||
|
// --- user tags ---
|
||||||
|
createTag: (t: { name: string; color?: string; category?: string }) =>
|
||||||
|
req("/api/tags", { method: "POST", body: JSON.stringify(t) }),
|
||||||
|
updateTag: (id: number, patch: { name?: string; color?: string }) =>
|
||||||
|
req(`/api/tags/${id}`, { method: "PATCH", body: JSON.stringify(patch) }),
|
||||||
|
deleteTag: (id: number) => req(`/api/tags/${id}`, { method: "DELETE" }),
|
||||||
};
|
};
|
||||||
|
|
||||||
export { HttpError };
|
export { HttpError };
|
||||||
|
|
|
||||||
|
|
@ -44,10 +44,58 @@ export interface NotifyInput {
|
||||||
}
|
}
|
||||||
|
|
||||||
const HISTORY_KEY = "subfeed.notifications";
|
const HISTORY_KEY = "subfeed.notifications";
|
||||||
|
const SETTINGS_KEY = "subfeed.notifSettings";
|
||||||
const MAX_HISTORY = 100;
|
const MAX_HISTORY = 100;
|
||||||
const DEFAULT_TTL = 6000;
|
|
||||||
const ERROR_TTL = 15000;
|
const ERROR_TTL = 15000;
|
||||||
|
|
||||||
|
// 6b: per-account notification settings (persisted by the Settings panel into the
|
||||||
|
// server `preferences`, mirrored to localStorage so they apply before login).
|
||||||
|
export interface NotifSettings {
|
||||||
|
sound: boolean; // play a short tone on attention-needing notifications
|
||||||
|
durationMs: number; // auto-dismiss time for info/success toasts
|
||||||
|
}
|
||||||
|
const DEFAULT_SETTINGS: NotifSettings = { sound: false, durationMs: 6000 };
|
||||||
|
let config: NotifSettings = loadSettings();
|
||||||
|
|
||||||
|
function loadSettings(): NotifSettings {
|
||||||
|
try {
|
||||||
|
return { ...DEFAULT_SETTINGS, ...JSON.parse(localStorage.getItem(SETTINGS_KEY) || "{}") };
|
||||||
|
} catch {
|
||||||
|
return DEFAULT_SETTINGS;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getNotifSettings(): NotifSettings {
|
||||||
|
return config;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function configureNotifications(p: Partial<NotifSettings>): void {
|
||||||
|
config = { ...config, ...p };
|
||||||
|
try {
|
||||||
|
localStorage.setItem(SETTINGS_KEY, JSON.stringify(config));
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let audioCtx: AudioContext | null = null;
|
||||||
|
function beep(): void {
|
||||||
|
try {
|
||||||
|
const Ctx = window.AudioContext || (window as unknown as { webkitAudioContext: typeof AudioContext }).webkitAudioContext;
|
||||||
|
audioCtx = audioCtx || new Ctx();
|
||||||
|
const osc = audioCtx.createOscillator();
|
||||||
|
const gain = audioCtx.createGain();
|
||||||
|
osc.connect(gain);
|
||||||
|
gain.connect(audioCtx.destination);
|
||||||
|
osc.frequency.value = 660;
|
||||||
|
gain.gain.value = 0.04;
|
||||||
|
osc.start();
|
||||||
|
osc.stop(audioCtx.currentTime + 0.12);
|
||||||
|
} catch {
|
||||||
|
/* autoplay blocked or unsupported — ignore */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let items: Notification[] = load();
|
let items: Notification[] = load();
|
||||||
let listeners: Array<() => void> = [];
|
let listeners: Array<() => void> = [];
|
||||||
let counter = items.reduce((m, n) => Math.max(m, n.id), 0) + 1;
|
let counter = items.reduce((m, n) => Math.max(m, n.id), 0) + 1;
|
||||||
|
|
@ -107,7 +155,7 @@ export function notify(input: NotifyInput): number {
|
||||||
? undefined
|
? undefined
|
||||||
: level === "error" || level === "fatal"
|
: level === "error" || level === "fatal"
|
||||||
? ERROR_TTL
|
? ERROR_TTL
|
||||||
: DEFAULT_TTL;
|
: config.durationMs;
|
||||||
items = [
|
items = [
|
||||||
...items,
|
...items,
|
||||||
{
|
{
|
||||||
|
|
@ -125,6 +173,9 @@ export function notify(input: NotifyInput): number {
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
emit();
|
emit();
|
||||||
|
if (config.sound && (requiresInteraction || level === "error" || level === "fatal")) {
|
||||||
|
beep();
|
||||||
|
}
|
||||||
if (duration) setTimeout(() => dismiss(id), duration);
|
if (duration) setTimeout(() => dismiss(id), duration);
|
||||||
return id;
|
return id;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -74,9 +74,19 @@ export function hasFilterParams(params: URLSearchParams): boolean {
|
||||||
return KEYS.some((k) => params.has(k));
|
return KEYS.some((k) => params.has(k));
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Reflect the current filters into the address bar without adding history entries. */
|
export type Page = "feed" | "channels";
|
||||||
export function syncUrl(f: FeedFilters): void {
|
|
||||||
const qs = filtersToParams(f).toString();
|
export function readPage(): Page {
|
||||||
|
return new URLSearchParams(window.location.search).get("page") === "channels"
|
||||||
|
? "channels"
|
||||||
|
: "feed";
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Reflect the current filters + page into the address bar without adding history entries. */
|
||||||
|
export function syncUrl(f: FeedFilters, page: Page = "feed"): void {
|
||||||
|
const params = filtersToParams(f);
|
||||||
|
if (page !== "feed") params.set("page", page);
|
||||||
|
const qs = params.toString();
|
||||||
const url = qs ? `${window.location.pathname}?${qs}` : window.location.pathname;
|
const url = qs ? `${window.location.pathname}?${qs}` : window.location.pathname;
|
||||||
window.history.replaceState(null, "", url);
|
window.history.replaceState(null, "", url);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue