Merge improvement/tags-ux: tag UX overhaul, user-tags feed widget, video reset, global error dialog
This commit is contained in:
commit
9c07698647
29 changed files with 547 additions and 54 deletions
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -9,10 +9,10 @@ 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, formatViews, relativeTime } from "../lib/format";
|
import { formatEta, formatViews, relativeTime } from "../lib/format";
|
||||||
|
|
@ -20,6 +20,7 @@ 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";
|
||||||
|
|
@ -42,6 +43,9 @@ export default function Channels({
|
||||||
canWrite,
|
canWrite,
|
||||||
isAdmin,
|
isAdmin,
|
||||||
onViewChannel,
|
onViewChannel,
|
||||||
|
onFilterByTag,
|
||||||
|
onFocusChannel,
|
||||||
|
focusChannelName,
|
||||||
statusFilter,
|
statusFilter,
|
||||||
setStatusFilter,
|
setStatusFilter,
|
||||||
onOpenWizard,
|
onOpenWizard,
|
||||||
|
|
@ -49,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;
|
||||||
|
|
@ -73,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"] });
|
||||||
|
|
@ -138,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: () => {
|
||||||
|
|
@ -307,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}
|
||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
|
|
@ -427,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 ? (
|
||||||
|
|
@ -475,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")}
|
||||||
/>
|
/>
|
||||||
|
|
@ -605,10 +593,12 @@ 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 { t } = useTranslation();
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
|
|
@ -634,12 +624,14 @@ function TagsCell({
|
||||||
return (
|
return (
|
||||||
<div ref={ref} className="relative flex flex-wrap items-center gap-1 min-w-[160px]">
|
<div ref={ref} className="relative flex flex-wrap items-center gap-1 min-w-[160px]">
|
||||||
{attached.map((tg) => (
|
{attached.map((tg) => (
|
||||||
<span
|
<button
|
||||||
key={tg.id}
|
key={tg.id}
|
||||||
className="text-[10px] px-1.5 py-0.5 rounded-full bg-accent text-accent-fg border border-accent"
|
onClick={() => 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}
|
{tg.name}
|
||||||
</span>
|
</button>
|
||||||
))}
|
))}
|
||||||
<button
|
<button
|
||||||
onClick={() => setOpen((o) => !o)}
|
onClick={() => setOpen((o) => !o)}
|
||||||
|
|
|
||||||
|
|
@ -66,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>[];
|
||||||
|
|
@ -77,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);
|
||||||
|
|
@ -93,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) {
|
||||||
|
|
|
||||||
24
frontend/src/components/ErrorDialog.tsx
Normal file
24
frontend/src/components/ErrorDialog.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
213
frontend/src/components/TagManager.tsx
Normal file
213
frontend/src/components/TagManager.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
@ -56,6 +57,7 @@
|
||||||
"subs": "{{formatted}} Abonnenten",
|
"subs": "{{formatted}} Abonnenten",
|
||||||
"openOnYouTube": "Auf YouTube öffnen",
|
"openOnYouTube": "Auf YouTube öffnen",
|
||||||
"editTags": "Tags bearbeiten",
|
"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",
|
||||||
|
|
@ -94,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"
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -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.",
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
12
frontend/src/i18n/locales/de/tagManager.json
Normal file
12
frontend/src/i18n/locales/de/tagManager.json
Normal 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"
|
||||||
|
}
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
@ -56,6 +57,7 @@
|
||||||
"subs": "{{formatted}} subs",
|
"subs": "{{formatted}} subs",
|
||||||
"openOnYouTube": "Open on YouTube",
|
"openOnYouTube": "Open on YouTube",
|
||||||
"editTags": "Edit tags",
|
"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",
|
||||||
|
|
@ -94,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"
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -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.",
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
12
frontend/src/i18n/locales/en/tagManager.json
Normal file
12
frontend/src/i18n/locales/en/tagManager.json
Normal 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"
|
||||||
|
}
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
@ -56,6 +57,7 @@
|
||||||
"subs": "{{formatted}} feliratkozó",
|
"subs": "{{formatted}} feliratkozó",
|
||||||
"openOnYouTube": "Megnyitás a YouTube-on",
|
"openOnYouTube": "Megnyitás a YouTube-on",
|
||||||
"editTags": "Címkék szerkesztése",
|
"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",
|
||||||
|
|
@ -94,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"
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -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.",
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
12
frontend/src/i18n/locales/hu/tagManager.json
Normal file
12
frontend/src/i18n/locales/hu/tagManager.json
Normal 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"
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
}
|
}
|
||||||
|
|
@ -365,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",
|
||||||
|
|
|
||||||
38
frontend/src/lib/errorDialog.ts
Normal file
38
frontend/src/lib/errorDialog.ts
Normal 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;
|
||||||
|
}
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue