diff --git a/VERSION b/VERSION
index faef31a..a3df0a6 100644
--- a/VERSION
+++ b/VERSION
@@ -1 +1 @@
-0.7.0
+0.8.0
diff --git a/backend/app/routes/channels.py b/backend/app/routes/channels.py
index 04ad3f7..309ef24 100644
--- a/backend/app/routes/channels.py
+++ b/backend/app/routes/channels.py
@@ -27,16 +27,33 @@ def list_channels(
.order_by(Subscription.priority.desc(), func.lower(Channel.title))
).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]
- stored: dict[str, int] = {}
+ agg: dict[str, dict] = {}
if channel_ids:
- for cid, count in db.execute(
- select(Video.channel_id, func.count(Video.id))
+ for cid, n, last_up, total_dur, shorts, live in db.execute(
+ 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))
.group_by(Video.channel_id)
).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
# 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,
"subscriber_count": ch.subscriber_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,
"hidden": sub.hidden,
"deep_requested": sub.deep_requested,
diff --git a/backend/app/routes/feed.py b/backend/app/routes/feed.py
index 8db08b6..04ef3be 100644
--- a/backend/app/routes/feed.py
+++ b/backend/app/routes/feed.py
@@ -435,6 +435,29 @@ def set_video_state(
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")
def set_video_progress(
video_id: str,
diff --git a/backend/app/routes/tags.py b/backend/app/routes/tags.py
index 1eb8546..96aac97 100644
--- a/backend/app/routes/tags.py
+++ b/backend/app/routes/tags.py
@@ -1,5 +1,6 @@
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy import func, or_, select
+from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session
from app.auth import current_user
@@ -49,7 +50,11 @@ def create_tag(
category=category if category in ("language", "topic", "other") else "other",
)
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}
@@ -76,7 +81,11 @@ def update_tag(
tag.name = name
if "color" in payload:
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}
diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx
index f3feb95..4bf2686 100644
--- a/frontend/src/App.tsx
+++ b/frontend/src/App.tsx
@@ -32,6 +32,7 @@ import SettingsPanel from "./components/SettingsPanel";
import OnboardingWizard from "./components/OnboardingWizard";
import { shouldAutoOpenOnboarding } from "./lib/onboarding";
import Toaster from "./components/Toaster";
+import ErrorDialog from "./components/ErrorDialog";
import About from "./components/About";
import ReleaseNotes from "./components/ReleaseNotes";
import VersionBanner from "./components/VersionBanner";
@@ -108,6 +109,16 @@ export default function App() {
const [aboutOpen, setAboutOpen] = useState(false);
const [notesOpen, setNotesOpen] = useState(false);
const [notesHighlight, setNotesHighlight] = useState(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(null);
+ const focusChannel = (name: string) => {
+ setFocusChannelName(name);
+ setPage("channels");
+ };
+ useEffect(() => {
+ if (page !== "channels") setFocusChannelName(null);
+ }, [page]);
function openReleaseNotes(highlight?: string) {
setNotesHighlight(highlight);
@@ -271,6 +282,7 @@ export default function App() {
setFilters={setFilters}
layout={sidebarLayout}
setLayout={setSidebarLayout}
+ onFocusChannel={focusChannel}
/>
)}
@@ -278,6 +290,8 @@ export default function App() {
setWizardOpen(true)}
@@ -285,6 +299,11 @@ export default function App() {
setFilters({ ...filters, channelId: id, channelName: name, show: "all" });
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" ? (
@@ -333,6 +352,7 @@ export default function App() {
{notesOpen && (
setNotesOpen(false)} highlight={notesHighlight} />
)}
+
);
}
diff --git a/frontend/src/components/Channels.tsx b/frontend/src/components/Channels.tsx
index e9eb9a0..ba06732 100644
--- a/frontend/src/components/Channels.tsx
+++ b/frontend/src/components/Channels.tsx
@@ -1,4 +1,4 @@
-import { useState } from "react";
+import { useEffect, useRef, useState } from "react";
import { Trans, useTranslation } from "react-i18next";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
@@ -9,21 +9,29 @@ import {
Eye,
EyeOff,
History,
+ Pencil,
Plus,
RefreshCw,
UserMinus,
- X,
} from "lucide-react";
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 Tooltip from "./Tooltip";
import Avatar from "./Avatar";
import DataTable, { type Column } from "./DataTable";
+import TagManager from "./TagManager";
import { useConfirm } from "./ConfirmProvider";
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 }[] = [
{ id: "all", labelKey: "channels.filters.all" },
{ id: "needs_full", labelKey: "channels.filters.needsFull" },
@@ -35,6 +43,9 @@ export default function Channels({
canWrite,
isAdmin,
onViewChannel,
+ onFilterByTag,
+ onFocusChannel,
+ focusChannelName,
statusFilter,
setStatusFilter,
onOpenWizard,
@@ -42,6 +53,9 @@ export default function Channels({
canWrite: boolean;
isAdmin: boolean;
onViewChannel: (id: string, name: string) => void;
+ onFilterByTag: (tagId: number, name: string) => void;
+ onFocusChannel: (name: string) => void;
+ focusChannelName: string | null;
statusFilter: ChannelStatusFilter;
setStatusFilter: (f: ChannelStatusFilter) => void;
onOpenWizard: () => void;
@@ -66,7 +80,7 @@ export default function Channels({
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 [newTag, setNewTag] = useState("");
+ const [tagManagerOpen, setTagManagerOpen] = useState(false);
const invalidate = () => {
qc.invalidateQueries({ queryKey: ["channels"] });
@@ -131,17 +145,6 @@ export default function Channels({
},
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({
mutationFn: (id: string) => api.unsubscribeChannel(id),
onSuccess: () => {
@@ -231,7 +234,7 @@ export default function Channels({
key: "stored",
header: t("channels.cols.stored"),
align: "right",
- width: "84px",
+ nowrap: true,
sortable: true,
sortValue: (c) => c.stored_videos,
render: (c) => {c.stored_videos.toLocaleString()},
@@ -240,26 +243,54 @@ export default function Channels({
key: "subs",
header: t("channels.cols.subs"),
align: "right",
- width: "92px",
+ nowrap: true,
sortable: true,
sortValue: (c) => c.subscriber_count ?? -1,
render: (c) => (
- {c.subscriber_count != null ? c.subscriber_count.toLocaleString() : "—"}
+ {c.subscriber_count != null ? formatViews(c.subscriber_count) : "—"}
),
},
+ {
+ 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) => {relativeTime(c.last_video_at) || "—"},
+ },
+ {
+ key: "length",
+ header: t("channels.cols.length"),
+ align: "right",
+ nowrap: true,
+ sortable: true,
+ sortValue: (c) => c.total_duration_seconds,
+ render: (c) => {fmtTotalDuration(c.total_duration_seconds)},
+ },
+ {
+ key: "types",
+ header: t("channels.cols.types"),
+ nowrap: true,
+ render: (c) => (
+
+
+ {c.count_normal} / {c.count_short} / {c.count_live}
+
+
+ ),
+ },
{
key: "sync",
header: t("channels.cols.sync"),
- width: "130px",
+ nowrap: true,
cardLabel: false,
render: (c) => ,
},
{
key: "tags",
header: t("channels.cols.tags"),
- width: "360px",
cardLabel: false,
filter: {
kind: "multi",
@@ -272,6 +303,7 @@ export default function Channels({
c={c}
userTags={userTags}
onToggleTag={(tagId) => toggleTag(c.id, tagId, c.tag_ids.includes(tagId))}
+ onTagClick={onFilterByTag}
/>
),
},
@@ -279,6 +311,7 @@ export default function Channels({
key: "actions",
header: t("channels.cols.actions"),
align: "right",
+ nowrap: true,
width: "104px",
cardLabel: false,
render: (c) => (
@@ -391,42 +424,32 @@ export default function Channels({
/>
- {/* Your tags */}
+ {/* Your tags — read-only overview; add/rename/delete live in the manager dialog. */}
{t("channels.tags.yourTags")}
- {userTags.map((t) => (
+ {userTags.map((tg) => (
- {t.name}
-
+ {tg.name}
))}
-
+
+ {t("channels.tags.manage")}
+
+ {tagManagerOpen && (
+ setTagManagerOpen(false)} onFocusChannel={onFocusChannel} />
+ )}
{/* Channel table */}
{channelsQuery.isLoading ? (
@@ -439,6 +462,7 @@ export default function Channels({
persistKey="siftlode.channelsTable"
controlsPosition="top"
controlsLeading={statusChips}
+ externalFilter={focusChannelName ? { key: "channel", value: focusChannelName } : null}
rowClassName={(c) => (c.hidden ? "opacity-60" : "")}
emptyText={t("channels.empty")}
/>
@@ -569,30 +593,79 @@ function TagsCell({
c,
userTags,
onToggleTag,
+ onTagClick,
}: {
c: ManagedChannel;
userTags: Tag[];
onToggleTag: (tagId: number) => void;
+ onTagClick: (tagId: number, name: string) => void;
}) {
+ const { t } = useTranslation();
+ const [open, setOpen] = useState(false);
+ const ref = useRef(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;
+ // 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 (
-
- {userTags.map((tg) => {
- const on = c.tag_ids.includes(tg.id);
- return (
-
- );
- })}
+
+ {attached.map((tg) => (
+
+ ))}
+
+ {open && (
+
+ {userTags.map((tg) => {
+ const on = c.tag_ids.includes(tg.id);
+ return (
+
+ );
+ })}
+
+ )}
);
}
diff --git a/frontend/src/components/DataTable.tsx b/frontend/src/components/DataTable.tsx
index ef1d5a8..f2e729d 100644
--- a/frontend/src/components/DataTable.tsx
+++ b/frontend/src/components/DataTable.tsx
@@ -22,6 +22,8 @@ export interface Column
{
sortable?: boolean;
sortValue?: (row: T) => string | number;
filter?: ColumnFilter;
+ // 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`
// omits the column; `cardLabel:false` shows the value without its header label.
cardPrimary?: boolean;
@@ -64,6 +66,7 @@ export default function DataTable({
rowClassName,
controlsPosition = "bottom",
controlsLeading,
+ externalFilter,
}: {
rows: T[];
columns: Column[];
@@ -75,6 +78,9 @@ export default function DataTable({
rowClassName?: (row: T) => string;
controlsPosition?: "top" | "bottom" | "both";
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 initial = loadPersist(persistKey);
@@ -91,6 +97,13 @@ export default function DataTable({
if (persistKey) localStorage.setItem(persistKey, JSON.stringify({ 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(() => {
if (!openFilter) return;
function onDown(e: MouseEvent) {
@@ -325,7 +338,7 @@ export default function DataTable({
+ {onResetState && resettable && (
+ {
+ 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"
+ >
+
+
+ )}
{onChannelFilter && (
{
@@ -224,6 +242,7 @@ function VideoCard({
video,
view,
onState,
+ onResetState,
onToggleSave,
onChannelFilter,
onOpen,
@@ -231,6 +250,7 @@ function VideoCard({
video: Video;
view: "grid" | "list";
onState: (id: string, status: string) => void;
+ onResetState?: (id: string) => void;
onToggleSave: (id: string, saved: boolean) => void;
onChannelFilter?: (channelId: string, channelName: string) => void;
onOpen?: (v: Video, startAt?: number | null) => void;
@@ -294,7 +314,7 @@ function VideoCard({
{meta}
-
+
);
}
@@ -325,7 +345,7 @@ function VideoCard({
{video.channel_title}
{meta}
-
+
diff --git a/frontend/src/i18n/locales/de/card.json b/frontend/src/i18n/locales/de/card.json
index 93076be..ff3e4e7 100644
--- a/frontend/src/i18n/locales/de/card.json
+++ b/frontend/src/i18n/locales/de/card.json
@@ -8,6 +8,7 @@
"unhide": "Einblenden",
"hide": "Ausblenden",
"onlyThisChannel": "Nur dieser Kanal",
+ "resetState": "Zurücksetzen — Fortschritt/Status löschen",
"thisChannel": "Dieser Kanal",
"continueTitle": "Dort fortsetzen, wo du aufgehört hast",
"continue": "Fortsetzen",
diff --git a/frontend/src/i18n/locales/de/channels.json b/frontend/src/i18n/locales/de/channels.json
index 94bfd9a..b559115 100644
--- a/frontend/src/i18n/locales/de/channels.json
+++ b/frontend/src/i18n/locales/de/channels.json
@@ -13,6 +13,7 @@
},
"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.)",
"newTag": "neuer Tag",
"createTag": "Tag erstellen"
@@ -29,6 +30,10 @@
"channel": "Kanal",
"stored": "Gespeichert",
"subs": "Abonnenten",
+ "lastUpload": "Letzter Upload",
+ "length": "Länge",
+ "types": "N/S/L",
+ "typesHint": "Videos nach Typ: Normale / Shorts / Live",
"sync": "Sync",
"tags": "Tags",
"actions": "Aktionen"
@@ -51,6 +56,8 @@
"stored": "{{formatted}} gespeichert",
"subs": "{{formatted}} Abonnenten",
"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.",
"raisePriority": "Priorität erhöhen",
"lowerPriority": "Priorität senken",
@@ -89,6 +96,7 @@
"fullHistoryFailed": "Vollständiger Verlauf konnte nicht angefordert werden",
"needYouTube": "Verbinde dein YouTube-Konto, um dies zu tun.",
"connect": "Verbinden",
+ "filteredByTag": "Feed nach Tag gefiltert: {{name}}",
"resetDone": "Kanal zurückgesetzt — wird neu geladen",
"resetFailed": "Kanal konnte nicht zurückgesetzt werden"
},
diff --git a/frontend/src/i18n/locales/de/errors.json b/frontend/src/i18n/locales/de/errors.json
index 2a78d52..799a006 100644
--- a/frontend/src/i18n/locales/de/errors.json
+++ b/frontend/src/i18n/locales/de/errors.json
@@ -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": {
"title": "Etwas ist schiefgelaufen.",
"subtitle": "Die App ist auf einen unerwarteten Fehler gestoßen.",
diff --git a/frontend/src/i18n/locales/de/sidebar.json b/frontend/src/i18n/locales/de/sidebar.json
index 2668e7b..2bb2de5 100644
--- a/frontend/src/i18n/locales/de/sidebar.json
+++ b/frontend/src/i18n/locales/de/sidebar.json
@@ -26,6 +26,7 @@
"clearDates": "Daten löschen",
"reshuffle": "Neu mischen",
"noMatchingTags": "Keine passenden Tags",
+ "manageTags": "Verwalten",
"shareView": "Link zu dieser Ansicht kopieren",
"shareCopied": "Ansichts-Link in die Zwischenablage kopiert",
"shareFailed": "Link konnte nicht kopiert werden",
@@ -35,7 +36,8 @@
"date": "Upload-Datum",
"content": "Inhaltstyp",
"language": "Sprache",
- "topic": "Thema"
+ "topic": "Thema",
+ "tags": "Deine Tags"
},
"show": {
"unwatched": "Ungesehen",
diff --git a/frontend/src/i18n/locales/de/tagManager.json b/frontend/src/i18n/locales/de/tagManager.json
new file mode 100644
index 0000000..a5a86d3
--- /dev/null
+++ b/frontend/src/i18n/locales/de/tagManager.json
@@ -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"
+}
diff --git a/frontend/src/i18n/locales/en/card.json b/frontend/src/i18n/locales/en/card.json
index c5fac09..2f43a26 100644
--- a/frontend/src/i18n/locales/en/card.json
+++ b/frontend/src/i18n/locales/en/card.json
@@ -8,6 +8,7 @@
"unhide": "Unhide",
"hide": "Hide",
"onlyThisChannel": "Only this channel",
+ "resetState": "Reset — clear watch progress/status",
"thisChannel": "This channel",
"continueTitle": "Continue where you left off",
"continue": "Continue",
diff --git a/frontend/src/i18n/locales/en/channels.json b/frontend/src/i18n/locales/en/channels.json
index f5adebd..80d1b11 100644
--- a/frontend/src/i18n/locales/en/channels.json
+++ b/frontend/src/i18n/locales/en/channels.json
@@ -13,6 +13,7 @@
},
"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.)",
"newTag": "new tag",
"createTag": "Create tag"
@@ -29,6 +30,10 @@
"channel": "Channel",
"stored": "Stored",
"subs": "Subscribers",
+ "lastUpload": "Last upload",
+ "length": "Length",
+ "types": "N/S/L",
+ "typesHint": "Videos by type: Normal / Shorts / Live",
"sync": "Sync",
"tags": "Tags",
"actions": "Actions"
@@ -51,6 +56,8 @@
"stored": "{{formatted}} stored",
"subs": "{{formatted}} subs",
"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.",
"raisePriority": "Raise priority",
"lowerPriority": "Lower priority",
@@ -89,6 +96,7 @@
"fullHistoryFailed": "Couldn't request full history",
"needYouTube": "Connect your YouTube account to do this.",
"connect": "Connect",
+ "filteredByTag": "Feed filtered by tag: {{name}}",
"resetDone": "Channel reset — re-fetching now",
"resetFailed": "Couldn't reset this channel"
},
diff --git a/frontend/src/i18n/locales/en/errors.json b/frontend/src/i18n/locales/en/errors.json
index 797c0c2..f22b32a 100644
--- a/frontend/src/i18n/locales/en/errors.json
+++ b/frontend/src/i18n/locales/en/errors.json
@@ -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": {
"title": "Something went wrong.",
"subtitle": "The app hit an unexpected error.",
diff --git a/frontend/src/i18n/locales/en/sidebar.json b/frontend/src/i18n/locales/en/sidebar.json
index 984af9c..3e36a6d 100644
--- a/frontend/src/i18n/locales/en/sidebar.json
+++ b/frontend/src/i18n/locales/en/sidebar.json
@@ -26,6 +26,7 @@
"clearDates": "clear dates",
"reshuffle": "Reshuffle",
"noMatchingTags": "No matching tags here",
+ "manageTags": "Manage",
"shareView": "Copy a link to this view",
"shareCopied": "View link copied to clipboard",
"shareFailed": "Couldn't copy the link",
@@ -35,7 +36,8 @@
"date": "Upload date",
"content": "Content type",
"language": "Language",
- "topic": "Topic"
+ "topic": "Topic",
+ "tags": "Your tags"
},
"show": {
"unwatched": "Unwatched",
diff --git a/frontend/src/i18n/locales/en/tagManager.json b/frontend/src/i18n/locales/en/tagManager.json
new file mode 100644
index 0000000..89ffd64
--- /dev/null
+++ b/frontend/src/i18n/locales/en/tagManager.json
@@ -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"
+}
diff --git a/frontend/src/i18n/locales/hu/card.json b/frontend/src/i18n/locales/hu/card.json
index 8141b46..e80d09b 100644
--- a/frontend/src/i18n/locales/hu/card.json
+++ b/frontend/src/i18n/locales/hu/card.json
@@ -8,6 +8,7 @@
"unhide": "Megjelenítés",
"hide": "Elrejtés",
"onlyThisChannel": "Csak ez a csatorna",
+ "resetState": "Alaphelyzet — nézési előzmény/státusz törlése",
"thisChannel": "Ez a csatorna",
"continueTitle": "Folytatás ott, ahol abbahagytad",
"continue": "Folytatás",
diff --git a/frontend/src/i18n/locales/hu/channels.json b/frontend/src/i18n/locales/hu/channels.json
index 802bb13..3b43b30 100644
--- a/frontend/src/i18n/locales/hu/channels.json
+++ b/frontend/src/i18n/locales/hu/channels.json
@@ -13,6 +13,7 @@
},
"tags": {
"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.)",
"newTag": "új címke",
"createTag": "Címke létrehozása"
@@ -29,6 +30,10 @@
"channel": "Csatorna",
"stored": "Tárolt",
"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",
"tags": "Címkék",
"actions": "Műveletek"
@@ -51,6 +56,8 @@
"stored": "{{formatted}} tárolt",
"subs": "{{formatted}} feliratkozó",
"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.",
"raisePriority": "Prioritás növelése",
"lowerPriority": "Prioritás csökkentése",
@@ -89,6 +96,7 @@
"fullHistoryFailed": "Nem sikerült teljes előzményt kérni",
"needYouTube": "Ehhez csatlakoztasd a YouTube-fiókod.",
"connect": "Csatlakoztatás",
+ "filteredByTag": "Hírfolyam szűrve címkére: {{name}}",
"resetDone": "Csatorna resetelve — újraletöltés folyamatban",
"resetFailed": "Nem sikerült resetelni a csatornát"
},
diff --git a/frontend/src/i18n/locales/hu/errors.json b/frontend/src/i18n/locales/hu/errors.json
index b224a13..b3c7bc3 100644
--- a/frontend/src/i18n/locales/hu/errors.json
+++ b/frontend/src/i18n/locales/hu/errors.json
@@ -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": {
"title": "Hiba történt.",
"subtitle": "Az alkalmazás váratlan hibába ütközött.",
diff --git a/frontend/src/i18n/locales/hu/sidebar.json b/frontend/src/i18n/locales/hu/sidebar.json
index 28425ab..030a877 100644
--- a/frontend/src/i18n/locales/hu/sidebar.json
+++ b/frontend/src/i18n/locales/hu/sidebar.json
@@ -26,6 +26,7 @@
"clearDates": "dátumok törlése",
"reshuffle": "Újrakeverés",
"noMatchingTags": "Nincs ide illő címke",
+ "manageTags": "Kezelés",
"shareView": "Hivatkozás másolása erre a nézetre",
"shareCopied": "Nézet-hivatkozás a vágólapra másolva",
"shareFailed": "Nem sikerült a hivatkozás másolása",
@@ -35,7 +36,8 @@
"date": "Feltöltés dátuma",
"content": "Tartalomtípus",
"language": "Nyelv",
- "topic": "Téma"
+ "topic": "Téma",
+ "tags": "Saját címkék"
},
"show": {
"unwatched": "Nem nézett",
diff --git a/frontend/src/i18n/locales/hu/tagManager.json b/frontend/src/i18n/locales/hu/tagManager.json
new file mode 100644
index 0000000..6ee27a5
--- /dev/null
+++ b/frontend/src/i18n/locales/hu/tagManager.json
@@ -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"
+}
diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts
index e13f097..12d4d82 100644
--- a/frontend/src/lib/api.ts
+++ b/frontend/src/lib/api.ts
@@ -1,4 +1,6 @@
import { notify } from "./notifications";
+import i18n from "../i18n";
+import { reportError } from "./errorDialog";
export interface Me {
id: number;
@@ -196,9 +198,14 @@ async function req(url: string, opts: RequestInit = {}): Promise {
} catch {
/* 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) {
- 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);
}
@@ -293,6 +300,11 @@ export interface ManagedChannel {
subscriber_count: number | null;
video_count: number | null;
stored_videos: number;
+ last_video_at: string | null;
+ total_duration_seconds: number;
+ count_normal: number;
+ count_short: number;
+ count_live: number;
priority: number;
hidden: boolean;
deep_requested: boolean;
@@ -360,6 +372,7 @@ export const api = {
req(`/api/facets?${filterParams(f).toString()}`),
setState: (id: string, status: string) =>
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) =>
req(`/api/videos/${id}/progress`, {
method: "POST",
diff --git a/frontend/src/lib/errorDialog.ts b/frontend/src/lib/errorDialog.ts
new file mode 100644
index 0000000..63c3c66
--- /dev/null
+++ b/frontend/src/lib/errorDialog.ts
@@ -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 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;
+}
diff --git a/frontend/src/lib/releaseNotes.ts b/frontend/src/lib/releaseNotes.ts
index df9aa0a..84b1f85 100644
--- a/frontend/src/lib/releaseNotes.ts
+++ b/frontend/src/lib/releaseNotes.ts
@@ -14,6 +14,21 @@ export interface 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",
date: "2026-06-17",
diff --git a/frontend/src/lib/sidebarLayout.ts b/frontend/src/lib/sidebarLayout.ts
index bb4f922..31e8335 100644
--- a/frontend/src/lib/sidebarLayout.ts
+++ b/frontend/src/lib/sidebarLayout.ts
@@ -4,14 +4,15 @@
// `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.
-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 = {
date: "Upload date",
language: "Language",
topic: "Topic",
+ tags: "Tags",
};
export interface SidebarLayout {
|