From d33a109ea25567149b29a1df0f96472b3153d97a Mon Sep 17 00:00:00 2001
From: npeter83
Date: Thu, 18 Jun 2026 01:17:31 +0200
Subject: [PATCH] feat(tags): tag UX overhaul + per-card video reset
- Channel-row tags show only what's attached; a '+' opens a per-channel picker. Clicking a tag
filters the feed by it; a 'Your tags' sidebar widget makes that filter visible/clearable.
- Tag manager dialog (add/rename/delete; delete only confirms when the tag is in use), reachable
from the Channel manager and the feed sidebar; hovering a tag's count lists its channels, each a
link that focuses it in the Channel manager (DataTable gains an external filter input).
- Video cards get a reset action that clears all watch state (incl. an in-progress position).
- api.req() now raises the error dialog on 5xx and 400/409/422 with the server's reason.
---
backend/app/routes/feed.py | 23 ++
frontend/src/App.tsx | 20 ++
frontend/src/components/Channels.tsx | 72 +++----
frontend/src/components/DataTable.tsx | 11 +
frontend/src/components/Feed.tsx | 21 ++
frontend/src/components/Sidebar.tsx | 33 +++
frontend/src/components/TagManager.tsx | 213 +++++++++++++++++++
frontend/src/components/VideoCard.tsx | 24 ++-
frontend/src/i18n/locales/de/card.json | 1 +
frontend/src/i18n/locales/de/channels.json | 3 +
frontend/src/i18n/locales/de/sidebar.json | 4 +-
frontend/src/i18n/locales/de/tagManager.json | 12 ++
frontend/src/i18n/locales/en/card.json | 1 +
frontend/src/i18n/locales/en/channels.json | 3 +
frontend/src/i18n/locales/en/sidebar.json | 4 +-
frontend/src/i18n/locales/en/tagManager.json | 12 ++
frontend/src/i18n/locales/hu/card.json | 1 +
frontend/src/i18n/locales/hu/channels.json | 3 +
frontend/src/i18n/locales/hu/sidebar.json | 4 +-
frontend/src/i18n/locales/hu/tagManager.json | 12 ++
frontend/src/lib/api.ts | 12 +-
frontend/src/lib/sidebarLayout.ts | 5 +-
22 files changed, 445 insertions(+), 49 deletions(-)
create mode 100644 frontend/src/components/TagManager.tsx
create mode 100644 frontend/src/i18n/locales/de/tagManager.json
create mode 100644 frontend/src/i18n/locales/en/tagManager.json
create mode 100644 frontend/src/i18n/locales/hu/tagManager.json
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/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 5a4fb45..ba06732 100644
--- a/frontend/src/components/Channels.tsx
+++ b/frontend/src/components/Channels.tsx
@@ -9,10 +9,10 @@ import {
Eye,
EyeOff,
History,
+ Pencil,
Plus,
RefreshCw,
UserMinus,
- X,
} from "lucide-react";
import { api, HttpError, type ManagedChannel, type Tag } from "../lib/api";
import { formatEta, formatViews, relativeTime } from "../lib/format";
@@ -20,6 +20,7 @@ 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";
@@ -42,6 +43,9 @@ export default function Channels({
canWrite,
isAdmin,
onViewChannel,
+ onFilterByTag,
+ onFocusChannel,
+ focusChannelName,
statusFilter,
setStatusFilter,
onOpenWizard,
@@ -49,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;
@@ -73,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"] });
@@ -138,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: () => {
@@ -307,6 +303,7 @@ export default function Channels({
c={c}
userTags={userTags}
onToggleTag={(tagId) => toggleTag(c.id, tagId, c.tag_ids.includes(tagId))}
+ onTagClick={onFilterByTag}
/>
),
},
@@ -427,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 ? (
@@ -475,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")}
/>
@@ -605,10 +593,12 @@ 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);
@@ -634,12 +624,14 @@ function TagsCell({
return (
{attached.map((tg) => (
-
onTagClick(tg.id, tg.name)}
+ title={t("channels.row.filterFeedByTag", { name: tg.name })}
+ className="text-[10px] px-1.5 py-0.5 rounded-full bg-accent text-accent-fg border border-accent hover:opacity-80 transition"
>
{tg.name}
-
+
))}
+ {tagManagerOpen && (
+ setTagManagerOpen(false)} onFocusChannel={onFocusChannel} />
+ )}
);
}
diff --git a/frontend/src/components/TagManager.tsx b/frontend/src/components/TagManager.tsx
new file mode 100644
index 0000000..5999728
--- /dev/null
+++ b/frontend/src/components/TagManager.tsx
@@ -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 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(null);
+ const closeTimer = useRef(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 (
+
+
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"
+ />
+
+ {t("tagManager.channels", { count: tag.channel_count })}
+
+
+
+
+ {open &&
+ createPortal(
+
+
+ {t("tagManager.onChannels")}
+
+ {channels.map((ch) => (
+
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}
+
+ ))}
+
,
+ document.body
+ )}
+
+ );
+}
+
+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 (
+
+
+ {userTags.length === 0 ? (
+
{t("tagManager.empty")}
+ ) : (
+ // Cap at ~8 rows tall, then scroll — the list can grow long.
+
+ {userTags.map((tag) => (
+ 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);
+ }}
+ />
+ ))}
+
+ )}
+
+
+
+ );
+}
diff --git a/frontend/src/components/VideoCard.tsx b/frontend/src/components/VideoCard.tsx
index 8f5d317..5087913 100644
--- a/frontend/src/components/VideoCard.tsx
+++ b/frontend/src/components/VideoCard.tsx
@@ -19,11 +19,13 @@ import { formatDuration, formatViews, relativeTime } from "../lib/format";
function Actions({
video,
onState,
+ onResetState,
onToggleSave,
onChannelFilter,
}: {
video: Video;
onState: (id: string, status: string) => void;
+ onResetState?: (id: string) => void;
onToggleSave: (id: string, saved: boolean) => void;
onChannelFilter?: (channelId: string, channelName: string) => void;
}) {
@@ -33,6 +35,9 @@ function Actions({
e.stopPropagation();
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) => {
e.preventDefault();
e.stopPropagation();
@@ -79,6 +84,19 @@ function Actions({
)}
+ {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 fca4e28..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"
@@ -56,6 +57,7 @@
"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",
@@ -94,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/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 182599b..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"
@@ -56,6 +57,7 @@
"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",
@@ -94,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/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 b40c0b8..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"
@@ -56,6 +57,7 @@
"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",
@@ -94,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/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 aa17661..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);
}
@@ -365,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/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 {