Merge: promote dev to prod

This commit is contained in:
npeter83 2026-06-18 01:19:23 +02:00
commit 19eb0242f8
32 changed files with 721 additions and 84 deletions

View file

@ -1 +1 @@
0.7.0 0.8.0

View file

@ -27,16 +27,33 @@ def list_channels(
.order_by(Subscription.priority.desc(), func.lower(Channel.title)) .order_by(Subscription.priority.desc(), func.lower(Channel.title))
).all() ).all()
# Per-channel stored video count (shared data) in one grouped query. # Per-channel aggregates over the shared catalog in ONE grouped pass: stored count, last
# upload, total duration, and a Normal/Shorts/Live breakdown. Cheap — a single grouped scan
# (~35 ms over the full 233k-row catalog on the dev DB), so computed on read rather than
# denormalised (measure-first: reads aren't frequent/hot enough to warrant cached columns).
channel_ids = [c.id for _, c in rows] channel_ids = [c.id for _, c in rows]
stored: dict[str, int] = {} agg: dict[str, dict] = {}
if channel_ids: if channel_ids:
for cid, count in db.execute( for cid, n, last_up, total_dur, shorts, live in db.execute(
select(Video.channel_id, func.count(Video.id)) select(
Video.channel_id,
func.count(Video.id),
func.max(Video.published_at),
func.coalesce(func.sum(Video.duration_seconds), 0),
func.count(Video.id).filter(Video.is_short.is_(True)),
func.count(Video.id).filter(Video.live_status.in_(("live", "upcoming"))),
)
.where(Video.channel_id.in_(channel_ids)) .where(Video.channel_id.in_(channel_ids))
.group_by(Video.channel_id) .group_by(Video.channel_id)
).all(): ).all():
stored[cid] = count agg[cid] = {
"stored": n,
"last_video_at": last_up.isoformat() if last_up else None,
"total_duration_seconds": int(total_dur or 0),
"count_short": int(shorts),
"count_live": int(live),
"count_normal": max(0, int(n) - int(shorts) - int(live)),
}
# Channels already in the *global* deep-backfill queue — i.e. at least one user (any # Channels already in the *global* deep-backfill queue — i.e. at least one user (any
# user) has requested full history and it isn't done yet. Their whole back-catalogue is # user) has requested full history and it isn't done yet. Their whole back-catalogue is
@ -71,7 +88,12 @@ def list_channels(
"thumbnail_url": ch.thumbnail_url, "thumbnail_url": ch.thumbnail_url,
"subscriber_count": ch.subscriber_count, "subscriber_count": ch.subscriber_count,
"video_count": ch.video_count, "video_count": ch.video_count,
"stored_videos": stored.get(ch.id, 0), "stored_videos": (agg.get(ch.id) or {}).get("stored", 0),
"last_video_at": (agg.get(ch.id) or {}).get("last_video_at"),
"total_duration_seconds": (agg.get(ch.id) or {}).get("total_duration_seconds", 0),
"count_normal": (agg.get(ch.id) or {}).get("count_normal", 0),
"count_short": (agg.get(ch.id) or {}).get("count_short", 0),
"count_live": (agg.get(ch.id) or {}).get("count_live", 0),
"priority": sub.priority, "priority": sub.priority,
"hidden": sub.hidden, "hidden": sub.hidden,
"deep_requested": sub.deep_requested, "deep_requested": sub.deep_requested,

View file

@ -435,6 +435,29 @@ def set_video_state(
return {"video_id": video_id, "status": status} return {"video_id": video_id, "status": status}
@router.delete("/videos/{video_id}/state")
def clear_video_state(
video_id: str,
user: User = Depends(current_user),
db: Session = Depends(get_db),
) -> dict:
"""Reset a video to pristine for this user — drop the whole VideoState row (status,
watch position and watched_at), as if it had never been opened. Saved/playlist membership
lives elsewhere and is untouched."""
row = db.execute(
select(VideoState).where(
VideoState.user_id == user.id, VideoState.video_id == video_id
)
).scalar_one_or_none()
if row is not None:
db.delete(row)
try:
db.commit()
except StaleDataError:
db.rollback()
return {"video_id": video_id, "status": "new", "position_seconds": 0}
@router.post("/videos/{video_id}/progress") @router.post("/videos/{video_id}/progress")
def set_video_progress( def set_video_progress(
video_id: str, video_id: str,

View file

@ -1,5 +1,6 @@
from fastapi import APIRouter, Depends, HTTPException from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy import func, or_, select from sqlalchemy import func, or_, select
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.auth import current_user from app.auth import current_user
@ -49,7 +50,11 @@ def create_tag(
category=category if category in ("language", "topic", "other") else "other", category=category if category in ("language", "topic", "other") else "other",
) )
db.add(tag) db.add(tag)
db.commit() try:
db.commit()
except IntegrityError:
db.rollback()
raise HTTPException(status_code=409, detail=f"You already have a tag named “{name}”.")
return {"id": tag.id, "name": tag.name, "color": tag.color, "category": tag.category} return {"id": tag.id, "name": tag.name, "color": tag.color, "category": tag.category}
@ -76,7 +81,11 @@ def update_tag(
tag.name = name tag.name = name
if "color" in payload: if "color" in payload:
tag.color = payload.get("color") tag.color = payload.get("color")
db.commit() try:
db.commit()
except IntegrityError:
db.rollback()
raise HTTPException(status_code=409, detail=f"You already have a tag named “{tag.name}”.")
return {"id": tag.id, "name": tag.name, "color": tag.color, "category": tag.category} return {"id": tag.id, "name": tag.name, "color": tag.color, "category": tag.category}

View file

@ -32,6 +32,7 @@ import SettingsPanel from "./components/SettingsPanel";
import OnboardingWizard from "./components/OnboardingWizard"; import OnboardingWizard from "./components/OnboardingWizard";
import { shouldAutoOpenOnboarding } from "./lib/onboarding"; import { shouldAutoOpenOnboarding } from "./lib/onboarding";
import Toaster from "./components/Toaster"; import Toaster from "./components/Toaster";
import ErrorDialog from "./components/ErrorDialog";
import About from "./components/About"; import About from "./components/About";
import ReleaseNotes from "./components/ReleaseNotes"; import ReleaseNotes from "./components/ReleaseNotes";
import VersionBanner from "./components/VersionBanner"; import VersionBanner from "./components/VersionBanner";
@ -108,6 +109,16 @@ export default function App() {
const [aboutOpen, setAboutOpen] = useState(false); const [aboutOpen, setAboutOpen] = useState(false);
const [notesOpen, setNotesOpen] = useState(false); const [notesOpen, setNotesOpen] = useState(false);
const [notesHighlight, setNotesHighlight] = useState<string | undefined>(undefined); const [notesHighlight, setNotesHighlight] = useState<string | undefined>(undefined);
// "Focus this channel in the manager": jump to the Channels page and seed its name filter
// so the channel is isolated. Cleared when leaving the page so it doesn't re-apply later.
const [focusChannelName, setFocusChannelName] = useState<string | null>(null);
const focusChannel = (name: string) => {
setFocusChannelName(name);
setPage("channels");
};
useEffect(() => {
if (page !== "channels") setFocusChannelName(null);
}, [page]);
function openReleaseNotes(highlight?: string) { function openReleaseNotes(highlight?: string) {
setNotesHighlight(highlight); setNotesHighlight(highlight);
@ -271,6 +282,7 @@ export default function App() {
setFilters={setFilters} setFilters={setFilters}
layout={sidebarLayout} layout={sidebarLayout}
setLayout={setSidebarLayout} setLayout={setSidebarLayout}
onFocusChannel={focusChannel}
/> />
)} )}
<main className="flex-1 min-w-0 overflow-y-auto"> <main className="flex-1 min-w-0 overflow-y-auto">
@ -278,6 +290,8 @@ export default function App() {
<Channels <Channels
canWrite={meQuery.data!.can_write} canWrite={meQuery.data!.can_write}
isAdmin={meQuery.data!.role === "admin"} isAdmin={meQuery.data!.role === "admin"}
focusChannelName={focusChannelName}
onFocusChannel={focusChannel}
statusFilter={channelFilter} statusFilter={channelFilter}
setStatusFilter={setChannelFilter} setStatusFilter={setChannelFilter}
onOpenWizard={() => setWizardOpen(true)} onOpenWizard={() => setWizardOpen(true)}
@ -285,6 +299,11 @@ export default function App() {
setFilters({ ...filters, channelId: id, channelName: name, show: "all" }); setFilters({ ...filters, channelId: id, channelName: name, show: "all" });
setPage("feed"); setPage("feed");
}} }}
onFilterByTag={(tagId, name) => {
setFilters({ ...filters, tags: [tagId], channelId: undefined, channelName: undefined });
setPage("feed");
notify({ level: "info", message: t("channels.notify.filteredByTag", { name }) });
}}
/> />
) : page === "stats" && meQuery.data!.role === "admin" ? ( ) : page === "stats" && meQuery.data!.role === "admin" ? (
<Stats /> <Stats />
@ -333,6 +352,7 @@ export default function App() {
{notesOpen && ( {notesOpen && (
<ReleaseNotes onClose={() => setNotesOpen(false)} highlight={notesHighlight} /> <ReleaseNotes onClose={() => setNotesOpen(false)} highlight={notesHighlight} />
)} )}
<ErrorDialog />
</div> </div>
); );
} }

View file

@ -1,4 +1,4 @@
import { useState } from "react"; import { useEffect, useRef, useState } from "react";
import { Trans, useTranslation } from "react-i18next"; import { Trans, useTranslation } from "react-i18next";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { import {
@ -9,21 +9,29 @@ import {
Eye, Eye,
EyeOff, EyeOff,
History, History,
Pencil,
Plus, Plus,
RefreshCw, RefreshCw,
UserMinus, UserMinus,
X,
} from "lucide-react"; } from "lucide-react";
import { api, HttpError, type ManagedChannel, type Tag } from "../lib/api"; import { api, HttpError, type ManagedChannel, type Tag } from "../lib/api";
import { formatEta } from "../lib/format"; import { formatEta, formatViews, relativeTime } 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 DataTable, { type Column } from "./DataTable"; import DataTable, { type Column } from "./DataTable";
import TagManager from "./TagManager";
import { useConfirm } from "./ConfirmProvider"; import { useConfirm } from "./ConfirmProvider";
export type ChannelStatusFilter = "all" | "needs_full" | "fully_synced" | "hidden"; export type ChannelStatusFilter = "all" | "needs_full" | "fully_synced" | "hidden";
// Compact total-duration label for a whole channel (hours-scale, so H:MM:SS would be huge).
function fmtTotalDuration(sec: number): string {
if (!sec) return "—";
const h = Math.round(sec / 3600);
return h >= 1 ? `${h.toLocaleString()} h` : `${Math.max(1, Math.round(sec / 60))} m`;
}
const STATUS_FILTERS: { id: ChannelStatusFilter; labelKey: string }[] = [ const STATUS_FILTERS: { id: ChannelStatusFilter; labelKey: string }[] = [
{ id: "all", labelKey: "channels.filters.all" }, { id: "all", labelKey: "channels.filters.all" },
{ id: "needs_full", labelKey: "channels.filters.needsFull" }, { id: "needs_full", labelKey: "channels.filters.needsFull" },
@ -35,6 +43,9 @@ export default function Channels({
canWrite, canWrite,
isAdmin, isAdmin,
onViewChannel, onViewChannel,
onFilterByTag,
onFocusChannel,
focusChannelName,
statusFilter, statusFilter,
setStatusFilter, setStatusFilter,
onOpenWizard, onOpenWizard,
@ -42,6 +53,9 @@ export default function Channels({
canWrite: boolean; canWrite: boolean;
isAdmin: boolean; isAdmin: boolean;
onViewChannel: (id: string, name: string) => void; onViewChannel: (id: string, name: string) => void;
onFilterByTag: (tagId: number, name: string) => void;
onFocusChannel: (name: string) => void;
focusChannelName: string | null;
statusFilter: ChannelStatusFilter; statusFilter: ChannelStatusFilter;
setStatusFilter: (f: ChannelStatusFilter) => void; setStatusFilter: (f: ChannelStatusFilter) => void;
onOpenWizard: () => void; onOpenWizard: () => void;
@ -66,7 +80,7 @@ export default function Channels({
const channelsQuery = useQuery({ queryKey: ["channels"], queryFn: api.channels }); const channelsQuery = useQuery({ queryKey: ["channels"], queryFn: api.channels });
const tagsQuery = useQuery({ queryKey: ["tags"], queryFn: api.tags }); const tagsQuery = useQuery({ queryKey: ["tags"], queryFn: api.tags });
const statusQuery = useQuery({ queryKey: ["my-status"], queryFn: api.myStatus }); const statusQuery = useQuery({ queryKey: ["my-status"], queryFn: api.myStatus });
const [newTag, setNewTag] = useState(""); const [tagManagerOpen, setTagManagerOpen] = useState(false);
const invalidate = () => { const invalidate = () => {
qc.invalidateQueries({ queryKey: ["channels"] }); qc.invalidateQueries({ queryKey: ["channels"] });
@ -131,17 +145,6 @@ export default function Channels({
}, },
onError: (e) => notifyActionError(e, "channels.notify.syncFailed"), onError: (e) => notifyActionError(e, "channels.notify.syncFailed"),
}); });
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 unsubscribe = useMutation({ const unsubscribe = useMutation({
mutationFn: (id: string) => api.unsubscribeChannel(id), mutationFn: (id: string) => api.unsubscribeChannel(id),
onSuccess: () => { onSuccess: () => {
@ -231,7 +234,7 @@ export default function Channels({
key: "stored", key: "stored",
header: t("channels.cols.stored"), header: t("channels.cols.stored"),
align: "right", align: "right",
width: "84px", nowrap: true,
sortable: true, sortable: true,
sortValue: (c) => c.stored_videos, sortValue: (c) => c.stored_videos,
render: (c) => <span className="text-muted tabular-nums">{c.stored_videos.toLocaleString()}</span>, render: (c) => <span className="text-muted tabular-nums">{c.stored_videos.toLocaleString()}</span>,
@ -240,26 +243,54 @@ export default function Channels({
key: "subs", key: "subs",
header: t("channels.cols.subs"), header: t("channels.cols.subs"),
align: "right", align: "right",
width: "92px", nowrap: true,
sortable: true, sortable: true,
sortValue: (c) => c.subscriber_count ?? -1, sortValue: (c) => c.subscriber_count ?? -1,
render: (c) => ( render: (c) => (
<span className="text-muted tabular-nums"> <span className="text-muted tabular-nums">
{c.subscriber_count != null ? c.subscriber_count.toLocaleString() : "—"} {c.subscriber_count != null ? formatViews(c.subscriber_count) : "—"}
</span> </span>
), ),
}, },
{
key: "lastUpload",
header: t("channels.cols.lastUpload"),
nowrap: true,
sortable: true,
sortValue: (c) => (c.last_video_at ? new Date(c.last_video_at).getTime() : 0),
render: (c) => <span className="text-muted">{relativeTime(c.last_video_at) || "—"}</span>,
},
{
key: "length",
header: t("channels.cols.length"),
align: "right",
nowrap: true,
sortable: true,
sortValue: (c) => c.total_duration_seconds,
render: (c) => <span className="text-muted tabular-nums">{fmtTotalDuration(c.total_duration_seconds)}</span>,
},
{
key: "types",
header: t("channels.cols.types"),
nowrap: true,
render: (c) => (
<Tooltip hint={t("channels.cols.typesHint")}>
<span className="text-muted tabular-nums cursor-help">
{c.count_normal} / {c.count_short} / {c.count_live}
</span>
</Tooltip>
),
},
{ {
key: "sync", key: "sync",
header: t("channels.cols.sync"), header: t("channels.cols.sync"),
width: "130px", nowrap: true,
cardLabel: false, cardLabel: false,
render: (c) => <SyncCell c={c} />, render: (c) => <SyncCell c={c} />,
}, },
{ {
key: "tags", key: "tags",
header: t("channels.cols.tags"), header: t("channels.cols.tags"),
width: "360px",
cardLabel: false, cardLabel: false,
filter: { filter: {
kind: "multi", kind: "multi",
@ -272,6 +303,7 @@ export default function Channels({
c={c} c={c}
userTags={userTags} userTags={userTags}
onToggleTag={(tagId) => toggleTag(c.id, tagId, c.tag_ids.includes(tagId))} onToggleTag={(tagId) => toggleTag(c.id, tagId, c.tag_ids.includes(tagId))}
onTagClick={onFilterByTag}
/> />
), ),
}, },
@ -279,6 +311,7 @@ export default function Channels({
key: "actions", key: "actions",
header: t("channels.cols.actions"), header: t("channels.cols.actions"),
align: "right", align: "right",
nowrap: true,
width: "104px", width: "104px",
cardLabel: false, cardLabel: false,
render: (c) => ( render: (c) => (
@ -391,42 +424,32 @@ export default function Channels({
/> />
</p> </p>
{/* Your tags */} {/* Your tags — read-only overview; add/rename/delete live in the manager dialog. */}
<div className="flex flex-wrap items-center gap-1.5 mb-4"> <div className="flex flex-wrap items-center gap-1.5 mb-4">
<Tooltip hint={t("channels.tags.yourTagsHint")}> <Tooltip hint={t("channels.tags.yourTagsHint")}>
<span className="text-xs uppercase tracking-wide text-muted mr-1 cursor-help underline decoration-dotted decoration-muted/40 underline-offset-4"> <span className="text-xs uppercase tracking-wide text-muted mr-1 cursor-help underline decoration-dotted decoration-muted/40 underline-offset-4">
{t("channels.tags.yourTags")} {t("channels.tags.yourTags")}
</span> </span>
</Tooltip> </Tooltip>
{userTags.map((t) => ( {userTags.map((tg) => (
<span <span
key={t.id} key={tg.id}
className="inline-flex items-center gap-1 text-xs px-2 py-1 rounded-full bg-card border border-border" className="text-xs px-2 py-1 rounded-full bg-card border border-border"
> >
{t.name} {tg.name}
<button onClick={() => deleteTag.mutate(t.id)} className="text-muted hover:text-red-400">
<X className="w-3 h-3" />
</button>
</span> </span>
))} ))}
<form <button
onSubmit={(e) => { onClick={() => setTagManagerOpen(true)}
e.preventDefault(); className="inline-flex items-center gap-1 text-xs px-2 py-1 rounded-full border border-dashed border-border text-muted hover:text-accent hover:border-accent transition"
if (newTag.trim()) createTag.mutate(newTag.trim());
}}
className="inline-flex items-center gap-1"
> >
<input <Pencil className="w-3 h-3" />
value={newTag} {t("channels.tags.manage")}
onChange={(e) => setNewTag(e.target.value)} </button>
placeholder={t("channels.tags.newTag")}
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={t("channels.tags.createTag")}>
<Plus className="w-4 h-4" />
</button>
</form>
</div> </div>
{tagManagerOpen && (
<TagManager onClose={() => setTagManagerOpen(false)} onFocusChannel={onFocusChannel} />
)}
{/* Channel table */} {/* Channel table */}
{channelsQuery.isLoading ? ( {channelsQuery.isLoading ? (
@ -439,6 +462,7 @@ export default function Channels({
persistKey="siftlode.channelsTable" persistKey="siftlode.channelsTable"
controlsPosition="top" controlsPosition="top"
controlsLeading={statusChips} controlsLeading={statusChips}
externalFilter={focusChannelName ? { key: "channel", value: focusChannelName } : null}
rowClassName={(c) => (c.hidden ? "opacity-60" : "")} rowClassName={(c) => (c.hidden ? "opacity-60" : "")}
emptyText={t("channels.empty")} emptyText={t("channels.empty")}
/> />
@ -569,30 +593,79 @@ function TagsCell({
c, c,
userTags, userTags,
onToggleTag, onToggleTag,
onTagClick,
}: { }: {
c: ManagedChannel; c: ManagedChannel;
userTags: Tag[]; userTags: Tag[];
onToggleTag: (tagId: number) => void; onToggleTag: (tagId: number) => void;
onTagClick: (tagId: number, name: string) => void;
}) { }) {
const { t } = useTranslation();
const [open, setOpen] = useState(false);
const ref = useRef<HTMLDivElement | null>(null);
useEffect(() => {
if (!open) return;
const onDown = (e: MouseEvent) => {
if (!ref.current?.contains(e.target as Node)) setOpen(false);
};
const onKey = (e: KeyboardEvent) => e.key === "Escape" && setOpen(false);
document.addEventListener("mousedown", onDown);
document.addEventListener("keydown", onKey);
return () => {
document.removeEventListener("mousedown", onDown);
document.removeEventListener("keydown", onKey);
};
}, [open]);
if (userTags.length === 0) return null; if (userTags.length === 0) return null;
// Show only the tags actually attached to this channel; the “+” opens a per-channel
// picker to attach/detach (kept the convenient "kirakás" without listing every tag
// on every row). Tag create/delete still lives in the top "YOUR TAGS" row.
const attached = userTags.filter((tg) => c.tag_ids.includes(tg.id));
return ( return (
<div className="flex flex-wrap items-center gap-1"> <div ref={ref} className="relative flex flex-wrap items-center gap-1 min-w-[160px]">
{userTags.map((tg) => { {attached.map((tg) => (
const on = c.tag_ids.includes(tg.id); <button
return ( key={tg.id}
<button onClick={() => onTagClick(tg.id, tg.name)}
key={tg.id} title={t("channels.row.filterFeedByTag", { name: tg.name })}
onClick={() => onToggleTag(tg.id)} className="text-[10px] px-1.5 py-0.5 rounded-full bg-accent text-accent-fg border border-accent hover:opacity-80 transition"
className={`text-[10px] px-1.5 py-0.5 rounded-full border transition ${ >
on {tg.name}
? "bg-accent text-accent-fg border-accent" </button>
: "bg-card border-border text-muted hover:border-accent" ))}
}`} <button
> onClick={() => setOpen((o) => !o)}
{tg.name} title={t("channels.row.editTags")}
</button> aria-label={t("channels.row.editTags")}
); className="w-5 h-5 inline-flex items-center justify-center rounded-full border border-dashed border-border text-muted hover:text-accent hover:border-accent transition"
})} >
<Plus className="w-3 h-3" />
</button>
{open && (
<div className="glass-menu absolute left-0 top-full mt-1 z-30 w-44 rounded-xl p-1.5 animate-[popIn_0.16s_ease]">
{userTags.map((tg) => {
const on = c.tag_ids.includes(tg.id);
return (
<button
key={tg.id}
onClick={() => onToggleTag(tg.id)}
className={`w-full flex items-center gap-2 text-left text-xs px-2 py-1.5 rounded-md transition ${
on ? "text-fg" : "text-muted hover:bg-card"
}`}
>
<span
className={`w-3.5 h-3.5 rounded border flex items-center justify-center shrink-0 ${
on ? "bg-accent border-accent text-accent-fg" : "border-border"
}`}
>
{on && <Check className="w-2.5 h-2.5" />}
</span>
{tg.name}
</button>
);
})}
</div>
)}
</div> </div>
); );
} }

View file

@ -22,6 +22,8 @@ export interface Column<T> {
sortable?: boolean; sortable?: boolean;
sortValue?: (row: T) => string | number; sortValue?: (row: T) => string | number;
filter?: ColumnFilter<T>; filter?: ColumnFilter<T>;
// Keep the cell on one line so the column auto-sizes to its content (table-layout: auto).
nowrap?: boolean;
// Card fallback (below md): `cardPrimary` renders as the card heading (no label); `hideInCard` // Card fallback (below md): `cardPrimary` renders as the card heading (no label); `hideInCard`
// omits the column; `cardLabel:false` shows the value without its header label. // omits the column; `cardLabel:false` shows the value without its header label.
cardPrimary?: boolean; cardPrimary?: boolean;
@ -64,6 +66,7 @@ export default function DataTable<T>({
rowClassName, rowClassName,
controlsPosition = "bottom", controlsPosition = "bottom",
controlsLeading, controlsLeading,
externalFilter,
}: { }: {
rows: T[]; rows: T[];
columns: Column<T>[]; columns: Column<T>[];
@ -75,6 +78,9 @@ export default function DataTable<T>({
rowClassName?: (row: T) => string; rowClassName?: (row: T) => string;
controlsPosition?: "top" | "bottom" | "both"; controlsPosition?: "top" | "bottom" | "both";
controlsLeading?: ReactNode; controlsLeading?: ReactNode;
// Set a column's filter from outside (e.g. "focus this channel" deep-links here). Applied
// whenever its value changes; the user can still clear it from the header afterwards.
externalFilter?: { key: string; value: string } | null;
}) { }) {
const { t } = useTranslation(); const { t } = useTranslation();
const initial = loadPersist(persistKey); const initial = loadPersist(persistKey);
@ -91,6 +97,13 @@ export default function DataTable<T>({
if (persistKey) localStorage.setItem(persistKey, JSON.stringify({ sort, filters, page, size })); if (persistKey) localStorage.setItem(persistKey, JSON.stringify({ sort, filters, page, size }));
}, [persistKey, sort, filters, page, size]); }, [persistKey, sort, filters, page, size]);
useEffect(() => {
if (!externalFilter) return;
setFilters((f) => ({ ...f, [externalFilter.key]: externalFilter.value }));
setPage(0);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [externalFilter?.key, externalFilter?.value]);
useEffect(() => { useEffect(() => {
if (!openFilter) return; if (!openFilter) return;
function onDown(e: MouseEvent) { function onDown(e: MouseEvent) {
@ -325,7 +338,7 @@ export default function DataTable<T>({
<th <th
key={col.key} key={col.key}
style={col.width ? { width: col.width } : undefined} style={col.width ? { width: col.width } : undefined}
className={`relative font-medium px-2 py-2 ${align(col.align)}`} className={`relative font-medium px-2 py-2 whitespace-nowrap ${align(col.align)}`}
> >
<span className="inline-flex items-center gap-1"> <span className="inline-flex items-center gap-1">
<button <button
@ -370,7 +383,11 @@ export default function DataTable<T>({
}`} }`}
> >
{columns.map((col) => ( {columns.map((col) => (
<td key={col.key} style={col.width ? { width: col.width } : undefined} className={`px-2 py-2 align-middle ${align(col.align)}`}> <td
key={col.key}
style={col.width ? { width: col.width } : undefined}
className={`px-2 py-2 align-middle ${col.nowrap ? "whitespace-nowrap" : ""} ${align(col.align)}`}
>
{col.render(row)} {col.render(row)}
</td> </td>
))} ))}

View file

@ -0,0 +1,24 @@
import { useSyncExternalStore } from "react";
import { useTranslation } from "react-i18next";
import { dismissError, getError, subscribeError } from "../lib/errorDialog";
import Modal from "./Modal";
// Renders the current app error (if any) as a modal the user must acknowledge.
export default function ErrorDialog() {
const { t } = useTranslation();
const err = useSyncExternalStore(subscribeError, getError, getError);
if (!err) return null;
return (
<Modal title={err.title} onClose={dismissError} maxWidth="max-w-md">
<p className="text-sm leading-relaxed text-fg/90">{err.message}</p>
<div className="flex justify-end mt-5">
<button
onClick={dismissError}
className="px-4 py-2 rounded-lg bg-accent text-accent-fg text-sm font-medium hover:opacity-90 transition"
>
{t("errors.ok")}
</button>
</div>
</Modal>
);
}

View file

@ -185,6 +185,25 @@ export default function Feed({
[qc] [qc]
); );
const onResetState = useCallback(
(id: string) => {
// Reset to pristine (drop the whole VideoState row, incl. resume position) — the
// un-watch toggle can't clear an in-progress position, this can.
setOverrides((o) => ({ ...o, [id]: "new" }));
api
.clearState(id)
.then(() => qc.invalidateQueries({ queryKey: ["feed"] }))
.catch(() =>
setOverrides((o) => {
const next = { ...o };
delete next[id];
return next;
})
);
},
[qc]
);
const onChannelFilter = useCallback( const onChannelFilter = useCallback(
(channelId: string, channelName: string) => { (channelId: string, channelName: string) => {
setFilters({ ...filters, channelId, channelName }); setFilters({ ...filters, channelId, channelName });
@ -363,6 +382,7 @@ export default function Feed({
onState={onState} onState={onState}
onToggleSave={onToggleSave} onToggleSave={onToggleSave}
onChannelFilter={onChannelFilter} onChannelFilter={onChannelFilter}
onResetState={onResetState}
onOpen={openVideo} onOpen={openVideo}
/> />
))} ))}
@ -377,6 +397,7 @@ export default function Feed({
onState={onState} onState={onState}
onToggleSave={onToggleSave} onToggleSave={onToggleSave}
onChannelFilter={onChannelFilter} onChannelFilter={onChannelFilter}
onResetState={onResetState}
onOpen={openVideo} onOpen={openVideo}
/> />
))} ))}

View file

@ -1,7 +1,12 @@
import { useEffect, type ReactNode } from "react"; import { useEffect, useRef, type ReactNode } from "react";
import { createPortal } from "react-dom"; import { createPortal } from "react-dom";
import { X } from "lucide-react"; import { X } from "lucide-react";
// Stack of open modals so ESC only closes the topmost one — e.g. an error dialog over the
// tag editor: pressing ESC dismisses just the error and returns to the editor underneath.
let modalStack: number[] = [];
let nextModalId = 1;
// Small centered modal shell (portaled to <body>): backdrop + ESC + scroll-lock close. // Small centered modal shell (portaled to <body>): backdrop + ESC + scroll-lock close.
export default function Modal({ export default function Modal({
title, title,
@ -14,18 +19,27 @@ export default function Modal({
children: ReactNode; children: ReactNode;
maxWidth?: string; maxWidth?: string;
}) { }) {
// Keep a stable handler across renders so the stack id is assigned once per mount.
const onCloseRef = useRef(onClose);
onCloseRef.current = onClose;
useEffect(() => { useEffect(() => {
const id = nextModalId++;
modalStack.push(id);
const onKey = (e: KeyboardEvent) => { const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") onClose(); if (e.key === "Escape" && modalStack[modalStack.length - 1] === id) {
e.stopPropagation();
onCloseRef.current();
}
}; };
window.addEventListener("keydown", onKey); window.addEventListener("keydown", onKey);
const prev = document.body.style.overflow; const prev = document.body.style.overflow;
document.body.style.overflow = "hidden"; document.body.style.overflow = "hidden";
return () => { return () => {
window.removeEventListener("keydown", onKey); window.removeEventListener("keydown", onKey);
modalStack = modalStack.filter((x) => x !== id);
document.body.style.overflow = prev; document.body.style.overflow = prev;
}; };
}, [onClose]); }, []);
return createPortal( return createPortal(
<div <div

View file

@ -36,6 +36,7 @@ import {
} from "../lib/sidebarLayout"; } from "../lib/sidebarLayout";
import { shareUrl } from "../lib/urlState"; import { shareUrl } from "../lib/urlState";
import { notify } from "../lib/notifications"; import { notify } from "../lib/notifications";
import TagManager from "./TagManager";
// Filter ids; display labels resolved at render time via t("sidebar.sort.<id>") etc. // Filter ids; display labels resolved at render time via t("sidebar.sort.<id>") etc.
const SORT_IDS = [ const SORT_IDS = [
@ -114,11 +115,13 @@ export default function Sidebar({
setFilters, setFilters,
layout, layout,
setLayout, setLayout,
onFocusChannel,
}: { }: {
filters: FeedFilters; filters: FeedFilters;
setFilters: (f: FeedFilters) => void; setFilters: (f: FeedFilters) => void;
layout: SidebarLayout; layout: SidebarLayout;
setLayout: (l: SidebarLayout) => void; setLayout: (l: SidebarLayout) => void;
onFocusChannel: (name: string) => void;
}) { }) {
const { t } = useTranslation(); const { t } = useTranslation();
const tagsQuery = useQuery({ queryKey: ["tags"], queryFn: api.tags }); const tagsQuery = useQuery({ queryKey: ["tags"], queryFn: api.tags });
@ -172,7 +175,11 @@ export default function Sidebar({
: t("sidebar.thisChannel")); : t("sidebar.thisChannel"));
const languages = tags.filter((t) => t.category === "language"); const languages = tags.filter((t) => t.category === "language");
const topics = tags.filter((t) => t.category === "topic"); const topics = tags.filter((t) => t.category === "topic");
// The user's own (non-system) tags — those assigned to channels in the Channel manager.
// Facets only cover language/topic, so these show their static channel_count.
const userTags = tags.filter((t) => !t.system);
const [customDates, setCustomDates] = useState(false); const [customDates, setCustomDates] = useState(false);
const [tagManagerOpen, setTagManagerOpen] = useState(false);
const [editing, setEditing] = useState(false); const [editing, setEditing] = useState(false);
const sensors = useSensors( const sensors = useSensors(
@ -217,6 +224,7 @@ export default function Sidebar({
date: true, date: true,
language: languages.length > 0, language: languages.length > 0,
topic: topics.length > 0, topic: topics.length > 0,
tags: userTags.length > 0,
}; };
function toggleCollapse(id: WidgetId) { function toggleCollapse(id: WidgetId) {
@ -393,6 +401,28 @@ export default function Sidebar({
</> </>
); );
} }
case "tags":
return (
<div className="flex flex-wrap items-center gap-1.5">
{userTags.map((tg) => (
<TagChip
key={tg.id}
tag={tg}
count={tg.channel_count}
active={filters.tags.includes(tg.id)}
onClick={() => toggleTag(tg.id)}
/>
))}
<button
onClick={() => setTagManagerOpen(true)}
title={t("sidebar.manageTags")}
className="inline-flex items-center gap-1 text-xs px-2 py-1 rounded-full border border-dashed border-border text-muted hover:text-accent hover:border-accent transition"
>
<Pencil className="w-3 h-3" />
{t("sidebar.manageTags")}
</button>
</div>
);
} }
} }
@ -496,6 +526,9 @@ export default function Sidebar({
</div> </div>
</SortableContext> </SortableContext>
</DndContext> </DndContext>
{tagManagerOpen && (
<TagManager onClose={() => setTagManagerOpen(false)} onFocusChannel={onFocusChannel} />
)}
</aside> </aside>
); );
} }

View file

@ -0,0 +1,213 @@
import { useRef, useState } from "react";
import { createPortal } from "react-dom";
import { useTranslation } from "react-i18next";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Plus, Trash2 } from "lucide-react";
import { api, type Tag } from "../lib/api";
import { notify } from "../lib/notifications";
import Modal from "./Modal";
import { useConfirm } from "./ConfirmProvider";
// One editable row: rename in place (commit on Enter/blur), delete with a confirm, and a
// hover popover on the count listing the tagged channels (each a link that focuses it in the
// Channel manager). The popover is portaled to <body> so the list's own scroll can't clip it.
function TagRow({
tag,
channels,
onRename,
onDelete,
onPickChannel,
}: {
tag: Tag;
channels: { id: string; title: string }[];
onRename: (name: string) => void;
onDelete: () => void;
onPickChannel: (name: string) => void;
}) {
const { t } = useTranslation();
const [name, setName] = useState(tag.name);
const [open, setOpen] = useState(false);
const [pos, setPos] = useState({ left: 0, top: 0 });
const countRef = useRef<HTMLSpanElement | null>(null);
const closeTimer = useRef<number | undefined>(undefined);
const commit = () => {
const v = name.trim();
if (v && v !== tag.name) onRename(v);
else setName(tag.name);
};
const openPop = () => {
window.clearTimeout(closeTimer.current);
if (channels.length === 0) return;
const r = countRef.current?.getBoundingClientRect();
if (r) setPos({ left: r.right + 8, top: r.top });
setOpen(true);
};
const closeSoon = () => {
closeTimer.current = window.setTimeout(() => setOpen(false), 150);
};
return (
<div className="flex items-center gap-2">
<input
value={name}
onChange={(e) => setName(e.target.value)}
onBlur={commit}
onKeyDown={(e) => e.key === "Enter" && (e.target as HTMLInputElement).blur()}
className="flex-1 min-w-0 bg-card border border-border rounded-md px-2 py-1.5 text-sm outline-none focus:border-accent"
/>
<span
ref={countRef}
onMouseEnter={openPop}
onMouseLeave={closeSoon}
className={`text-xs text-muted tabular-nums shrink-0 ${
channels.length
? "cursor-help underline decoration-dotted decoration-muted/40 underline-offset-2"
: ""
}`}
>
{t("tagManager.channels", { count: tag.channel_count })}
</span>
<button
onClick={onDelete}
title={t("tagManager.delete")}
aria-label={t("tagManager.delete")}
className="shrink-0 p-1.5 rounded-md text-muted hover:text-red-400 transition"
>
<Trash2 className="w-4 h-4" />
</button>
{open &&
createPortal(
<div
onMouseEnter={openPop}
onMouseLeave={closeSoon}
style={{ position: "fixed", left: pos.left, top: pos.top, zIndex: 60 }}
className="glass-menu w-56 max-h-72 overflow-y-auto rounded-xl p-1.5 animate-[popIn_0.16s_ease]"
>
<div className="text-[11px] uppercase tracking-wide text-muted px-2 py-1">
{t("tagManager.onChannels")}
</div>
{channels.map((ch) => (
<button
key={ch.id}
onClick={() => onPickChannel(ch.title)}
className="w-full text-left text-sm px-2 py-1.5 rounded-md text-muted hover:text-fg hover:bg-card truncate transition"
>
{ch.title}
</button>
))}
</div>,
document.body
)}
</div>
);
}
export default function TagManager({
onClose,
onFocusChannel,
}: {
onClose: () => void;
onFocusChannel: (name: string) => void;
}) {
const { t } = useTranslation();
const qc = useQueryClient();
const confirm = useConfirm();
const [newName, setNewName] = useState("");
const tagsQuery = useQuery({ queryKey: ["tags"], queryFn: api.tags });
const channelsQuery = useQuery({ queryKey: ["channels"], queryFn: api.channels });
const userTags = (tagsQuery.data ?? []).filter((tg) => !tg.system);
const channelsForTag = (tagId: number) =>
(channelsQuery.data ?? [])
.filter((c) => c.tag_ids.includes(tagId))
.map((c) => ({ id: c.id, title: c.title ?? c.id }));
const invalidate = () => {
qc.invalidateQueries({ queryKey: ["tags"] });
qc.invalidateQueries({ queryKey: ["channels"] });
qc.invalidateQueries({ queryKey: ["feed"] });
};
const create = useMutation({
mutationFn: (name: string) => api.createTag({ name, category: "other" }),
onSuccess: () => {
setNewName("");
invalidate();
},
});
const rename = useMutation({
mutationFn: (v: { id: number; name: string }) => api.updateTag(v.id, { name: v.name }),
onSuccess: invalidate,
});
const del = useMutation({
mutationFn: (id: number) => api.deleteTag(id),
onSuccess: () => {
invalidate();
notify({ level: "success", message: t("tagManager.deleted") });
},
});
// Picking a channel from a tag's popover: close the dialog, then focus it in the manager.
const pickChannel = (name: string) => {
onClose();
onFocusChannel(name);
};
return (
<Modal title={t("tagManager.title")} onClose={onClose}>
<div className="flex flex-col gap-2">
{userTags.length === 0 ? (
<p className="text-sm text-muted">{t("tagManager.empty")}</p>
) : (
// Cap at ~8 rows tall, then scroll — the list can grow long.
<div className="flex flex-col gap-2 max-h-[22rem] overflow-y-auto pr-1">
{userTags.map((tag) => (
<TagRow
key={tag.id}
tag={tag}
channels={channelsForTag(tag.id)}
onPickChannel={pickChannel}
onRename={(name) => rename.mutate({ id: tag.id, name })}
onDelete={async () => {
// Only confirm when the tag is actually in use; an unused tag deletes outright.
if (tag.channel_count > 0) {
const ok = await confirm({
title: t("tagManager.deleteTitle"),
message: t("tagManager.confirmDelete", {
name: tag.name,
count: tag.channel_count,
}),
confirmLabel: t("tagManager.delete"),
danger: true,
});
if (!ok) return;
}
del.mutate(tag.id);
}}
/>
))}
</div>
)}
<form
onSubmit={(e) => {
e.preventDefault();
if (newName.trim()) create.mutate(newName.trim());
}}
className="flex items-center gap-2 mt-2 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("tagManager.newPlaceholder")}
className="flex-1 min-w-0 bg-card border border-border rounded-md px-2 py-1.5 text-sm outline-none focus:border-accent"
/>
<button
type="submit"
disabled={!newName.trim()}
className="shrink-0 px-3 py-1.5 rounded-md border border-border text-sm text-muted enabled:hover:text-fg enabled:hover:border-accent disabled:opacity-40 transition"
>
{t("tagManager.add")}
</button>
</form>
</div>
</Modal>
);
}

View file

@ -19,11 +19,13 @@ import { formatDuration, formatViews, relativeTime } from "../lib/format";
function Actions({ function Actions({
video, video,
onState, onState,
onResetState,
onToggleSave, onToggleSave,
onChannelFilter, onChannelFilter,
}: { }: {
video: Video; video: Video;
onState: (id: string, status: string) => void; onState: (id: string, status: string) => void;
onResetState?: (id: string) => void;
onToggleSave: (id: string, saved: boolean) => void; onToggleSave: (id: string, saved: boolean) => void;
onChannelFilter?: (channelId: string, channelName: string) => void; onChannelFilter?: (channelId: string, channelName: string) => void;
}) { }) {
@ -33,6 +35,9 @@ function Actions({
e.stopPropagation(); e.stopPropagation();
onState(video.id, video.status === status ? "new" : status); onState(video.id, video.status === status ? "new" : status);
}; };
// Pristine = never opened: default status and no resume position. The reset clears the
// whole state (incl. an in-progress position the un-watch toggle can't touch).
const resettable = video.status !== "new" || video.position_seconds > 0;
const toggleSave = (e: React.MouseEvent) => { const toggleSave = (e: React.MouseEvent) => {
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
@ -79,6 +84,19 @@ function Actions({
<EyeOff className="w-4 h-4" /> <EyeOff className="w-4 h-4" />
)} )}
</button> </button>
{onResetState && resettable && (
<button
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
onResetState(video.id);
}}
title={t("card.resetState")}
className="p-1.5 rounded-md hover:bg-surface text-muted hover:text-fg"
>
<RotateCcw className="w-4 h-4" />
</button>
)}
{onChannelFilter && ( {onChannelFilter && (
<button <button
onClick={(e) => { onClick={(e) => {
@ -224,6 +242,7 @@ function VideoCard({
video, video,
view, view,
onState, onState,
onResetState,
onToggleSave, onToggleSave,
onChannelFilter, onChannelFilter,
onOpen, onOpen,
@ -231,6 +250,7 @@ function VideoCard({
video: Video; video: Video;
view: "grid" | "list"; view: "grid" | "list";
onState: (id: string, status: string) => void; onState: (id: string, status: string) => void;
onResetState?: (id: string) => void;
onToggleSave: (id: string, saved: boolean) => void; onToggleSave: (id: string, saved: boolean) => void;
onChannelFilter?: (channelId: string, channelName: string) => void; onChannelFilter?: (channelId: string, channelName: string) => void;
onOpen?: (v: Video, startAt?: number | null) => void; onOpen?: (v: Video, startAt?: number | null) => void;
@ -294,7 +314,7 @@ function VideoCard({
</a> </a>
<div className="text-xs text-muted mt-0.5">{meta}</div> <div className="text-xs text-muted mt-0.5">{meta}</div>
</div> </div>
<Actions video={video} onState={onState} onToggleSave={onToggleSave} onChannelFilter={onChannelFilter} /> <Actions video={video} onState={onState} onResetState={onResetState} onToggleSave={onToggleSave} onChannelFilter={onChannelFilter} />
</div> </div>
); );
} }
@ -325,7 +345,7 @@ function VideoCard({
{video.channel_title} {video.channel_title}
</a> </a>
<div className="text-xs text-muted mt-0.5">{meta}</div> <div className="text-xs text-muted mt-0.5">{meta}</div>
<Actions video={video} onState={onState} onToggleSave={onToggleSave} onChannelFilter={onChannelFilter} /> <Actions video={video} onState={onState} onResetState={onResetState} onToggleSave={onToggleSave} onChannelFilter={onChannelFilter} />
</div> </div>
</div> </div>
</div> </div>

View file

@ -8,6 +8,7 @@
"unhide": "Einblenden", "unhide": "Einblenden",
"hide": "Ausblenden", "hide": "Ausblenden",
"onlyThisChannel": "Nur dieser Kanal", "onlyThisChannel": "Nur dieser Kanal",
"resetState": "Zurücksetzen — Fortschritt/Status löschen",
"thisChannel": "Dieser Kanal", "thisChannel": "Dieser Kanal",
"continueTitle": "Dort fortsetzen, wo du aufgehört hast", "continueTitle": "Dort fortsetzen, wo du aufgehört hast",
"continue": "Fortsetzen", "continue": "Fortsetzen",

View file

@ -13,6 +13,7 @@
}, },
"tags": { "tags": {
"yourTags": "Deine Tags", "yourTags": "Deine Tags",
"manage": "Tags verwalten",
"yourTagsHint": "Deine persönlichen Labels. Hänge sie unten an Kanäle an und filtere den Feed dann über die Seitenleiste nach Tag. (Getrennt von den automatischen Sprach-/Themen-Tags.)", "yourTagsHint": "Deine persönlichen Labels. Hänge sie unten an Kanäle an und filtere den Feed dann über die Seitenleiste nach Tag. (Getrennt von den automatischen Sprach-/Themen-Tags.)",
"newTag": "neuer Tag", "newTag": "neuer Tag",
"createTag": "Tag erstellen" "createTag": "Tag erstellen"
@ -29,6 +30,10 @@
"channel": "Kanal", "channel": "Kanal",
"stored": "Gespeichert", "stored": "Gespeichert",
"subs": "Abonnenten", "subs": "Abonnenten",
"lastUpload": "Letzter Upload",
"length": "Länge",
"types": "N/S/L",
"typesHint": "Videos nach Typ: Normale / Shorts / Live",
"sync": "Sync", "sync": "Sync",
"tags": "Tags", "tags": "Tags",
"actions": "Aktionen" "actions": "Aktionen"
@ -51,6 +56,8 @@
"stored": "{{formatted}} gespeichert", "stored": "{{formatted}} gespeichert",
"subs": "{{formatted}} Abonnenten", "subs": "{{formatted}} Abonnenten",
"openOnYouTube": "Auf YouTube öffnen", "openOnYouTube": "Auf YouTube öffnen",
"editTags": "Tags bearbeiten",
"filterFeedByTag": "Feed nach „{{name}}“ filtern",
"priorityHint": "Deine Einstufung für diesen Kanal. Sortiere den Feed nach „Kanalpriorität“, um Kanäle mit höherer Priorität nach oben zu bringen.", "priorityHint": "Deine Einstufung für diesen Kanal. Sortiere den Feed nach „Kanalpriorität“, um Kanäle mit höherer Priorität nach oben zu bringen.",
"raisePriority": "Priorität erhöhen", "raisePriority": "Priorität erhöhen",
"lowerPriority": "Priorität senken", "lowerPriority": "Priorität senken",
@ -89,6 +96,7 @@
"fullHistoryFailed": "Vollständiger Verlauf konnte nicht angefordert werden", "fullHistoryFailed": "Vollständiger Verlauf konnte nicht angefordert werden",
"needYouTube": "Verbinde dein YouTube-Konto, um dies zu tun.", "needYouTube": "Verbinde dein YouTube-Konto, um dies zu tun.",
"connect": "Verbinden", "connect": "Verbinden",
"filteredByTag": "Feed nach Tag gefiltert: {{name}}",
"resetDone": "Kanal zurückgesetzt — wird neu geladen", "resetDone": "Kanal zurückgesetzt — wird neu geladen",
"resetFailed": "Kanal konnte nicht zurückgesetzt werden" "resetFailed": "Kanal konnte nicht zurückgesetzt werden"
}, },

View file

@ -1,4 +1,8 @@
{ {
"title": "Nicht möglich",
"generic": "Der Server konnte die Aktion nicht ausführen. Bitte erneut versuchen.",
"server": "Serverfehler",
"ok": "OK",
"boundary": { "boundary": {
"title": "Etwas ist schiefgelaufen.", "title": "Etwas ist schiefgelaufen.",
"subtitle": "Die App ist auf einen unerwarteten Fehler gestoßen.", "subtitle": "Die App ist auf einen unerwarteten Fehler gestoßen.",

View file

@ -26,6 +26,7 @@
"clearDates": "Daten löschen", "clearDates": "Daten löschen",
"reshuffle": "Neu mischen", "reshuffle": "Neu mischen",
"noMatchingTags": "Keine passenden Tags", "noMatchingTags": "Keine passenden Tags",
"manageTags": "Verwalten",
"shareView": "Link zu dieser Ansicht kopieren", "shareView": "Link zu dieser Ansicht kopieren",
"shareCopied": "Ansichts-Link in die Zwischenablage kopiert", "shareCopied": "Ansichts-Link in die Zwischenablage kopiert",
"shareFailed": "Link konnte nicht kopiert werden", "shareFailed": "Link konnte nicht kopiert werden",
@ -35,7 +36,8 @@
"date": "Upload-Datum", "date": "Upload-Datum",
"content": "Inhaltstyp", "content": "Inhaltstyp",
"language": "Sprache", "language": "Sprache",
"topic": "Thema" "topic": "Thema",
"tags": "Deine Tags"
}, },
"show": { "show": {
"unwatched": "Ungesehen", "unwatched": "Ungesehen",

View file

@ -0,0 +1,12 @@
{
"title": "Tags verwalten",
"channels": "{{count}} Kan.",
"onChannels": "Getaggte Kanäle",
"delete": "Löschen",
"deleted": "Tag gelöscht",
"deleteTitle": "Tag löschen",
"confirmDelete": "Tag „{{name}}“ löschen? Er wird von allen Kanälen entfernt, auf denen er liegt.",
"empty": "Noch keine Tags — füge unten einen hinzu.",
"newPlaceholder": "Neuer Tag-Name…",
"add": "Hinzufügen"
}

View file

@ -8,6 +8,7 @@
"unhide": "Unhide", "unhide": "Unhide",
"hide": "Hide", "hide": "Hide",
"onlyThisChannel": "Only this channel", "onlyThisChannel": "Only this channel",
"resetState": "Reset — clear watch progress/status",
"thisChannel": "This channel", "thisChannel": "This channel",
"continueTitle": "Continue where you left off", "continueTitle": "Continue where you left off",
"continue": "Continue", "continue": "Continue",

View file

@ -13,6 +13,7 @@
}, },
"tags": { "tags": {
"yourTags": "Your tags", "yourTags": "Your tags",
"manage": "Manage tags",
"yourTagsHint": "Your personal labels. Attach them to channels below, then filter the feed by tag from the sidebar. (Separate from the automatic language/topic tags.)", "yourTagsHint": "Your personal labels. Attach them to channels below, then filter the feed by tag from the sidebar. (Separate from the automatic language/topic tags.)",
"newTag": "new tag", "newTag": "new tag",
"createTag": "Create tag" "createTag": "Create tag"
@ -29,6 +30,10 @@
"channel": "Channel", "channel": "Channel",
"stored": "Stored", "stored": "Stored",
"subs": "Subscribers", "subs": "Subscribers",
"lastUpload": "Last upload",
"length": "Length",
"types": "N/S/L",
"typesHint": "Videos by type: Normal / Shorts / Live",
"sync": "Sync", "sync": "Sync",
"tags": "Tags", "tags": "Tags",
"actions": "Actions" "actions": "Actions"
@ -51,6 +56,8 @@
"stored": "{{formatted}} stored", "stored": "{{formatted}} stored",
"subs": "{{formatted}} subs", "subs": "{{formatted}} subs",
"openOnYouTube": "Open on YouTube", "openOnYouTube": "Open on YouTube",
"editTags": "Edit tags",
"filterFeedByTag": "Filter the feed by “{{name}}”",
"priorityHint": "Your ranking for this channel. Sort the feed by “Channel priority” to bring higher-priority channels to the top.", "priorityHint": "Your ranking for this channel. Sort the feed by “Channel priority” to bring higher-priority channels to the top.",
"raisePriority": "Raise priority", "raisePriority": "Raise priority",
"lowerPriority": "Lower priority", "lowerPriority": "Lower priority",
@ -89,6 +96,7 @@
"fullHistoryFailed": "Couldn't request full history", "fullHistoryFailed": "Couldn't request full history",
"needYouTube": "Connect your YouTube account to do this.", "needYouTube": "Connect your YouTube account to do this.",
"connect": "Connect", "connect": "Connect",
"filteredByTag": "Feed filtered by tag: {{name}}",
"resetDone": "Channel reset — re-fetching now", "resetDone": "Channel reset — re-fetching now",
"resetFailed": "Couldn't reset this channel" "resetFailed": "Couldn't reset this channel"
}, },

View file

@ -1,4 +1,8 @@
{ {
"title": "Couldn't complete that",
"generic": "The server couldn't carry out that action. Please try again.",
"server": "Server error",
"ok": "OK",
"boundary": { "boundary": {
"title": "Something went wrong.", "title": "Something went wrong.",
"subtitle": "The app hit an unexpected error.", "subtitle": "The app hit an unexpected error.",

View file

@ -26,6 +26,7 @@
"clearDates": "clear dates", "clearDates": "clear dates",
"reshuffle": "Reshuffle", "reshuffle": "Reshuffle",
"noMatchingTags": "No matching tags here", "noMatchingTags": "No matching tags here",
"manageTags": "Manage",
"shareView": "Copy a link to this view", "shareView": "Copy a link to this view",
"shareCopied": "View link copied to clipboard", "shareCopied": "View link copied to clipboard",
"shareFailed": "Couldn't copy the link", "shareFailed": "Couldn't copy the link",
@ -35,7 +36,8 @@
"date": "Upload date", "date": "Upload date",
"content": "Content type", "content": "Content type",
"language": "Language", "language": "Language",
"topic": "Topic" "topic": "Topic",
"tags": "Your tags"
}, },
"show": { "show": {
"unwatched": "Unwatched", "unwatched": "Unwatched",

View file

@ -0,0 +1,12 @@
{
"title": "Manage tags",
"channels": "{{count}} ch.",
"onChannels": "Tagged channels",
"delete": "Delete",
"deleted": "Tag deleted",
"deleteTitle": "Delete tag",
"confirmDelete": "Delete the tag “{{name}}”? It will be removed from every channel it's on.",
"empty": "No tags yet — add one below.",
"newPlaceholder": "New tag name…",
"add": "Add"
}

View file

@ -8,6 +8,7 @@
"unhide": "Megjelenítés", "unhide": "Megjelenítés",
"hide": "Elrejtés", "hide": "Elrejtés",
"onlyThisChannel": "Csak ez a csatorna", "onlyThisChannel": "Csak ez a csatorna",
"resetState": "Alaphelyzet — nézési előzmény/státusz törlése",
"thisChannel": "Ez a csatorna", "thisChannel": "Ez a csatorna",
"continueTitle": "Folytatás ott, ahol abbahagytad", "continueTitle": "Folytatás ott, ahol abbahagytad",
"continue": "Folytatás", "continue": "Folytatás",

View file

@ -13,6 +13,7 @@
}, },
"tags": { "tags": {
"yourTags": "Címkéid", "yourTags": "Címkéid",
"manage": "Címkék kezelése",
"yourTagsHint": "Saját személyes címkéid. Csatold őket az alábbi csatornákhoz, majd szűrd a hírfolyamot címke szerint az oldalsávból. (Az automatikus nyelvi/téma címkéktől függetlenül.)", "yourTagsHint": "Saját személyes címkéid. Csatold őket az alábbi csatornákhoz, majd szűrd a hírfolyamot címke szerint az oldalsávból. (Az automatikus nyelvi/téma címkéktől függetlenül.)",
"newTag": "új címke", "newTag": "új címke",
"createTag": "Címke létrehozása" "createTag": "Címke létrehozása"
@ -29,6 +30,10 @@
"channel": "Csatorna", "channel": "Csatorna",
"stored": "Tárolt", "stored": "Tárolt",
"subs": "Feliratkozók", "subs": "Feliratkozók",
"lastUpload": "Utolsó feltöltés",
"length": "Hossz",
"types": "N/S/L",
"typesHint": "Videók típusonként: Normál / Short / Live",
"sync": "Szinkron", "sync": "Szinkron",
"tags": "Címkék", "tags": "Címkék",
"actions": "Műveletek" "actions": "Műveletek"
@ -51,6 +56,8 @@
"stored": "{{formatted}} tárolt", "stored": "{{formatted}} tárolt",
"subs": "{{formatted}} feliratkozó", "subs": "{{formatted}} feliratkozó",
"openOnYouTube": "Megnyitás a YouTube-on", "openOnYouTube": "Megnyitás a YouTube-on",
"editTags": "Címkék szerkesztése",
"filterFeedByTag": "Hírfolyam szűrése: „{{name}}”",
"priorityHint": "A te rangsorod ehhez a csatornához. Rendezd a hírfolyamot „Csatorna prioritás” szerint, hogy a magasabb prioritású csatornák felülre kerüljenek.", "priorityHint": "A te rangsorod ehhez a csatornához. Rendezd a hírfolyamot „Csatorna prioritás” szerint, hogy a magasabb prioritású csatornák felülre kerüljenek.",
"raisePriority": "Prioritás növelése", "raisePriority": "Prioritás növelése",
"lowerPriority": "Prioritás csökkentése", "lowerPriority": "Prioritás csökkentése",
@ -89,6 +96,7 @@
"fullHistoryFailed": "Nem sikerült teljes előzményt kérni", "fullHistoryFailed": "Nem sikerült teljes előzményt kérni",
"needYouTube": "Ehhez csatlakoztasd a YouTube-fiókod.", "needYouTube": "Ehhez csatlakoztasd a YouTube-fiókod.",
"connect": "Csatlakoztatás", "connect": "Csatlakoztatás",
"filteredByTag": "Hírfolyam szűrve címkére: {{name}}",
"resetDone": "Csatorna resetelve — újraletöltés folyamatban", "resetDone": "Csatorna resetelve — újraletöltés folyamatban",
"resetFailed": "Nem sikerült resetelni a csatornát" "resetFailed": "Nem sikerült resetelni a csatornát"
}, },

View file

@ -1,4 +1,8 @@
{ {
"title": "Nem sikerült",
"generic": "A szerver nem tudta végrehajtani a műveletet. Próbáld újra.",
"server": "Szerverhiba",
"ok": "OK",
"boundary": { "boundary": {
"title": "Hiba történt.", "title": "Hiba történt.",
"subtitle": "Az alkalmazás váratlan hibába ütközött.", "subtitle": "Az alkalmazás váratlan hibába ütközött.",

View file

@ -26,6 +26,7 @@
"clearDates": "dátumok törlése", "clearDates": "dátumok törlése",
"reshuffle": "Újrakeverés", "reshuffle": "Újrakeverés",
"noMatchingTags": "Nincs ide illő címke", "noMatchingTags": "Nincs ide illő címke",
"manageTags": "Kezelés",
"shareView": "Hivatkozás másolása erre a nézetre", "shareView": "Hivatkozás másolása erre a nézetre",
"shareCopied": "Nézet-hivatkozás a vágólapra másolva", "shareCopied": "Nézet-hivatkozás a vágólapra másolva",
"shareFailed": "Nem sikerült a hivatkozás másolása", "shareFailed": "Nem sikerült a hivatkozás másolása",
@ -35,7 +36,8 @@
"date": "Feltöltés dátuma", "date": "Feltöltés dátuma",
"content": "Tartalomtípus", "content": "Tartalomtípus",
"language": "Nyelv", "language": "Nyelv",
"topic": "Téma" "topic": "Téma",
"tags": "Saját címkék"
}, },
"show": { "show": {
"unwatched": "Nem nézett", "unwatched": "Nem nézett",

View file

@ -0,0 +1,12 @@
{
"title": "Címkék kezelése",
"channels": "{{count}} csat.",
"onChannels": "Címkézett csatornák",
"delete": "Törlés",
"deleted": "Címke törölve",
"deleteTitle": "Címke törlése",
"confirmDelete": "Törlöd a(z) „{{name}}” címkét? Minden csatornáról lekerül, amin rajta van.",
"empty": "Még nincs címke — adj hozzá lentebb.",
"newPlaceholder": "Új címke neve…",
"add": "Hozzáad"
}

View file

@ -1,4 +1,6 @@
import { notify } from "./notifications"; import { notify } from "./notifications";
import i18n from "../i18n";
import { reportError } from "./errorDialog";
export interface Me { export interface Me {
id: number; id: number;
@ -196,9 +198,14 @@ async function req(url: string, opts: RequestInit = {}): Promise<any> {
} catch { } catch {
/* no JSON body */ /* no JSON body */
} }
// Server faults are worth surfacing; 401/403/404 etc. are handled by callers. // Surface anything the server definitively refused as a self-explanatory dialog the
// user must acknowledge: 5xx (a fault) and 400/409/422 (validation/conflict, with the
// server's own reason). 401/403/404 are control flow handled by callers (auth, demo
// gating, not-found), so they just throw.
if (r.status >= 500) { if (r.status >= 500) {
notifyErrorThrottled(`Server error ${r.status}`, `${method} ${url}`); reportError(detail || `${i18n.t("errors.server")} (${r.status})`);
} else if (r.status === 400 || r.status === 409 || r.status === 422) {
reportError(detail);
} }
throw new HttpError(r.status, detail); throw new HttpError(r.status, detail);
} }
@ -293,6 +300,11 @@ export interface ManagedChannel {
subscriber_count: number | null; subscriber_count: number | null;
video_count: number | null; video_count: number | null;
stored_videos: number; stored_videos: number;
last_video_at: string | null;
total_duration_seconds: number;
count_normal: number;
count_short: number;
count_live: number;
priority: number; priority: number;
hidden: boolean; hidden: boolean;
deep_requested: boolean; deep_requested: boolean;
@ -360,6 +372,7 @@ export const api = {
req(`/api/facets?${filterParams(f).toString()}`), req(`/api/facets?${filterParams(f).toString()}`),
setState: (id: string, status: string) => setState: (id: string, status: string) =>
req(`/api/videos/${id}/state`, { method: "POST", body: JSON.stringify({ status }) }), req(`/api/videos/${id}/state`, { method: "POST", body: JSON.stringify({ status }) }),
clearState: (id: string) => req(`/api/videos/${id}/state`, { method: "DELETE" }),
saveProgress: (id: string, positionSeconds: number, durationSeconds: number) => saveProgress: (id: string, positionSeconds: number, durationSeconds: number) =>
req(`/api/videos/${id}/progress`, { req(`/api/videos/${id}/progress`, {
method: "POST", method: "POST",

View file

@ -0,0 +1,38 @@
import i18n from "../i18n";
// A single, self-explanatory error dialog (modal) the user must acknowledge — used when the
// server can't carry out an action (a definitive 4xx/5xx response). Distinct from toasts
// (transient, e.g. connection blips) and from silent handling. Module-level so the non-React
// api layer can raise it; an <ErrorDialog> mounted at the app root renders the current one.
export interface AppError {
title: string;
message: string;
}
let current: AppError | null = null;
let listeners: Array<() => void> = [];
const emit = () => listeners.forEach((l) => l());
export function reportError(message?: string, title?: string): void {
current = {
title: title || i18n.t("errors.title"),
message: message || i18n.t("errors.generic"),
};
emit();
}
export function dismissError(): void {
current = null;
emit();
}
export function subscribeError(listener: () => void): () => void {
listeners.push(listener);
return () => {
listeners = listeners.filter((l) => l !== listener);
};
}
export function getError(): AppError | null {
return current;
}

View file

@ -14,6 +14,21 @@ export interface ReleaseEntry {
} }
export const RELEASE_NOTES: ReleaseEntry[] = [ export const RELEASE_NOTES: ReleaseEntry[] = [
{
version: "0.8.0",
date: "2026-06-18",
summary: "Richer channel columns, a proper tag workflow, video reset, and clearer errors.",
features: [
"Channel manager: new sortable columns — last upload, total length, and a Normal/Shorts/Live breakdown — with subscriber counts shown compactly (e.g. 919K). Columns size to their content.",
"Tags, reworked: each channel row now shows only the tags you've attached, with a “+” to add or remove them per channel. A tag manager (open it from the Channel manager or the feed sidebar) lets you add, rename and delete tags — deleting only asks to confirm when a tag is actually in use.",
"Filter the feed by a tag: click a tag on a channel, and a new “Your tags” widget in the feed sidebar shows that filter so you can see and clear it. In the tag manager, hovering a tag's channel count lists those channels, each a link that jumps to it in the Channel manager.",
"Video cards get a reset action that returns a video to pristine — clearing watch progress and status, as if you'd never opened it.",
],
fixes: [
"When the server can't complete an action (for example a duplicate tag name), you now get a clear pop-up explaining why, instead of a cryptic failure.",
"Pressing Esc with a dialog stacked over another now closes only the top one, leaving your work underneath open.",
],
},
{ {
version: "0.7.0", version: "0.7.0",
date: "2026-06-17", date: "2026-06-17",

View file

@ -4,14 +4,15 @@
// `show`, `sort` and `content` moved to the feed toolbar (above the cards); they are no // `show`, `sort` and `content` moved to the feed toolbar (above the cards); they are no
// longer sidebar widgets. normalizeLayout drops them from any persisted layout automatically. // longer sidebar widgets. normalizeLayout drops them from any persisted layout automatically.
export type WidgetId = "date" | "language" | "topic"; export type WidgetId = "date" | "language" | "topic" | "tags";
export const ALL_WIDGETS: WidgetId[] = ["date", "language", "topic"]; export const ALL_WIDGETS: WidgetId[] = ["date", "language", "topic", "tags"];
export const WIDGET_TITLES: Record<WidgetId, string> = { export const WIDGET_TITLES: Record<WidgetId, string> = {
date: "Upload date", date: "Upload date",
language: "Language", language: "Language",
topic: "Topic", topic: "Topic",
tags: "Tags",
}; };
export interface SidebarLayout { export interface SidebarLayout {