Merge: promote dev to prod

This commit is contained in:
npeter83 2026-06-17 19:18:19 +02:00
commit 0edd7af45d
25 changed files with 1283 additions and 318 deletions

View file

@ -1 +1 @@
0.6.0
0.7.0

View file

@ -9,6 +9,7 @@ from app import quota
from app.auth import current_user, has_write_scope
from app.db import get_db
from app.models import Channel, ChannelTag, Subscription, Tag, User, Video
from app.routes.admin import admin_user
from app.sync.runner import run_recent_backfill
from app.youtube.client import YouTubeClient, YouTubeError
@ -134,6 +135,30 @@ def update_channel(
}
@router.post("/{channel_id}/reset-backfill")
def reset_backfill(
channel_id: str,
user: User = Depends(admin_user),
db: Session = Depends(get_db),
) -> dict:
"""Admin-only reset: re-fetch this channel from scratch regardless of its current sync
state (a "reset" trigger). Clears the channel's backfill markers, opts it back into deep
backfill, and re-runs its recent pull immediately; the deep scheduler re-pages the full
back-catalog on its next run. Idempotent videos upsert by id, so nothing duplicates."""
channel = db.get(Channel, channel_id)
if channel is None:
raise HTTPException(status_code=404, detail="Unknown channel")
sub = _user_subscription(db, user, channel_id)
channel.backfill_done = False
channel.backfill_cursor = None
channel.recent_synced_at = None
sub.deep_requested = True
db.commit()
with quota.attribute(user.id, "backfill_recent"):
run_recent_backfill(db, [channel], max_channels=1)
return {"id": channel_id, "reset": True}
@router.delete("/{channel_id}/subscription")
def unsubscribe(
channel_id: str,

View file

@ -2,7 +2,9 @@ from datetime import date, datetime, timedelta, timezone
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy import Select, and_, false, func, or_, select
from sqlalchemy.dialects.postgresql import insert as pg_insert
from sqlalchemy.orm import Session, aliased
from sqlalchemy.orm.exc import StaleDataError
from app import quota
from app.auth import current_user
@ -387,29 +389,48 @@ def set_video_state(
if db.get(Video, video_id) is None:
raise HTTPException(status_code=404, detail="Unknown video")
now = datetime.now(timezone.utc)
if status == "new":
# Un-marking: keep the row if it still holds a resume position (un-marking
# "watched" should restore the in-progress state, not wipe where the user left
# off); otherwise drop it. The in-app player fires this alongside a progress
# checkpoint that may DELETE the same row, so tolerate it vanishing under us.
row = db.execute(
select(VideoState).where(
VideoState.user_id == user.id, VideoState.video_id == video_id
)
).scalar_one_or_none()
if status == "new":
if row is not None:
# Keep the row if it still holds a resume position (un-marking "watched"
# should restore the in-progress state, not wipe where the user left off).
if row.position_seconds:
row.status = "new"
row.watched_at = None
else:
db.delete(row)
try:
db.commit()
except StaleDataError:
db.rollback()
return {"video_id": video_id, "status": "new"}
if row is None:
row = VideoState(user_id=user.id, video_id=video_id)
db.add(row)
row.status = status
row.watched_at = datetime.now(timezone.utc) if status == "watched" else row.watched_at
# watched / hidden: atomic upsert. The player marks "watched" at end-of-playback at
# the same instant a progress checkpoint may DELETE the in-progress row; a plain
# SELECT-then-UPDATE then raises StaleDataError ("0 rows matched") on that race. An
# INSERT … ON CONFLICT DO UPDATE keyed on uq_user_video is immune to it.
set_: dict = {"status": status, "updated_at": now}
if status == "watched":
set_["watched_at"] = now # hidden keeps any existing watched_at
stmt = (
pg_insert(VideoState)
.values(
user_id=user.id,
video_id=video_id,
status=status,
watched_at=now if status == "watched" else None,
)
.on_conflict_do_update(constraint="uq_user_video", set_=set_)
)
db.execute(stmt)
db.commit()
return {"video_id": video_id, "status": status}
@ -455,7 +476,12 @@ def set_video_progress(
else:
row.position_seconds = 0
row.progress_updated_at = datetime.now(timezone.utc)
# A concurrent state change (e.g. the end-of-playback "watched" mark) may
# have removed/updated this row already — tolerate the lost race.
try:
db.commit()
except StaleDataError:
db.rollback()
return {"video_id": video_id, "position_seconds": 0}
if row is None:

View file

@ -86,14 +86,25 @@ function loadInitialFilters(): FeedFilters {
}
export default function App() {
const { t } = useTranslation();
const { t, i18n } = useTranslation();
const [theme, setThemeState] = useState<ThemePrefs>(() => loadLocalTheme());
const [filters, setFiltersState] = useState<FeedFilters>(loadInitialFilters);
const [view, setView] = useState<"grid" | "list">("grid");
const [sidebarLayout, setSidebarLayoutState] = useState<SidebarLayout>(loadLayout);
const [page, setPageState] = useState<Page>(loadInitialPage);
const [wizardOpen, setWizardOpen] = useState(false);
const [channelFilter, setChannelFilter] = useState<ChannelStatusFilter>("all");
const CHANNEL_FILTER_KEY = "siftlode.channelFilter";
const [channelFilter, setChannelFilterState] = useState<ChannelStatusFilter>(() => {
const v = localStorage.getItem(CHANNEL_FILTER_KEY);
return v === "needs_full" || v === "fully_synced" || v === "hidden" || v === "all"
? v
: "all";
});
// Persist the channel status chip so a reload (F5) keeps it.
const setChannelFilter = (f: ChannelStatusFilter) => {
setChannelFilterState(f);
localStorage.setItem(CHANNEL_FILTER_KEY, f);
};
const [aboutOpen, setAboutOpen] = useState(false);
const [notesOpen, setNotesOpen] = useState(false);
const [notesHighlight, setNotesHighlight] = useState<string | undefined>(undefined);
@ -235,14 +246,17 @@ export default function App() {
page={page}
setPage={setPage}
onOpenAbout={() => setAboutOpen(true)}
onChangeLanguage={changeLanguage}
language={i18n.language as LangCode}
filters={filters}
setFilters={setFilters}
/>
<div className="flex-1 min-w-0 flex flex-col">
<div className="relative flex-1 min-w-0 flex flex-col">
<Header
me={meQuery.data!}
filters={filters}
setFilters={setFilters}
page={page}
onChangeLanguage={changeLanguage}
onGoToFullHistory={() => {
setChannelFilter("needs_full");
setPage("channels");
@ -263,6 +277,7 @@ export default function App() {
{page === "channels" ? (
<Channels
canWrite={meQuery.data!.can_write}
isAdmin={meQuery.data!.role === "admin"}
statusFilter={channelFilter}
setStatusFilter={setChannelFilter}
onOpenWizard={() => setWizardOpen(true)}
@ -298,6 +313,10 @@ export default function App() {
)}
</main>
</div>
{/* Toasts rise from the bottom-left, by the notification bell in the nav rail.
Anchored inside the content column so they clear the sidebar automatically
(collapsed or expanded) without tracking its width. */}
<Toaster />
</div>
{wizardOpen && meQuery.data && !meQuery.data.is_demo && (
<OnboardingWizard me={meQuery.data} onClose={() => setWizardOpen(false)} />
@ -314,7 +333,6 @@ export default function App() {
{notesOpen && (
<ReleaseNotes onClose={() => setNotesOpen(false)} highlight={notesHighlight} />
)}
<Toaster />
</div>
);
}

View file

@ -4,12 +4,13 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
ArrowDown,
ArrowUp,
Check,
ExternalLink,
Eye,
EyeOff,
History,
Plus,
RefreshCw,
Search,
UserMinus,
X,
} from "lucide-react";
@ -18,6 +19,7 @@ import { formatEta } from "../lib/format";
import { notify } from "../lib/notifications";
import Tooltip from "./Tooltip";
import Avatar from "./Avatar";
import DataTable, { type Column } from "./DataTable";
import { useConfirm } from "./ConfirmProvider";
export type ChannelStatusFilter = "all" | "needs_full" | "fully_synced" | "hidden";
@ -31,12 +33,14 @@ const STATUS_FILTERS: { id: ChannelStatusFilter; labelKey: string }[] = [
export default function Channels({
canWrite,
isAdmin,
onViewChannel,
statusFilter,
setStatusFilter,
onOpenWizard,
}: {
canWrite: boolean;
isAdmin: boolean;
onViewChannel: (id: string, name: string) => void;
statusFilter: ChannelStatusFilter;
setStatusFilter: (f: ChannelStatusFilter) => void;
@ -62,7 +66,6 @@ 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 [q, setQ] = useState("");
const [newTag, setNewTag] = useState("");
const invalidate = () => {
@ -100,14 +103,26 @@ export default function Channels({
.catch(() => qc.invalidateQueries({ queryKey: ["channels"] }));
};
const attach = useMutation({
mutationFn: (v: { id: string; tagId: number }) => api.attachChannelTag(v.id, v.tagId),
onSuccess: () => qc.invalidateQueries({ queryKey: ["channels"] }),
});
const detach = useMutation({
mutationFn: (v: { id: string; tagId: number }) => api.detachChannelTag(v.id, v.tagId),
onSuccess: () => qc.invalidateQueries({ queryKey: ["channels"] }),
});
// Tagging is updated optimistically in place (no refetch of the whole channel list, which
// made the toggle feel ~2s slow); only revert by refetching if the server call fails.
const toggleTag = (id: string, tagId: number, hasTag: boolean) => {
qc.setQueryData<ManagedChannel[]>(["channels"], (old) =>
(old ?? []).map((ch) =>
ch.id === id
? {
...ch,
tag_ids: hasTag
? ch.tag_ids.filter((x) => x !== tagId)
: [...ch.tag_ids, tagId],
}
: ch
)
);
const call = hasTag
? api.detachChannelTag(id, tagId)
: api.attachChannelTag(id, tagId);
call.catch(() => qc.invalidateQueries({ queryKey: ["channels"] }));
};
const syncSubs = useMutation({
mutationFn: () => api.syncSubscriptions(),
onSuccess: (r: { subscriptions?: number }) => {
@ -136,6 +151,15 @@ export default function Channels({
},
onError: (e) => notifyActionError(e, "channels.notify.unsubscribeFailed"),
});
const resetBackfill = useMutation({
mutationFn: (id: string) => api.resetChannelBackfill(id),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["channels"] });
qc.invalidateQueries({ queryKey: ["my-status"] });
notify({ level: "success", message: t("channels.notify.resetDone") });
},
onError: (e) => notifyActionError(e, "channels.notify.resetFailed"),
});
const deepAll = useMutation({
mutationFn: () => api.deepAll(true),
onSuccess: (r: { updated?: number }) => {
@ -149,9 +173,10 @@ export default function Channels({
onError: (e) => notifyActionError(e, "channels.notify.fullHistoryFailed"),
});
const channels = (channelsQuery.data ?? [])
.filter((c) => !q || (c.title ?? "").toLowerCase().includes(q.toLowerCase()))
.filter((c) =>
// The Sync-status filter stays a top-level chip set (not a column filter) because the
// header's "go to full history" deep-link drives it; the DataTable then handles name
// search, tag filtering, sort and pagination over whatever the status chip leaves.
const channels = (channelsQuery.data ?? []).filter((c) =>
statusFilter === "needs_full"
? !c.backfill_done
: statusFilter === "fully_synced"
@ -162,11 +187,142 @@ export default function Channels({
);
const s = statusQuery.data;
const onView = (c: ManagedChannel) =>
onViewChannel(c.id, c.title ?? t("channels.row.thisChannel"));
const onUnsub = async (c: ManagedChannel) => {
const ok = await confirm({
title: t("channels.row.unsubscribeOnYoutube"),
message: t("channels.confirmUnsubscribe", { name: c.title ?? c.id }),
confirmLabel: t("channels.row.unsubscribeOnYoutube"),
danger: true,
});
if (ok) unsubscribe.mutate(c.id);
};
const onReset = async (c: ManagedChannel) => {
const ok = await confirm({
title: t("channels.row.backfillThis"),
message: t("channels.confirmReset", { name: c.title ?? c.id }),
confirmLabel: t("channels.row.backfillThis"),
});
if (ok) resetBackfill.mutate(c.id);
};
const columns: Column<ManagedChannel>[] = [
{
key: "priority",
header: t("channels.cols.priority"),
align: "center",
width: "56px",
sortable: true,
hideInCard: true,
sortValue: (c) => c.priority,
render: (c) => <PriorityCell c={c} onPriority={(d) => bumpPriority(c.id, c.priority, d)} />,
},
{
key: "channel",
header: t("channels.cols.channel"),
sortable: true,
sortValue: (c) => (c.title ?? c.id).toLowerCase(),
filter: { kind: "text", get: (c) => `${c.title ?? ""} ${c.handle ?? ""}` },
cardPrimary: true,
render: (c) => <NameCell c={c} onView={() => onView(c)} />,
},
{
key: "stored",
header: t("channels.cols.stored"),
align: "right",
width: "84px",
sortable: true,
sortValue: (c) => c.stored_videos,
render: (c) => <span className="text-muted tabular-nums">{c.stored_videos.toLocaleString()}</span>,
},
{
key: "subs",
header: t("channels.cols.subs"),
align: "right",
width: "92px",
sortable: true,
sortValue: (c) => c.subscriber_count ?? -1,
render: (c) => (
<span className="text-muted tabular-nums">
{c.subscriber_count != null ? c.subscriber_count.toLocaleString() : "—"}
</span>
),
},
{
key: "sync",
header: t("channels.cols.sync"),
width: "130px",
cardLabel: false,
render: (c) => <SyncCell c={c} />,
},
{
key: "tags",
header: t("channels.cols.tags"),
width: "360px",
cardLabel: false,
filter: {
kind: "multi",
options: userTags.map((tg) => ({ value: String(tg.id), label: tg.name })),
// OR semantics: show channels carrying any of the selected tags.
test: (c, values) => values.some((v) => c.tag_ids.includes(Number(v))),
},
render: (c) => (
<TagsCell
c={c}
userTags={userTags}
onToggleTag={(tagId) => toggleTag(c.id, tagId, c.tag_ids.includes(tagId))}
/>
),
},
{
key: "actions",
header: t("channels.cols.actions"),
align: "right",
width: "104px",
cardLabel: false,
render: (c) => (
<ActionsCell
c={c}
isAdmin={isAdmin}
canWrite={canWrite}
onHide={() => patch.mutate({ id: c.id, body: { hidden: !c.hidden } })}
onDeep={() => patch.mutate({ id: c.id, body: { deep_requested: !c.deep_requested } })}
onReset={() => onReset(c)}
onUnsubscribe={() => onUnsub(c)}
/>
),
},
];
// Status chips sit in the table's controls row (next to paging) — compact and close to
// where they act.
const statusChips = (
<div className="flex flex-wrap items-center gap-1.5">
{STATUS_FILTERS.map((f) => (
<button
key={f.id}
onClick={() => setStatusFilter(f.id)}
className={`text-xs px-2.5 py-1 rounded-full border transition ${
statusFilter === f.id
? "bg-accent text-accent-fg border-accent"
: "bg-card border-border text-muted hover:border-accent"
}`}
>
{t(f.labelKey)}
</button>
))}
</div>
);
return (
<div className="p-4 max-w-4xl mx-auto">
{/* Per-user sync status */}
<div className="p-4 max-w-7xl mx-auto">
{/* Per-user sync status + catalog-wide actions on one row (search/tags filtering
now lives in the table headers). */}
<div className="flex items-start justify-between gap-4 flex-wrap mb-4">
<div className="flex flex-wrap items-center gap-x-5 gap-y-1 text-sm text-muted">
{s && (
<div className="flex flex-wrap items-center gap-x-5 gap-y-1 text-sm text-muted mb-4">
<>
<Stat label={t("channels.stats.channels")} value={s.channels_total} hint={t("channels.stats.channelsHint")} />
<Stat
label={t("channels.stats.recentSynced")}
@ -191,20 +347,10 @@ export default function Channels({
value={s.quota_remaining_today.toLocaleString()}
hint={t("channels.stats.quotaLeftHint")}
/>
</div>
</>
)}
{/* Toolbar */}
<div className="flex items-center gap-2 mb-4">
<div className="relative flex-1">
<Search className="w-4 h-4 absolute left-3 top-1/2 -translate-y-1/2 text-muted" />
<input
value={q}
onChange={(e) => setQ(e.target.value)}
placeholder={t("channels.filterPlaceholder")}
className="w-full bg-card border border-border rounded-full pl-9 pr-4 py-2 text-sm outline-none focus:border-accent"
/>
</div>
<div className="flex items-center gap-2 shrink-0">
<Tooltip
side="bottom"
hint={t("channels.syncSubscriptionsHint")}
@ -232,22 +378,6 @@ export default function Channels({
</button>
</Tooltip>
</div>
{/* Status filter */}
<div className="flex flex-wrap items-center gap-1.5 mb-4">
{STATUS_FILTERS.map((f) => (
<button
key={f.id}
onClick={() => setStatusFilter(f.id)}
className={`text-xs px-2.5 py-1 rounded-full border transition ${
statusFilter === f.id
? "bg-accent text-accent-fg border-accent"
: "bg-card border-border text-muted hover:border-accent"
}`}
>
{t(f.labelKey)}
</button>
))}
</div>
<p className="text-xs text-muted mb-4 leading-relaxed">
@ -298,42 +428,20 @@ export default function Channels({
</form>
</div>
{/* Channel list */}
{/* Channel table */}
{channelsQuery.isLoading ? (
<div className="text-muted py-8">{t("channels.loading")}</div>
) : channels.length === 0 ? (
<div className="text-muted py-8">{t("channels.empty")}</div>
) : (
<div className="flex flex-col gap-1.5">
{channels.map((c) => (
<ChannelRow
key={c.id}
c={c}
userTags={userTags}
canWrite={canWrite}
onUnsubscribe={async () => {
const ok = await confirm({
title: t("channels.row.unsubscribeOnYoutube"),
message: t("channels.confirmUnsubscribe", { name: c.title ?? c.id }),
confirmLabel: t("channels.row.unsubscribeOnYoutube"),
danger: true,
});
if (ok) unsubscribe.mutate(c.id);
}}
onView={() => onViewChannel(c.id, c.title ?? t("channels.row.thisChannel"))}
onPriority={(d) => bumpPriority(c.id, c.priority, d)}
onHide={() => patch.mutate({ id: c.id, body: { hidden: !c.hidden } })}
onDeep={() =>
patch.mutate({ id: c.id, body: { deep_requested: !c.deep_requested } })
}
onToggleTag={(tagId) =>
c.tag_ids.includes(tagId)
? detach.mutate({ id: c.id, tagId })
: attach.mutate({ id: c.id, tagId })
}
<DataTable
rows={channels}
columns={columns}
rowKey={(c) => c.id}
persistKey="siftlode.channelsTable"
controlsPosition="top"
controlsLeading={statusChips}
rowClassName={(c) => (c.hidden ? "opacity-60" : "")}
emptyText={t("channels.empty")}
/>
))}
</div>
)}
</div>
);
@ -361,46 +469,22 @@ function SyncBadge({ ok, label, hint }: { ok: boolean; label: string; hint?: str
return (
<Tooltip hint={hint ?? ""}>
<span
className={`text-[10px] px-1.5 py-0.5 rounded-full border ${
className={`inline-flex items-center gap-1 text-[10px] px-1.5 py-0.5 rounded-full border ${
ok ? "border-accent/40 text-accent" : "border-border text-muted"
}`}
>
{ok && <Check className="w-3 h-3" />}
{label}
</span>
</Tooltip>
);
}
function ChannelRow({
c,
userTags,
canWrite,
onUnsubscribe,
onView,
onPriority,
onHide,
onDeep,
onToggleTag,
}: {
c: ManagedChannel;
userTags: Tag[];
canWrite: boolean;
onUnsubscribe: () => void;
onView: () => void;
onPriority: (delta: number) => void;
onHide: () => void;
onDeep: () => void;
onToggleTag: (tagId: number) => void;
}) {
function PriorityCell({ c, onPriority }: { c: ManagedChannel; onPriority: (delta: number) => void }) {
const { t } = useTranslation();
return (
<div
className={`glass-card glass-hover flex items-center gap-3 p-2.5 rounded-xl transition ${
c.hidden ? "opacity-60" : ""
}`}
>
<Tooltip hint={t("channels.row.priorityHint")}>
<div className="flex flex-col items-center cursor-help">
<div className="inline-flex flex-col items-center cursor-help">
<button onClick={() => onPriority(1)} className="text-muted hover:text-accent" aria-label={t("channels.row.raisePriority")}>
<ArrowUp className="w-3.5 h-3.5" />
</button>
@ -410,22 +494,59 @@ function ChannelRow({
</button>
</div>
</Tooltip>
);
}
<Avatar
src={c.thumbnail_url}
fallback={c.title ?? ""}
className="w-10 h-10 rounded-full shrink-0"
/>
<div className="min-w-0 flex-1">
<button onClick={onView} className="text-sm font-semibold truncate hover:text-accent block max-w-full text-left">
function NameCell({ c, onView }: { c: ManagedChannel; onView: () => void }) {
const { t } = useTranslation();
const ytUrl = c.handle
? `https://www.youtube.com/@${c.handle.replace(/^@/, "")}`
: `https://www.youtube.com/channel/${c.id}`;
return (
<div className="flex items-center gap-2 min-w-0">
<Avatar src={c.thumbnail_url} fallback={c.title ?? ""} className="w-8 h-8 rounded-full shrink-0" />
<button
onClick={onView}
onAuxClick={(e) => {
// Middle-click opens the channel's YouTube page in a new tab; left-click
// keeps opening the in-app channel detail.
if (e.button === 1) {
e.preventDefault();
window.open(ytUrl, "_blank", "noopener,noreferrer");
}
}}
onMouseDown={(e) => {
if (e.button === 1) e.preventDefault(); // suppress middle-click autoscroll
}}
className="text-sm font-medium truncate hover:text-accent text-left min-w-0"
>
{c.title ?? c.id}
</button>
<div className="flex items-center gap-2 text-[11px] text-muted">
<span>{t("channels.row.stored", { count: c.stored_videos, formatted: c.stored_videos.toLocaleString() })}</span>
{c.subscriber_count != null && <span>· {t("channels.row.subs", { count: c.subscriber_count, formatted: c.subscriber_count.toLocaleString() })}</span>}
<Tooltip hint={t("channels.row.openOnYouTube")}>
<a
href={ytUrl}
target="_blank"
rel="noopener noreferrer"
className="text-muted hover:text-accent shrink-0"
aria-label={t("channels.row.openOnYouTube")}
>
<ExternalLink className="w-3.5 h-3.5" />
</a>
</Tooltip>
</div>
<div className="flex flex-wrap items-center gap-1 mt-1">
);
}
// Status only — the backfill *action* lives in the Actions column. When both recent and
// full history are in, collapse to a single "fully synced" chip; otherwise show what's
// present plus the missing/queued state.
function SyncCell({ c }: { c: ManagedChannel }) {
const { t } = useTranslation();
if (c.recent_synced && c.backfill_done) {
return <SyncBadge ok label={t("channels.row.fullySynced")} hint={t("channels.row.fullySyncedHint")} />;
}
return (
<div className="flex flex-wrap items-center gap-1">
<SyncBadge
ok={c.recent_synced}
label={t("channels.row.recent")}
@ -434,60 +555,105 @@ function ChannelRow({
{c.backfill_done ? (
<SyncBadge ok label={t("channels.row.full")} hint={t("channels.row.fullHint")} />
) : c.deep_requested ? (
<Tooltip hint={t("channels.row.queuedRequestedHint")}>
<button
onClick={onDeep}
className="inline-flex items-center gap-1 text-[10px] px-1.5 py-0.5 rounded-full border border-accent bg-accent text-accent-fg transition"
>
<History className="w-3 h-3" />
{t("channels.row.fullHistoryQueued")}
</button>
</Tooltip>
<SyncBadge ok={false} label={t("channels.row.fullHistoryQueued")} hint={t("channels.row.queuedRequestedHint")} />
) : c.deep_in_queue ? (
<Tooltip hint={t("channels.row.queuedByOtherHint")}>
<span className="inline-flex items-center gap-1 text-[10px] px-1.5 py-0.5 rounded-full border border-accent/40 text-accent">
<History className="w-3 h-3" />
{t("channels.row.fullHistoryComing")}
</span>
</Tooltip>
<SyncBadge ok={false} label={t("channels.row.fullHistoryComing")} hint={t("channels.row.queuedByOtherHint")} />
) : (
<Tooltip hint={t("channels.row.getFullHistoryHint")}>
<button
onClick={onDeep}
className="inline-flex items-center gap-1 text-[10px] px-1.5 py-0.5 rounded-full border bg-card border-border text-muted hover:border-accent transition"
>
<History className="w-3 h-3" />
{t("channels.row.getFullHistory")}
</button>
</Tooltip>
<SyncBadge ok={false} label={t("channels.row.full")} hint={t("channels.row.fullNotFetchedHint")} />
)}
{userTags.map((t) => {
const on = c.tag_ids.includes(t.id);
</div>
);
}
function TagsCell({
c,
userTags,
onToggleTag,
}: {
c: ManagedChannel;
userTags: Tag[];
onToggleTag: (tagId: number) => void;
}) {
if (userTags.length === 0) return null;
return (
<div className="flex flex-wrap items-center gap-1">
{userTags.map((tg) => {
const on = c.tag_ids.includes(tg.id);
return (
<button
key={t.id}
onClick={() => onToggleTag(t.id)}
key={tg.id}
onClick={() => onToggleTag(tg.id)}
className={`text-[10px] px-1.5 py-0.5 rounded-full border transition ${
on
? "bg-accent text-accent-fg border-accent"
: "bg-card border-border text-muted hover:border-accent"
}`}
>
{t.name}
{tg.name}
</button>
);
})}
</div>
</div>
);
}
<Tooltip
hint={c.hidden ? t("channels.row.hiddenHint") : t("channels.row.hideHint")}
function ActionsCell({
c,
isAdmin,
canWrite,
onHide,
onDeep,
onReset,
onUnsubscribe,
}: {
c: ManagedChannel;
isAdmin: boolean;
canWrite: boolean;
onHide: () => void;
onDeep: () => void;
onReset: () => void;
onUnsubscribe: () => void;
}) {
const { t } = useTranslation();
// Admins get a "reset" trigger that re-fetches the channel from scratch regardless of
// state — always actionable. Regular users get the per-user "request full history" opt-in,
// which only applies until the back-catalog is in (or already coming for everyone).
const done = c.backfill_done;
const requested = !done && c.deep_requested;
const optInActionable = !done && !c.deep_in_queue;
const backfillHint = isAdmin
? t("channels.row.resetHint")
: c.deep_in_queue
? t("channels.row.queuedByOtherHint")
: requested
? t("channels.row.queuedRequestedHint")
: done
? t("channels.row.fullHint")
: t("channels.row.backfillThisHint");
const enabled = isAdmin || optInActionable;
return (
<div className="inline-flex items-center gap-2">
<Tooltip hint={backfillHint}>
<button
onClick={!enabled ? undefined : isAdmin ? onReset : onDeep}
disabled={!enabled}
className={`shrink-0 transition ${
requested
? "text-accent"
: enabled
? "text-muted hover:text-fg"
: "text-muted/40 cursor-default"
}`}
aria-label={isAdmin ? t("channels.row.resetBackfill") : t("channels.row.backfillThis")}
>
<History className="w-4 h-4" />
</button>
</Tooltip>
<Tooltip hint={c.hidden ? t("channels.row.hiddenHint") : t("channels.row.hideHint")}>
<button onClick={onHide} className="text-muted hover:text-fg shrink-0" aria-label={c.hidden ? t("channels.row.unhide") : t("channels.row.hideFromFeed")}>
{c.hidden ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
</button>
</Tooltip>
{canWrite && (
<Tooltip hint={t("channels.row.unsubscribeHint")}>
<button

View file

@ -0,0 +1,421 @@
import { useEffect, useRef, useState, type ReactNode } from "react";
import { useTranslation } from "react-i18next";
import { ArrowDown, ArrowUp, ChevronLeft, ChevronRight, ListFilter, X } from "lucide-react";
// A reusable, client-side data table: per-column sort + filter (in the header) + pagination,
// with its sort/filter/page state optionally persisted to localStorage so a reload (F5) keeps
// the view. Columns are declared by the caller; the table is generic over the row type, so
// other modules (e.g. the playlist manager) can reuse it. Below `md` it falls back to a card
// list built from the same column definitions.
export type ColumnFilter<T> =
| { kind: "text"; get: (row: T) => string }
| { kind: "select"; options: { value: string; label: string }[]; test: (row: T, value: string) => boolean }
| { kind: "multi"; options: { value: string; label: string }[]; test: (row: T, values: string[]) => boolean };
export interface Column<T> {
key: string;
header: string;
render: (row: T) => ReactNode;
align?: "left" | "right" | "center";
width?: string;
sortable?: boolean;
sortValue?: (row: T) => string | number;
filter?: ColumnFilter<T>;
// 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;
hideInCard?: boolean;
cardLabel?: boolean;
}
type SortState = { key: string; dir: "asc" | "desc" } | null;
type FilterMap = Record<string, string | string[]>;
interface Persisted {
sort: SortState;
filters: FilterMap;
page: number;
size?: number;
}
function loadPersist(key?: string): Persisted {
const empty: Persisted = { sort: null, filters: {}, page: 0 };
if (!key) return empty;
try {
const v = JSON.parse(localStorage.getItem(key) || "{}");
return { sort: v.sort ?? null, filters: v.filters ?? {}, page: v.page ?? 0, size: v.size };
} catch {
return empty;
}
}
function isActive(value: string | string[] | undefined): boolean {
return Array.isArray(value) ? value.length > 0 : !!value;
}
export default function DataTable<T>({
rows,
columns,
rowKey,
pageSize = 10,
pageSizeOptions = [10, 25, 50, 100],
persistKey,
emptyText,
rowClassName,
controlsPosition = "bottom",
controlsLeading,
}: {
rows: T[];
columns: Column<T>[];
rowKey: (row: T) => string;
pageSize?: number;
pageSizeOptions?: number[];
persistKey?: string;
emptyText?: string;
rowClassName?: (row: T) => string;
controlsPosition?: "top" | "bottom" | "both";
controlsLeading?: ReactNode;
}) {
const { t } = useTranslation();
const initial = loadPersist(persistKey);
const [sort, setSort] = useState<SortState>(initial.sort);
const [filters, setFilters] = useState<FilterMap>(initial.filters);
const [page, setPage] = useState(initial.page);
const [size, setSize] = useState(initial.size ?? pageSize);
const [openFilter, setOpenFilter] = useState<string | null>(null);
// Local text for the editable "jump to page" box (committed on Enter/blur).
const [pageInput, setPageInput] = useState("1");
const popRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
if (persistKey) localStorage.setItem(persistKey, JSON.stringify({ sort, filters, page, size }));
}, [persistKey, sort, filters, page, size]);
useEffect(() => {
if (!openFilter) return;
function onDown(e: MouseEvent) {
if (!popRef.current?.contains(e.target as Node)) setOpenFilter(null);
}
function onKey(e: KeyboardEvent) {
if (e.key === "Escape") setOpenFilter(null);
}
document.addEventListener("mousedown", onDown);
document.addEventListener("keydown", onKey);
return () => {
document.removeEventListener("mousedown", onDown);
document.removeEventListener("keydown", onKey);
};
}, [openFilter]);
const filtered = rows.filter((row) =>
columns.every((col) => {
const f = col.filter;
if (!f) return true;
const val = filters[col.key];
if (!isActive(val)) return true;
if (f.kind === "text") return f.get(row).toLowerCase().includes(String(val).toLowerCase());
if (f.kind === "select") return f.test(row, String(val));
return f.test(row, val as string[]);
})
);
const sortCol = sort ? columns.find((c) => c.key === sort.key) : undefined;
const sorted =
sort && sortCol?.sortValue
? [...filtered].sort((a, b) => {
const av = sortCol.sortValue!(a);
const bv = sortCol.sortValue!(b);
const cmp =
typeof av === "number" && typeof bv === "number"
? av - bv
: String(av).localeCompare(String(bv));
return sort.dir === "asc" ? cmp : -cmp;
})
: filtered;
// size <= 0 means "All" — one page with every row.
const allRows = size <= 0;
const totalPages = allRows ? 1 : Math.max(1, Math.ceil(sorted.length / size));
const safePage = Math.min(page, totalPages - 1);
const paged = allRows ? sorted : sorted.slice(safePage * size, safePage * size + size);
useEffect(() => setPageInput(String(safePage + 1)), [safePage]);
function commitPageInput() {
const n = parseInt(pageInput, 10);
if (!isNaN(n)) setPage(Math.min(totalPages - 1, Math.max(0, n - 1)));
else setPageInput(String(safePage + 1));
}
function toggleSort(key: string) {
setSort((s) =>
!s || s.key !== key ? { key, dir: "asc" } : s.dir === "asc" ? { key, dir: "desc" } : null
);
setPage(0);
}
function applyFilter(key: string, value: string | string[]) {
setFilters((f) => ({ ...f, [key]: value }));
setPage(0);
}
const align = (a?: "left" | "right" | "center") =>
a === "right" ? "text-right" : a === "center" ? "text-center" : "text-left";
function FilterPopover({ col }: { col: Column<T> }) {
const f = col.filter!;
const val = filters[col.key];
return (
<div
ref={popRef}
className="glass-menu absolute left-0 top-full mt-1 z-30 w-52 rounded-xl p-2 animate-[popIn_0.16s_ease]"
>
{f.kind === "text" && (
<input
autoFocus
value={(val as string) ?? ""}
onChange={(e) => applyFilter(col.key, e.target.value)}
placeholder={col.header}
className="w-full bg-card border border-border rounded-md px-2 py-1 text-xs outline-none focus:border-accent"
/>
)}
{f.kind === "select" && (
<div className="flex flex-col gap-0.5">
<button
onClick={() => applyFilter(col.key, "")}
className={`text-left text-xs px-2 py-1.5 rounded-md transition ${
!isActive(val) ? "bg-accent text-accent-fg" : "text-muted hover:bg-card"
}`}
>
{t("datatable.all")}
</button>
{f.options.map((o) => (
<button
key={o.value}
onClick={() => applyFilter(col.key, o.value)}
className={`text-left text-xs px-2 py-1.5 rounded-md transition ${
val === o.value ? "bg-accent text-accent-fg" : "text-muted hover:bg-card"
}`}
>
{o.label}
</button>
))}
</div>
)}
{f.kind === "multi" && (
<div className="flex flex-col gap-0.5 max-h-60 overflow-y-auto">
{f.options.length === 0 && (
<span className="text-xs text-muted px-2 py-1">{t("datatable.noOptions")}</span>
)}
{f.options.map((o) => {
const arr = (val as string[]) ?? [];
const on = arr.includes(o.value);
return (
<button
key={o.value}
onClick={() =>
applyFilter(
col.key,
on ? arr.filter((x) => x !== o.value) : [...arr, o.value]
)
}
className={`flex items-center gap-2 text-left text-xs px-2 py-1.5 rounded-md transition ${
on ? "text-fg" : "text-muted hover:bg-card"
}`}
>
<span
className={`w-3.5 h-3.5 rounded border flex items-center justify-center ${
on ? "bg-accent border-accent text-accent-fg" : "border-border"
}`}
>
{on && <X className="w-2.5 h-2.5" />}
</span>
{o.label}
</button>
);
})}
</div>
)}
{isActive(val) && (
<button
onClick={() => applyFilter(col.key, f.kind === "multi" ? [] : "")}
className="mt-1.5 w-full text-xs text-muted hover:text-accent transition"
>
{t("datatable.clear")}
</button>
)}
</div>
);
}
const controls =
controlsLeading != null || sorted.length > 0 ? (
<div className="flex items-center justify-between gap-3 my-3 text-sm flex-wrap">
<div className="flex items-center gap-3 flex-wrap">{controlsLeading}</div>
<div className="flex items-center gap-3 flex-wrap">
{totalPages > 1 && (
<div className="flex items-center gap-2">
<button
onClick={() => setPage((p) => Math.max(0, p - 1))}
disabled={safePage === 0}
className="glass-card glass-hover flex items-center gap-1 px-3 py-1.5 rounded-lg disabled:opacity-40 transition"
>
<ChevronLeft className="w-4 h-4" />
{t("datatable.pager.prev")}
</button>
<span className="text-muted flex items-center gap-1.5">
{t("datatable.pager.pageLabel")}
<input
type="number"
min={1}
max={totalPages}
value={pageInput}
onChange={(e) => setPageInput(e.target.value)}
onBlur={commitPageInput}
onKeyDown={(e) => e.key === "Enter" && commitPageInput()}
aria-label={t("datatable.pager.pageLabel")}
className="w-14 bg-card border border-border rounded-md px-1.5 py-1 text-xs text-center tabular-nums outline-none focus:border-accent"
/>
{t("datatable.pager.ofTotal", { total: totalPages })}
</span>
<button
onClick={() => setPage((p) => Math.min(totalPages - 1, p + 1))}
disabled={safePage >= totalPages - 1}
className="glass-card glass-hover flex items-center gap-1 px-3 py-1.5 rounded-lg disabled:opacity-40 transition"
>
{t("datatable.pager.next")}
<ChevronRight className="w-4 h-4" />
</button>
</div>
)}
{sorted.length > 0 && (
<label className="flex items-center gap-2 text-muted">
{t("datatable.rowsPerPage")}
<select
value={size}
onChange={(e) => {
setSize(Number(e.target.value));
setPage(0);
}}
className="bg-card border border-border rounded-md px-1.5 py-1 text-xs outline-none focus:border-accent"
>
{pageSizeOptions.map((n) => (
<option key={n} value={n}>
{n}
</option>
))}
<option value={0}>{t("datatable.all")}</option>
</select>
</label>
)}
</div>
</div>
) : null;
return (
<div>
{(controlsPosition === "top" || controlsPosition === "both") && controls}
{/* Wide screens: table. The wrapper isn't clipped so header filter popovers can overflow. */}
<div className="hidden md:block">
<table className="w-full border-collapse text-sm">
<thead>
<tr className="text-muted border-b border-border">
{columns.map((col) => {
const sorted_ = sort?.key === col.key;
const filterOn = isActive(filters[col.key]);
return (
<th
key={col.key}
style={col.width ? { width: col.width } : undefined}
className={`relative font-medium px-2 py-2 ${align(col.align)}`}
>
<span className="inline-flex items-center gap-1">
<button
onClick={() => col.sortable && toggleSort(col.key)}
className={`inline-flex items-center gap-1 ${
col.sortable ? "hover:text-fg cursor-pointer" : "cursor-default"
}`}
>
{col.header}
{col.sortable && sorted_ && sort && (
sort.dir === "asc" ? (
<ArrowUp className="w-3 h-3" />
) : (
<ArrowDown className="w-3 h-3" />
)
)}
</button>
{col.filter && (
<button
onClick={() => setOpenFilter((o) => (o === col.key ? null : col.key))}
aria-label={t("datatable.filter")}
className={`transition ${
filterOn ? "text-accent" : "text-muted/60 hover:text-fg"
}`}
>
<ListFilter className="w-3.5 h-3.5" />
</button>
)}
</span>
{openFilter === col.key && col.filter && <FilterPopover col={col} />}
</th>
);
})}
</tr>
</thead>
<tbody>
{paged.map((row) => (
<tr
key={rowKey(row)}
className={`border-b border-border/50 hover:bg-card/40 transition ${
rowClassName?.(row) ?? ""
}`}
>
{columns.map((col) => (
<td key={col.key} style={col.width ? { width: col.width } : undefined} className={`px-2 py-2 align-middle ${align(col.align)}`}>
{col.render(row)}
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
{/* Narrow screens: a compact card per row the primary column as the heading, the
rest flowing in a single wrapping meta line (no full-width label/value gaps). */}
<div className="md:hidden flex flex-col gap-2">
{paged.map((row) => (
<div
key={rowKey(row)}
className={`glass-card rounded-xl p-3 ${rowClassName?.(row) ?? ""}`}
>
{columns
.filter((c) => c.cardPrimary)
.map((col) => (
<div key={col.key} className="font-medium">
{col.render(row)}
</div>
))}
<div className="flex flex-wrap items-center gap-x-3 gap-y-1.5 mt-2 text-sm">
{columns
.filter((c) => !c.hideInCard && !c.cardPrimary)
.map((col) => (
<span key={col.key} className="inline-flex items-center gap-1">
{col.cardLabel !== false && (
<span className="text-muted text-xs">{col.header}</span>
)}
{col.render(row)}
</span>
))}
</div>
</div>
))}
</div>
{paged.length === 0 && (
<div className="text-muted py-8 text-center text-sm">{emptyText ?? t("datatable.empty")}</div>
)}
{(controlsPosition === "bottom" || controlsPosition === "both") && controls}
</div>
);
}

View file

@ -147,7 +147,15 @@ export default function Feed({
api
.setState(id, status)
.then(() => qc.invalidateQueries({ queryKey: ["feed"] }))
.catch(() => {});
.catch(() =>
// Server rejected the change — drop the optimistic override so the card
// reverts to the real (unchanged) state instead of staying phantom-hidden.
setOverrides((o) => {
const next = { ...o };
delete next[id];
return next;
})
);
if (status === "hidden") {
const v = loadedRef.current.find((x) => x.id === id);
notify({

View file

@ -2,10 +2,7 @@ import { useTranslation } from "react-i18next";
import { Library, Search, User } from "lucide-react";
import type { FeedFilters, Me } from "../lib/api";
import type { Page } from "../lib/urlState";
import { type LangCode } from "../i18n";
import SyncStatus from "./SyncStatus";
import NotificationCenter from "./NotificationCenter";
import LanguageSwitcher from "./LanguageSwitcher";
// Contextual top bar. Primary navigation + account now live in the left NavSidebar; the
// header carries the global sync status, the feed's scope toggle + search (or the current
@ -15,17 +12,15 @@ export default function Header({
filters,
setFilters,
page,
onChangeLanguage,
onGoToFullHistory,
}: {
me: Me;
filters: FeedFilters;
setFilters: (f: FeedFilters) => void;
page: Page;
onChangeLanguage: (code: LangCode) => void;
onGoToFullHistory: () => void;
}) {
const { t, i18n } = useTranslation();
const { t } = useTranslation();
return (
<header className="glass h-14 shrink-0 border-b border-border flex items-center gap-3 px-4 z-20">
@ -83,11 +78,6 @@ export default function Header({
: t("header.channelManager")}
</div>
)}
<div className="flex items-center gap-1">
<LanguageSwitcher value={i18n.language as LangCode} onChange={onChangeLanguage} />
<NotificationCenter filters={filters} setFilters={setFilters} />
</div>
</header>
);
}

View file

@ -1,20 +1,31 @@
import { useRef, useState } from "react";
import { useEffect, useRef, useState } from "react";
import { createPortal } from "react-dom";
import { Check, Globe } from "lucide-react";
import { LANGUAGES, type LangCode } from "../i18n";
// Compact language picker (globe + current code). Presentational: the parent decides what
// changing the language does (set it; persist server-side when signed in).
//
// Two variants: "header" (legacy inline dropdown, opens below) and "rail" (used in the left
// nav's bottom icon cluster — an icon-only button whose menu is portaled to <body> and
// anchored to the right + above the button, escaping the nav's backdrop-filter which would
// otherwise trap an absolutely-positioned popover).
export default function LanguageSwitcher({
value,
onChange,
align = "right",
variant = "header",
}: {
value: LangCode;
onChange: (code: LangCode) => void;
align?: "left" | "right";
variant?: "header" | "rail";
}) {
const [open, setOpen] = useState(false);
const closeTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const btnRef = useRef<HTMLButtonElement | null>(null);
const panelRef = useRef<HTMLDivElement | null>(null);
const [pos, setPos] = useState<{ left: number; bottom: number }>({ left: 0, bottom: 0 });
const current = LANGUAGES.find((l) => l.code === value) ?? LANGUAGES[0];
function openNow() {
@ -24,6 +35,77 @@ export default function LanguageSwitcher({
function closeSoon() {
closeTimer.current = setTimeout(() => setOpen(false), 200);
}
function toggleRail() {
if (!open) {
const r = btnRef.current?.getBoundingClientRect();
if (r) setPos({ left: r.right + 8, bottom: window.innerHeight - r.bottom });
}
setOpen((o) => !o);
}
// Rail popover: dismiss on outside click / Escape (it's portaled, so a contains() check
// spans both the button and the floating panel).
useEffect(() => {
if (variant !== "rail" || !open) return;
function onDoc(e: MouseEvent) {
const target = e.target as Node;
if (panelRef.current?.contains(target) || btnRef.current?.contains(target)) return;
setOpen(false);
}
function onKey(e: KeyboardEvent) {
if (e.key === "Escape") setOpen(false);
}
document.addEventListener("mousedown", onDoc);
document.addEventListener("keydown", onKey);
return () => {
document.removeEventListener("mousedown", onDoc);
document.removeEventListener("keydown", onKey);
};
}, [variant, open]);
const menu = (
<div
ref={panelRef}
className="glass-menu w-40 rounded-xl p-1.5 animate-[popIn_0.16s_ease]"
>
{LANGUAGES.map((l) => (
<button
key={l.code}
onClick={() => {
onChange(l.code);
setOpen(false);
}}
className="w-full flex items-center justify-between gap-2 text-sm px-2 py-2 rounded-lg text-muted hover:text-fg hover:bg-card transition"
>
<span>{l.label}</span>
{l.code === value && <Check className="w-4 h-4 text-accent" />}
</button>
))}
</div>
);
if (variant === "rail") {
return (
<>
<button
ref={btnRef}
onClick={toggleRail}
title={current.label}
aria-label={current.label}
className="p-2 rounded-lg text-muted hover:text-fg hover:bg-card transition"
>
<Globe className="w-5 h-5" />
</button>
{open &&
createPortal(
<div style={{ position: "fixed", left: pos.left, bottom: pos.bottom, zIndex: 40 }}>
{menu}
</div>,
document.body
)}
</>
);
}
return (
<div className="relative" onMouseEnter={openNow} onMouseLeave={closeSoon}>

View file

@ -16,9 +16,12 @@ import {
Tv,
UserPlus,
} from "lucide-react";
import { api, type Me } from "../lib/api";
import { api, type FeedFilters, type Me } from "../lib/api";
import type { Page } from "../lib/urlState";
import { type LangCode } from "../i18n";
import AvatarImg from "./Avatar";
import LanguageSwitcher from "./LanguageSwitcher";
import NotificationCenter from "./NotificationCenter";
// Primary app navigation: a collapsible left rail with icon+label entries (Design C). The
// modules used to hide under the avatar dropdown; they now live here. Collapsed, it becomes
@ -28,11 +31,19 @@ export default function NavSidebar({
page,
setPage,
onOpenAbout,
onChangeLanguage,
language,
filters,
setFilters,
}: {
me: Me;
page: Page;
setPage: (p: Page) => void;
onOpenAbout: () => void;
onChangeLanguage: (code: LangCode) => void;
language: LangCode;
filters: FeedFilters;
setFilters: (f: FeedFilters) => void;
}) {
const { t } = useTranslation();
const [collapsed, setCollapsed] = useState<boolean>(
@ -105,20 +116,40 @@ export default function NavSidebar({
}
}
const items: { page: Page; icon: typeof Home; label: string }[] = [
type NavItem = { page: Page; icon: typeof Home; label: string };
// User-facing content modules vs. admin/system modules, separated by a divider in the rail.
const userItems: NavItem[] = [
{ page: "feed", icon: Home, label: t("header.account.feed") },
{ page: "channels", icon: Tv, label: t("header.account.channels") },
{ page: "playlists", icon: ListVideo, label: t("header.account.playlists") },
];
if (me.role === "admin") {
items.push({ page: "stats", icon: BarChart3, label: t("header.account.stats") });
items.push({ page: "scheduler", icon: Activity, label: t("header.account.scheduler") });
}
const systemItems: NavItem[] =
me.role === "admin"
? [
{ page: "stats", icon: BarChart3, label: t("header.account.stats") },
{ page: "scheduler", icon: Activity, label: t("header.account.scheduler") },
]
: [];
const rowBase =
"w-full flex items-center gap-3 rounded-lg px-2.5 py-2 text-sm transition";
const name = me.display_name ?? me.email.split("@")[0];
const renderItem = ({ page: p, icon: Icon, label }: NavItem) => (
<button
key={p}
onClick={() => setPage(p)}
title={collapsed ? label : undefined}
aria-current={page === p ? "page" : undefined}
className={`${rowBase} ${collapsed ? "justify-center px-0" : ""} ${
page === p ? "bg-accent text-accent-fg" : "text-muted hover:text-fg hover:bg-card"
}`}
>
<Icon className="w-[18px] h-[18px] shrink-0" />
{!collapsed && <span className="truncate">{label}</span>}
</button>
);
return (
<nav
className={`glass relative shrink-0 border-r border-border flex flex-col py-3 transition-[width] ${
@ -151,25 +182,30 @@ export default function NavSidebar({
</div>
<div className="flex-1 min-h-0 overflow-y-auto flex flex-col gap-1">
{items.map(({ page: p, icon: Icon, label }) => (
<button
key={p}
onClick={() => setPage(p)}
title={collapsed ? label : undefined}
aria-current={page === p ? "page" : undefined}
className={`${rowBase} ${collapsed ? "justify-center px-0" : ""} ${
page === p
? "bg-accent text-accent-fg"
: "text-muted hover:text-fg hover:bg-card"
}`}
>
<Icon className="w-[18px] h-[18px] shrink-0" />
{!collapsed && <span className="truncate">{label}</span>}
</button>
))}
{userItems.map(renderItem)}
{systemItems.length > 0 && (
<div className="my-1.5 border-t border-border/70" />
)}
{systemItems.map(renderItem)}
</div>
<div className="mt-2 pt-2 border-t border-border flex flex-col gap-1">
<div
className={`flex items-center justify-center gap-2 pb-2 mb-1 border-b border-border ${
collapsed ? "flex-col" : "flex-row"
}`}
>
<LanguageSwitcher variant="rail" value={language} onChange={onChangeLanguage} />
<button
onClick={onOpenAbout}
title={t("header.account.about")}
aria-label={t("header.account.about")}
className="p-2 rounded-lg text-muted hover:text-fg hover:bg-card transition"
>
<Info className="w-5 h-5" />
</button>
<NotificationCenter variant="rail" filters={filters} setFilters={setFilters} />
</div>
<button
onClick={() => setPage("settings")}
title={collapsed ? t("header.account.settings") : undefined}
@ -257,16 +293,6 @@ export default function NavSidebar({
<UserPlus className="w-4 h-4" />
{t("header.account.addAccount")}
</button>
<button
onClick={() => {
onOpenAbout();
setAcctOpen(false);
}}
className="w-full flex items-center gap-2 text-sm px-2 py-2 rounded-lg text-muted hover:text-fg hover:bg-card transition"
>
<Info className="w-4 h-4" />
{t("header.account.about")}
</button>
<button
onClick={logout}
className="w-full flex items-center gap-2 text-sm px-2 py-2 rounded-lg text-muted hover:text-fg hover:bg-card transition"

View file

@ -1,4 +1,5 @@
import { useEffect, useRef, useState, useSyncExternalStore } from "react";
import { createPortal } from "react-dom";
import { useTranslation } from "react-i18next";
import type { TFunction } from "i18next";
import { useQueryClient } from "@tanstack/react-query";
@ -31,26 +32,44 @@ function relTime(ts: number, t: TFunction): string {
export default function NotificationCenter({
filters,
setFilters,
variant = "header",
}: {
filters: FeedFilters;
setFilters: (f: FeedFilters) => void;
variant?: "header" | "rail";
}) {
const { t } = useTranslation();
const notifications = useSyncExternalStore(subscribe, getNotifications, getNotifications);
const unread = useSyncExternalStore(subscribe, getUnreadCount, getUnreadCount);
const [open, setOpen] = useState(false);
const wrap = useRef<HTMLDivElement>(null);
const btnRef = useRef<HTMLButtonElement>(null);
const panelRef = useRef<HTMLDivElement>(null);
// Rail variant anchors the panel right + above the button (fixed, viewport coords) and
// portals it to <body> so it escapes the nav's backdrop-filter.
const [pos, setPos] = useState<{ left: number; bottom: number }>({ left: 0, bottom: 0 });
const qc = useQueryClient();
// Close when clicking anywhere outside the panel.
// Close when clicking anywhere outside the button + panel.
useEffect(() => {
if (!open) return;
function onDown(e: MouseEvent) {
if (wrap.current && !wrap.current.contains(e.target as Node)) setOpen(false);
const target = e.target as Node;
if (btnRef.current?.contains(target) || panelRef.current?.contains(target)) return;
if (variant === "header" && wrap.current?.contains(target)) return;
setOpen(false);
}
document.addEventListener("mousedown", onDown);
return () => document.removeEventListener("mousedown", onDown);
}, [open]);
}, [open, variant]);
function toggle() {
if (!open && variant === "rail") {
const r = btnRef.current?.getBoundingClientRect();
if (r) setPos({ left: r.right + 8, bottom: window.innerHeight - r.bottom });
}
setOpen((o) => !o);
}
// Opening the center marks everything read.
useEffect(() => {
@ -81,10 +100,21 @@ export default function NotificationCenter({
.catch(() => {});
}
function renderPanel(el: JSX.Element) {
if (variant !== "rail") return el;
return createPortal(
<div style={{ position: "fixed", left: pos.left, bottom: pos.bottom, zIndex: 40 }}>
{el}
</div>,
document.body
);
}
return (
<div className="relative" ref={wrap}>
<button
onClick={() => setOpen((o) => !o)}
ref={btnRef}
onClick={toggle}
title={t("notifications.title")}
className="relative p-2 rounded-lg text-muted hover:text-fg hover:bg-card transition"
>
@ -96,8 +126,14 @@ export default function NotificationCenter({
)}
</button>
{open && (
<div className="glass-menu absolute right-0 mt-2 w-80 max-w-[calc(100vw-2rem)] rounded-xl z-30 flex flex-col max-h-[70vh] animate-[popIn_0.16s_ease]">
{open &&
renderPanel(
<div
ref={panelRef}
className={`glass-menu w-80 max-w-[calc(100vw-2rem)] rounded-xl flex flex-col max-h-[70vh] animate-[popIn_0.16s_ease] ${
variant === "rail" ? "" : "absolute right-0 mt-2 z-30"
}`}
>
<div className="flex items-center justify-between px-3 py-2 border-b border-border">
<div className="text-sm font-semibold">{t("notifications.title")}</div>
{notifications.length > 0 && (

View file

@ -339,12 +339,14 @@ export default function PlayerModal({
// Auto-watch only applies to the active item — not to other videos the player
// navigated to via description links.
const maybeAutoWatch = (current: number, duration: number) => {
if (autoMarkedRef.current || duration <= 0 || currentIdRef.current !== id) return;
const maybeAutoWatch = (current: number, duration: number): boolean => {
if (autoMarkedRef.current || duration <= 0 || currentIdRef.current !== id) return false;
if (current > duration - FINISH_MARGIN) {
autoMarkedRef.current = true;
setWatched(true);
return true;
}
return false;
};
const persist = (): Promise<unknown> | void => {
const p = playerRef.current;
@ -352,7 +354,10 @@ export default function PlayerModal({
try {
const cur = p.getCurrentTime();
const dur = typeof p.getDuration === "function" ? p.getDuration() : 0;
maybeAutoWatch(cur, dur);
// If we just auto-marked watched, skip the near-end progress checkpoint: it would
// only clear the resume position, and firing both at once needlessly races the
// watched write against the progress row it deletes.
if (maybeAutoWatch(cur, dur)) return;
// Only checkpoint the feed video we opened with — a navigated-to video may not
// be in this user's library, so the server would 404 on it.
if (currentIdRef.current === id) {

View file

@ -580,6 +580,21 @@ export default function Playlists({ canWrite }: { canWrite: boolean }) {
</button>
</div>
)}
<form
onSubmit={(e) => {
e.preventDefault();
if (newName.trim()) createMut.mutate(newName.trim());
}}
className="flex items-center gap-1.5 mb-3 pb-3 border-b border-border"
>
<Plus className="w-4 h-4 text-muted shrink-0" />
<input
value={newName}
onChange={(e) => setNewName(e.target.value)}
placeholder={t("playlists.newPlaylist")}
className="flex-1 min-w-0 bg-card border border-border rounded-md px-2 py-1 text-xs outline-none focus:border-accent"
/>
</form>
{playlists.length === 0 && !listQuery.isLoading && (
<div className="text-xs text-muted px-1 py-2">{t("playlists.noneYet")}</div>
)}
@ -618,21 +633,6 @@ export default function Playlists({ canWrite }: { canWrite: boolean }) {
</button>
))}
</div>
<form
onSubmit={(e) => {
e.preventDefault();
if (newName.trim()) createMut.mutate(newName.trim());
}}
className="flex items-center gap-1.5 mt-3 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("playlists.newPlaylist")}
className="flex-1 min-w-0 bg-card border border-border rounded-md px-2 py-1 text-xs outline-none focus:border-accent"
/>
</form>
</aside>
<main className="flex-1 min-w-0 overflow-y-auto p-4">

View file

@ -24,6 +24,14 @@ const TABS: { id: TabId; icon: typeof Monitor }[] = [
{ id: "account", icon: User },
];
// Persist the active tab so a reload (F5) keeps the user where they were instead of
// snapping back to "appearance".
const SETTINGS_TAB_KEY = "siftlode.settingsTab";
function loadSettingsTab(): TabId {
const v = localStorage.getItem(SETTINGS_TAB_KEY);
return TABS.some((tabItem) => tabItem.id === v) ? (v as TabId) : "appearance";
}
// Settings as a page (Design B): rendered in the main content area, not an overlay. It keeps
// the frosted `.glass` surface so it still refracts the ambient backdrop. The page title is
// shown by the Header; the tab rail stays as in-page sub-navigation.
@ -43,7 +51,11 @@ export default function SettingsPanel({
onOpenWizard: () => void;
}) {
const { t } = useTranslation();
const [tab, setTab] = useState<TabId>("appearance");
const [tab, setTabState] = useState<TabId>(loadSettingsTab);
const setTab = (id: TabId) => {
setTabState(id);
localStorage.setItem(SETTINGS_TAB_KEY, id);
};
return (
<div className="p-4 max-w-3xl w-full mx-auto">

View file

@ -63,15 +63,21 @@ export default function Stats() {
) : (
<div className="flex items-end gap-0.5 h-24">
{data.daily.map((d) => (
// Each day gets a full-height track so the column is always visible
// (even on near-zero days); the accent fill renders the value on top.
<div
key={d.day}
className="flex-1 bg-accent/70 hover:bg-accent rounded-t transition"
style={{ height: `${Math.max(2, (d.total / maxDaily) * 100)}%` }}
className="flex-1 h-full flex items-end bg-card/50 rounded-t"
title={t("stats.dailyTooltip", {
day: d.day,
units: d.total.toLocaleString(),
})}
>
<div
className="w-full bg-accent hover:opacity-80 rounded-t transition"
style={{ height: `${Math.max(3, (d.total / maxDaily) * 100)}%` }}
/>
</div>
))}
</div>
)}

View file

@ -31,13 +31,13 @@ export default function Toaster() {
const toasts = useSyncExternalStore(subscribe, getActiveToasts, getActiveToasts);
return (
<div className="fixed top-4 right-4 z-50 flex flex-col gap-2 w-80 max-w-[calc(100vw-2rem)]">
<div className="absolute bottom-4 left-4 z-50 flex flex-col gap-2 w-80 max-w-[calc(100vw-2rem)]">
{toasts.map((toast) => {
const { icon: Icon, color, bar } = LEVEL_STYLE[toast.level];
return (
<div
key={toast.id}
className="glass relative overflow-hidden rounded-xl px-3 py-3 flex items-start gap-3 animate-[popIn_0.16s_ease]"
className="toast-card glass relative overflow-hidden rounded-xl px-3 py-3 flex items-start gap-3 animate-[popIn_0.16s_ease]"
>
<Icon className={`w-5 h-5 shrink-0 mt-0.5 ${color}`} />
<div className="min-w-0 flex-1">

View file

@ -1,7 +1,7 @@
{
"intro": "Lege die <0>Priorität</0> eines Kanals fest, um seine Videos nach oben zu schieben, wenn du nach „Kanalpriorität“ sortierst, hänge eigene <1>Tags</1> an, um den Feed zu filtern, oder <2>blende</2> einen Kanal <2>aus</2>, um ihn ohne Abbestellung aus dem Feed zu entfernen.",
"filterPlaceholder": "Kanäle filtern…",
"syncSubscriptions": "Abos synchronisieren",
"syncSubscriptions": "Abos von YouTube lesen",
"syncSubscriptionsHint": "Importiert deine Abo-Liste erneut von YouTube — fügt neu abonnierte Kanäle hinzu und entfernt abbestellte. Die Videos selbst werden weiterhin automatisch im Hintergrund synchronisiert; sie werden hierbei nicht neu geladen.",
"backfillEverything": "Alles nachladen",
"backfillEverythingHint": "Fordert das vollständige Nachladen des gesamten Katalogs für jeden abonnierten Kanal an. Ältere Videos und die Suche werden vollständig, soweit das gemeinsame Tageskontingent es zulässt — das kann eine Weile dauern.",
@ -19,6 +19,20 @@
},
"loading": "Kanäle werden geladen…",
"empty": "Keine Kanäle.",
"pager": {
"prev": "Zurück",
"next": "Weiter",
"page": "Seite {{page}} von {{total}}"
},
"cols": {
"priority": "Prio",
"channel": "Kanal",
"stored": "Gespeichert",
"subs": "Abonnenten",
"sync": "Sync",
"tags": "Tags",
"actions": "Aktionen"
},
"stats": {
"channels": "Kanäle",
"channelsHint": "Kanäle, die du abonniert hast.",
@ -36,6 +50,7 @@
"row": {
"stored": "{{formatted}} gespeichert",
"subs": "{{formatted}} Abonnenten",
"openOnYouTube": "Auf YouTube öffnen",
"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",
@ -44,6 +59,13 @@
"recentNotSyncedHint": "Neueste Uploads noch nicht geladen.",
"full": "vollständig",
"fullHint": "Vollständiger Verlauf geladen.",
"fullNotFetchedHint": "Das vollständige Archiv ist noch nicht geladen — nutze die Backfill-Aktion in der Spalte Aktionen.",
"fullySynced": "vollständig synchron",
"fullySyncedHint": "Aktuelle Uploads und das vollständige Archiv sind beide vorhanden.",
"backfillThis": "Diesen Kanal vollständig laden",
"backfillThisHint": "Das vollständige Archiv dieses Kanals laden (ältere Videos + komplette Suche). Erneut klicken, um die Anfrage zurückzuziehen.",
"resetBackfill": "Kanal zurücksetzen & neu laden",
"resetHint": "Admin: diesen Kanal von Grund auf neu laden (aktuelle + vollständiges Archiv), unabhängig vom aktuellen Sync-Status.",
"fullHistoryQueued": "vollständiger Verlauf in Warteschlange",
"fullHistoryComing": "vollständiger Verlauf unterwegs",
"queuedRequestedHint": "Vollständiger Verlauf angefordert — der gesamte Katalog dieses Kanals wird nachgeladen, soweit das gemeinsame Kontingent es zulässt. Klicke, um deine Anforderung abzubrechen.",
@ -66,7 +88,10 @@
"fullHistoryRequested": "Vollständiger Verlauf für {{count}} Kanäle angefordert",
"fullHistoryFailed": "Vollständiger Verlauf konnte nicht angefordert werden",
"needYouTube": "Verbinde dein YouTube-Konto, um dies zu tun.",
"connect": "Verbinden"
"connect": "Verbinden",
"resetDone": "Kanal zurückgesetzt — wird neu geladen",
"resetFailed": "Kanal konnte nicht zurückgesetzt werden"
},
"confirmUnsubscribe": "Kanal „{{name}}“ bei YouTube abbestellen? Dies ändert dein echtes YouTube-Konto. Um ihn nur aus deinem Feed zu entfernen, blende ihn stattdessen aus."
"confirmUnsubscribe": "Kanal „{{name}}“ bei YouTube abbestellen? Dies ändert dein echtes YouTube-Konto. Um ihn nur aus deinem Feed zu entfernen, blende ihn stattdessen aus.",
"confirmReset": "Kanal „{{name}}“ von Grund auf neu laden? Dies startet den Backfill neu (aktuell + vollständiger Verlauf) und verbraucht etwas API-Kontingent."
}

View file

@ -0,0 +1,15 @@
{
"filter": "Filtern",
"clear": "Löschen",
"all": "Alle",
"rowsPerPage": "Zeilen pro Seite",
"noOptions": "Keine Optionen",
"empty": "Nichts anzuzeigen.",
"pager": {
"prev": "Zurück",
"next": "Weiter",
"page": "Seite {{page}} von {{total}}",
"pageLabel": "Seite",
"ofTotal": "von {{total}}"
}
}

View file

@ -1,7 +1,7 @@
{
"intro": "Set a channel's <0>priority</0> to push its videos up when you sort by “Channel priority”, attach your own <1>tags</1> to filter the feed, or <2>hide</2> a channel to drop it from the feed without unsubscribing.",
"filterPlaceholder": "Filter channels…",
"syncSubscriptions": "Sync subscriptions",
"syncSubscriptions": "Read subscriptions from YouTube",
"syncSubscriptionsHint": "Re-import your subscription list from YouTube — adds channels you've newly followed and drops ones you've unfollowed. The videos themselves keep syncing automatically in the background; this does not re-fetch them.",
"backfillEverything": "Backfill everything",
"backfillEverythingHint": "Request full back-catalog backfill for every channel you're subscribed to. Older videos and search become complete as the shared daily quota allows — this can take a while.",
@ -19,6 +19,20 @@
},
"loading": "Loading channels…",
"empty": "No channels.",
"pager": {
"prev": "Previous",
"next": "Next",
"page": "Page {{page}} of {{total}}"
},
"cols": {
"priority": "Prio",
"channel": "Channel",
"stored": "Stored",
"subs": "Subscribers",
"sync": "Sync",
"tags": "Tags",
"actions": "Actions"
},
"stats": {
"channels": "Channels",
"channelsHint": "Channels you're subscribed to.",
@ -36,6 +50,7 @@
"row": {
"stored": "{{formatted}} stored",
"subs": "{{formatted}} subs",
"openOnYouTube": "Open on YouTube",
"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",
@ -44,6 +59,13 @@
"recentNotSyncedHint": "Recent uploads not fetched yet.",
"full": "full",
"fullHint": "Full history fetched.",
"fullNotFetchedHint": "Full back-catalog not fetched yet — use the backfill action in the Actions column.",
"fullySynced": "fully synced",
"fullySyncedHint": "Recent uploads and the full back-catalog are both in.",
"backfillThis": "Backfill this channel",
"backfillThisHint": "Fetch this channel's full back-catalog (older videos + complete search). Click again to cancel the request.",
"resetBackfill": "Reset & re-fetch this channel",
"resetHint": "Admin: re-fetch this channel from scratch (recent + full back-catalog), regardless of its current sync state.",
"fullHistoryQueued": "full history queued",
"fullHistoryComing": "full history coming",
"queuedRequestedHint": "Full history requested — this channel's whole back-catalog will backfill as the shared quota allows. Click to cancel your request.",
@ -66,7 +88,10 @@
"fullHistoryRequested": "Full history requested for {{count}} channels",
"fullHistoryFailed": "Couldn't request full history",
"needYouTube": "Connect your YouTube account to do this.",
"connect": "Connect"
"connect": "Connect",
"resetDone": "Channel reset — re-fetching now",
"resetFailed": "Couldn't reset this channel"
},
"confirmUnsubscribe": "Unsubscribe from \"{{name}}\" on YouTube? This changes your real YouTube account. To just remove it from your feed, hide it instead."
"confirmUnsubscribe": "Unsubscribe from \"{{name}}\" on YouTube? This changes your real YouTube account. To just remove it from your feed, hide it instead.",
"confirmReset": "Re-fetch \"{{name}}\" from scratch? This re-runs its backfill (recent + full history) and spends some API quota."
}

View file

@ -0,0 +1,15 @@
{
"filter": "Filter",
"clear": "Clear",
"all": "All",
"rowsPerPage": "Rows per page",
"noOptions": "No options",
"empty": "Nothing to show.",
"pager": {
"prev": "Previous",
"next": "Next",
"page": "Page {{page}} of {{total}}",
"pageLabel": "Page",
"ofTotal": "of {{total}}"
}
}

View file

@ -1,7 +1,7 @@
{
"intro": "Állíts be egy csatornának <0>prioritást</0>, hogy a videói előrébb kerüljenek, amikor „Csatorna prioritás” szerint rendezel, csatolj saját <1>címkéket</1> a hírfolyam szűréséhez, vagy <2>rejts el</2> egy csatornát, hogy leiratkozás nélkül kivedd a hírfolyamból.",
"filterPlaceholder": "Csatornák szűrése…",
"syncSubscriptions": "Feliratkozások szinkronizálása",
"syncSubscriptions": "Feliratkozások beolvasása a YouTube-ról",
"syncSubscriptionsHint": "Újraimportálja a feliratkozási listádat a YouTube-ról — hozzáadja az újonnan követett csatornákat, és eltávolítja azokat, amelyekről leiratkoztál. Maguk a videók a háttérben automatikusan tovább szinkronizálódnak; ez nem tölti le őket újra.",
"backfillEverything": "Minden letöltése",
"backfillEverythingHint": "Teljes archívum letöltését kéri minden csatornához, amelyre feliratkoztál. A régebbi videók és a keresés a megosztott napi kvóta függvényében válik teljessé — ez eltarthat egy ideig.",
@ -19,6 +19,20 @@
},
"loading": "Csatornák betöltése…",
"empty": "Nincsenek csatornák.",
"pager": {
"prev": "Előző",
"next": "Következő",
"page": "{{page}}. oldal / {{total}}"
},
"cols": {
"priority": "Prio",
"channel": "Csatorna",
"stored": "Tárolt",
"subs": "Feliratkozók",
"sync": "Szinkron",
"tags": "Címkék",
"actions": "Műveletek"
},
"stats": {
"channels": "Csatorna",
"channelsHint": "Csatornák, amelyekre feliratkoztál.",
@ -36,6 +50,7 @@
"row": {
"stored": "{{formatted}} tárolt",
"subs": "{{formatted}} feliratkozó",
"openOnYouTube": "Megnyitás a YouTube-on",
"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",
@ -44,6 +59,13 @@
"recentNotSyncedHint": "A legutóbbi feltöltések még nincsenek letöltve.",
"full": "teljes",
"fullHint": "Teljes előzmény letöltve.",
"fullNotFetchedHint": "A teljes archívum még nincs letöltve — használd a Műveletek oszlop backfill gombját.",
"fullySynced": "teljesen szinkronban",
"fullySyncedHint": "A legutóbbi feltöltések és a teljes archívum is megvan.",
"backfillThis": "Csatorna teljes letöltése",
"backfillThisHint": "A csatorna teljes archívumának letöltése (régebbi videók + teljes keresés). Újra kattintva visszavonod a kérést.",
"resetBackfill": "Csatorna resetelése és újraletöltése",
"resetHint": "Admin: a csatorna teljes újraletöltése a nulláról (legutóbbi + teljes archívum), a jelenlegi szinkron-állapottól függetlenül.",
"fullHistoryQueued": "teljes előzmény sorban",
"fullHistoryComing": "teljes előzmény úton",
"queuedRequestedHint": "Teljes előzmény kérve — a csatorna teljes archívuma letöltődik, ahogy a megosztott kvóta engedi. Kattints a kérés visszavonásához.",
@ -66,7 +88,10 @@
"fullHistoryRequested": "Teljes előzmény kérve {{count}} csatornához",
"fullHistoryFailed": "Nem sikerült teljes előzményt kérni",
"needYouTube": "Ehhez csatlakoztasd a YouTube-fiókod.",
"connect": "Csatlakoztatás"
"connect": "Csatlakoztatás",
"resetDone": "Csatorna resetelve — újraletöltés folyamatban",
"resetFailed": "Nem sikerült resetelni a csatornát"
},
"confirmUnsubscribe": "Leiratkozol a(z) „{{name}}” csatornáról a YouTube-on? Ez megváltoztatja a valódi YouTube-fiókodat. Ha csak a hírfolyamodból szeretnéd eltávolítani, inkább rejtsd el."
"confirmUnsubscribe": "Leiratkozol a(z) „{{name}}” csatornáról a YouTube-on? Ez megváltoztatja a valódi YouTube-fiókodat. Ha csak a hírfolyamodból szeretnéd eltávolítani, inkább rejtsd el.",
"confirmReset": "Újraletöltöd a(z) „{{name}}” csatornát a nulláról? Ez újrafuttatja a backfillt (legutóbbi + teljes előzmény), és némi API-kvótát fogyaszt."
}

View file

@ -0,0 +1,15 @@
{
"filter": "Szűrés",
"clear": "Törlés",
"all": "Mind",
"rowsPerPage": "Sor/oldal",
"noOptions": "Nincs lehetőség",
"empty": "Nincs megjeleníthető elem.",
"pager": {
"prev": "Előző",
"next": "Következő",
"page": "{{page}}. oldal / {{total}}",
"pageLabel": "Oldal",
"ofTotal": "/ {{total}}"
}
}

View file

@ -93,6 +93,12 @@ html[data-theme="dark"] .glass-menu {
backdrop-filter: blur(30px) saturate(1.7) brightness(1.4);
-webkit-backdrop-filter: blur(30px) saturate(1.7) brightness(1.4);
}
/* Toasts surface bottom-left (by the nav's notification bell), off the conventional
top-right; a brighter rim draws the eye there. Most needed in dark mode, where the
default glass border (dark navy) all but vanishes against the backdrop. */
html[data-theme="dark"] .toast-card {
border-color: color-mix(in srgb, #fff 50%, transparent);
}
/* Performance mode (Settings → Appearance) drops the expensive blur + soft shadows. */
html[data-perf="1"] .glass,

View file

@ -381,6 +381,8 @@ export const api = {
id: string,
patch: { priority?: number; hidden?: boolean; deep_requested?: boolean }
) => req(`/api/channels/${id}`, { method: "PATCH", body: JSON.stringify(patch) }),
resetChannelBackfill: (id: string) =>
req(`/api/channels/${id}/reset-backfill`, { method: "POST" }),
deepAll: (on = true) =>
req(`/api/sync/deep-all?on=${on}`, { method: "POST" }),
attachChannelTag: (id: string, tagId: number) =>

View file

@ -14,6 +14,22 @@ export interface ReleaseEntry {
}
export const RELEASE_NOTES: ReleaseEntry[] = [
{
version: "0.7.0",
date: "2026-06-17",
summary: "A redesigned, sortable Channel manager — plus a reorganized sidebar and several fixes.",
features: [
"Channel manager is now a sortable, filterable table: sort by any column, filter channels by name or tag straight from the headers, choose how many rows per page (or show them all), and jump to any page. Your sorting, filters and page are remembered across reloads.",
"Channel rows: open a channel on YouTube (middle-click the name or the ↗ icon), a single “fully synced” badge once a channel is fully caught up, and instant tag toggling. Admins can reset a channel to re-fetch it from scratch.",
"Reorganized left sidebar: your content (Feed, Channels, Playlists) and the admin tools (Stats, Scheduler) are now grouped, and the language, About and notifications controls moved into the sidebar. Pop-up notifications now appear bottom-left, by the bell.",
"Playlists: the “new playlist” box moved to the top of the list.",
],
fixes: [
"Marking a video watched at the end of playback now sticks — it leaves the Unwatched feed and stays gone after a refresh.",
"Settings now remembers which tab you were on after a reload.",
"The usage chart's daily bars are always visible now, not just on hover.",
],
},
{
version: "0.6.0",
date: "2026-06-16",