Merge: promote dev to prod
This commit is contained in:
commit
2536c3d690
25 changed files with 1283 additions and 318 deletions
2
VERSION
2
VERSION
|
|
@ -1 +1 @@
|
||||||
0.6.0
|
0.7.0
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ from app import quota
|
||||||
from app.auth import current_user, has_write_scope
|
from app.auth import current_user, has_write_scope
|
||||||
from app.db import get_db
|
from app.db import get_db
|
||||||
from app.models import Channel, ChannelTag, Subscription, Tag, User, Video
|
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.sync.runner import run_recent_backfill
|
||||||
from app.youtube.client import YouTubeClient, YouTubeError
|
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")
|
@router.delete("/{channel_id}/subscription")
|
||||||
def unsubscribe(
|
def unsubscribe(
|
||||||
channel_id: str,
|
channel_id: str,
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,9 @@ from datetime import date, datetime, timedelta, timezone
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||||
from sqlalchemy import Select, and_, false, func, or_, select
|
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 import Session, aliased
|
||||||
|
from sqlalchemy.orm.exc import StaleDataError
|
||||||
|
|
||||||
from app import quota
|
from app import quota
|
||||||
from app.auth import current_user
|
from app.auth import current_user
|
||||||
|
|
@ -387,29 +389,48 @@ def set_video_state(
|
||||||
if db.get(Video, video_id) is None:
|
if db.get(Video, video_id) is None:
|
||||||
raise HTTPException(status_code=404, detail="Unknown video")
|
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(
|
row = db.execute(
|
||||||
select(VideoState).where(
|
select(VideoState).where(
|
||||||
VideoState.user_id == user.id, VideoState.video_id == video_id
|
VideoState.user_id == user.id, VideoState.video_id == video_id
|
||||||
)
|
)
|
||||||
).scalar_one_or_none()
|
).scalar_one_or_none()
|
||||||
|
|
||||||
if status == "new":
|
|
||||||
if row is not None:
|
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:
|
if row.position_seconds:
|
||||||
row.status = "new"
|
row.status = "new"
|
||||||
row.watched_at = None
|
row.watched_at = None
|
||||||
else:
|
else:
|
||||||
db.delete(row)
|
db.delete(row)
|
||||||
|
try:
|
||||||
db.commit()
|
db.commit()
|
||||||
|
except StaleDataError:
|
||||||
|
db.rollback()
|
||||||
return {"video_id": video_id, "status": "new"}
|
return {"video_id": video_id, "status": "new"}
|
||||||
|
|
||||||
if row is None:
|
# watched / hidden: atomic upsert. The player marks "watched" at end-of-playback at
|
||||||
row = VideoState(user_id=user.id, video_id=video_id)
|
# the same instant a progress checkpoint may DELETE the in-progress row; a plain
|
||||||
db.add(row)
|
# SELECT-then-UPDATE then raises StaleDataError ("0 rows matched") on that race. An
|
||||||
row.status = status
|
# INSERT … ON CONFLICT DO UPDATE keyed on uq_user_video is immune to it.
|
||||||
row.watched_at = datetime.now(timezone.utc) if status == "watched" else row.watched_at
|
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()
|
db.commit()
|
||||||
return {"video_id": video_id, "status": status}
|
return {"video_id": video_id, "status": status}
|
||||||
|
|
||||||
|
|
@ -455,7 +476,12 @@ def set_video_progress(
|
||||||
else:
|
else:
|
||||||
row.position_seconds = 0
|
row.position_seconds = 0
|
||||||
row.progress_updated_at = datetime.now(timezone.utc)
|
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()
|
db.commit()
|
||||||
|
except StaleDataError:
|
||||||
|
db.rollback()
|
||||||
return {"video_id": video_id, "position_seconds": 0}
|
return {"video_id": video_id, "position_seconds": 0}
|
||||||
|
|
||||||
if row is None:
|
if row is None:
|
||||||
|
|
|
||||||
|
|
@ -86,14 +86,25 @@ function loadInitialFilters(): FeedFilters {
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
const { t } = useTranslation();
|
const { t, i18n } = useTranslation();
|
||||||
const [theme, setThemeState] = useState<ThemePrefs>(() => loadLocalTheme());
|
const [theme, setThemeState] = useState<ThemePrefs>(() => loadLocalTheme());
|
||||||
const [filters, setFiltersState] = useState<FeedFilters>(loadInitialFilters);
|
const [filters, setFiltersState] = useState<FeedFilters>(loadInitialFilters);
|
||||||
const [view, setView] = useState<"grid" | "list">("grid");
|
const [view, setView] = useState<"grid" | "list">("grid");
|
||||||
const [sidebarLayout, setSidebarLayoutState] = useState<SidebarLayout>(loadLayout);
|
const [sidebarLayout, setSidebarLayoutState] = useState<SidebarLayout>(loadLayout);
|
||||||
const [page, setPageState] = useState<Page>(loadInitialPage);
|
const [page, setPageState] = useState<Page>(loadInitialPage);
|
||||||
const [wizardOpen, setWizardOpen] = useState(false);
|
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 [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);
|
||||||
|
|
@ -235,14 +246,17 @@ export default function App() {
|
||||||
page={page}
|
page={page}
|
||||||
setPage={setPage}
|
setPage={setPage}
|
||||||
onOpenAbout={() => setAboutOpen(true)}
|
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
|
<Header
|
||||||
me={meQuery.data!}
|
me={meQuery.data!}
|
||||||
filters={filters}
|
filters={filters}
|
||||||
setFilters={setFilters}
|
setFilters={setFilters}
|
||||||
page={page}
|
page={page}
|
||||||
onChangeLanguage={changeLanguage}
|
|
||||||
onGoToFullHistory={() => {
|
onGoToFullHistory={() => {
|
||||||
setChannelFilter("needs_full");
|
setChannelFilter("needs_full");
|
||||||
setPage("channels");
|
setPage("channels");
|
||||||
|
|
@ -263,6 +277,7 @@ export default function App() {
|
||||||
{page === "channels" ? (
|
{page === "channels" ? (
|
||||||
<Channels
|
<Channels
|
||||||
canWrite={meQuery.data!.can_write}
|
canWrite={meQuery.data!.can_write}
|
||||||
|
isAdmin={meQuery.data!.role === "admin"}
|
||||||
statusFilter={channelFilter}
|
statusFilter={channelFilter}
|
||||||
setStatusFilter={setChannelFilter}
|
setStatusFilter={setChannelFilter}
|
||||||
onOpenWizard={() => setWizardOpen(true)}
|
onOpenWizard={() => setWizardOpen(true)}
|
||||||
|
|
@ -298,6 +313,10 @@ export default function App() {
|
||||||
)}
|
)}
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
{wizardOpen && meQuery.data && !meQuery.data.is_demo && (
|
{wizardOpen && meQuery.data && !meQuery.data.is_demo && (
|
||||||
<OnboardingWizard me={meQuery.data} onClose={() => setWizardOpen(false)} />
|
<OnboardingWizard me={meQuery.data} onClose={() => setWizardOpen(false)} />
|
||||||
|
|
@ -314,7 +333,6 @@ export default function App() {
|
||||||
{notesOpen && (
|
{notesOpen && (
|
||||||
<ReleaseNotes onClose={() => setNotesOpen(false)} highlight={notesHighlight} />
|
<ReleaseNotes onClose={() => setNotesOpen(false)} highlight={notesHighlight} />
|
||||||
)}
|
)}
|
||||||
<Toaster />
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,12 +4,13 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import {
|
import {
|
||||||
ArrowDown,
|
ArrowDown,
|
||||||
ArrowUp,
|
ArrowUp,
|
||||||
|
Check,
|
||||||
|
ExternalLink,
|
||||||
Eye,
|
Eye,
|
||||||
EyeOff,
|
EyeOff,
|
||||||
History,
|
History,
|
||||||
Plus,
|
Plus,
|
||||||
RefreshCw,
|
RefreshCw,
|
||||||
Search,
|
|
||||||
UserMinus,
|
UserMinus,
|
||||||
X,
|
X,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
|
@ -18,6 +19,7 @@ import { formatEta } from "../lib/format";
|
||||||
import { notify } from "../lib/notifications";
|
import { notify } from "../lib/notifications";
|
||||||
import Tooltip from "./Tooltip";
|
import Tooltip from "./Tooltip";
|
||||||
import Avatar from "./Avatar";
|
import Avatar from "./Avatar";
|
||||||
|
import DataTable, { type Column } from "./DataTable";
|
||||||
import { 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";
|
||||||
|
|
@ -31,12 +33,14 @@ const STATUS_FILTERS: { id: ChannelStatusFilter; labelKey: string }[] = [
|
||||||
|
|
||||||
export default function Channels({
|
export default function Channels({
|
||||||
canWrite,
|
canWrite,
|
||||||
|
isAdmin,
|
||||||
onViewChannel,
|
onViewChannel,
|
||||||
statusFilter,
|
statusFilter,
|
||||||
setStatusFilter,
|
setStatusFilter,
|
||||||
onOpenWizard,
|
onOpenWizard,
|
||||||
}: {
|
}: {
|
||||||
canWrite: boolean;
|
canWrite: boolean;
|
||||||
|
isAdmin: boolean;
|
||||||
onViewChannel: (id: string, name: string) => void;
|
onViewChannel: (id: string, name: string) => void;
|
||||||
statusFilter: ChannelStatusFilter;
|
statusFilter: ChannelStatusFilter;
|
||||||
setStatusFilter: (f: ChannelStatusFilter) => void;
|
setStatusFilter: (f: ChannelStatusFilter) => void;
|
||||||
|
|
@ -62,7 +66,6 @@ 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 [q, setQ] = useState("");
|
|
||||||
const [newTag, setNewTag] = useState("");
|
const [newTag, setNewTag] = useState("");
|
||||||
|
|
||||||
const invalidate = () => {
|
const invalidate = () => {
|
||||||
|
|
@ -100,14 +103,26 @@ export default function Channels({
|
||||||
.catch(() => qc.invalidateQueries({ queryKey: ["channels"] }));
|
.catch(() => qc.invalidateQueries({ queryKey: ["channels"] }));
|
||||||
};
|
};
|
||||||
|
|
||||||
const attach = useMutation({
|
// Tagging is updated optimistically in place (no refetch of the whole channel list, which
|
||||||
mutationFn: (v: { id: string; tagId: number }) => api.attachChannelTag(v.id, v.tagId),
|
// made the toggle feel ~2s slow); only revert by refetching if the server call fails.
|
||||||
onSuccess: () => qc.invalidateQueries({ queryKey: ["channels"] }),
|
const toggleTag = (id: string, tagId: number, hasTag: boolean) => {
|
||||||
});
|
qc.setQueryData<ManagedChannel[]>(["channels"], (old) =>
|
||||||
const detach = useMutation({
|
(old ?? []).map((ch) =>
|
||||||
mutationFn: (v: { id: string; tagId: number }) => api.detachChannelTag(v.id, v.tagId),
|
ch.id === id
|
||||||
onSuccess: () => qc.invalidateQueries({ queryKey: ["channels"] }),
|
? {
|
||||||
});
|
...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({
|
const syncSubs = useMutation({
|
||||||
mutationFn: () => api.syncSubscriptions(),
|
mutationFn: () => api.syncSubscriptions(),
|
||||||
onSuccess: (r: { subscriptions?: number }) => {
|
onSuccess: (r: { subscriptions?: number }) => {
|
||||||
|
|
@ -136,6 +151,15 @@ export default function Channels({
|
||||||
},
|
},
|
||||||
onError: (e) => notifyActionError(e, "channels.notify.unsubscribeFailed"),
|
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({
|
const deepAll = useMutation({
|
||||||
mutationFn: () => api.deepAll(true),
|
mutationFn: () => api.deepAll(true),
|
||||||
onSuccess: (r: { updated?: number }) => {
|
onSuccess: (r: { updated?: number }) => {
|
||||||
|
|
@ -149,9 +173,10 @@ export default function Channels({
|
||||||
onError: (e) => notifyActionError(e, "channels.notify.fullHistoryFailed"),
|
onError: (e) => notifyActionError(e, "channels.notify.fullHistoryFailed"),
|
||||||
});
|
});
|
||||||
|
|
||||||
const channels = (channelsQuery.data ?? [])
|
// The Sync-status filter stays a top-level chip set (not a column filter) because the
|
||||||
.filter((c) => !q || (c.title ?? "").toLowerCase().includes(q.toLowerCase()))
|
// header's "go to full history" deep-link drives it; the DataTable then handles name
|
||||||
.filter((c) =>
|
// search, tag filtering, sort and pagination over whatever the status chip leaves.
|
||||||
|
const channels = (channelsQuery.data ?? []).filter((c) =>
|
||||||
statusFilter === "needs_full"
|
statusFilter === "needs_full"
|
||||||
? !c.backfill_done
|
? !c.backfill_done
|
||||||
: statusFilter === "fully_synced"
|
: statusFilter === "fully_synced"
|
||||||
|
|
@ -162,11 +187,142 @@ export default function Channels({
|
||||||
);
|
);
|
||||||
const s = statusQuery.data;
|
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 (
|
return (
|
||||||
<div className="p-4 max-w-4xl mx-auto">
|
<div className="p-4 max-w-7xl mx-auto">
|
||||||
{/* Per-user sync status */}
|
{/* 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 && (
|
{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.channels")} value={s.channels_total} hint={t("channels.stats.channelsHint")} />
|
||||||
<Stat
|
<Stat
|
||||||
label={t("channels.stats.recentSynced")}
|
label={t("channels.stats.recentSynced")}
|
||||||
|
|
@ -191,20 +347,10 @@ export default function Channels({
|
||||||
value={s.quota_remaining_today.toLocaleString()}
|
value={s.quota_remaining_today.toLocaleString()}
|
||||||
hint={t("channels.stats.quotaLeftHint")}
|
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>
|
||||||
|
<div className="flex items-center gap-2 shrink-0">
|
||||||
<Tooltip
|
<Tooltip
|
||||||
side="bottom"
|
side="bottom"
|
||||||
hint={t("channels.syncSubscriptionsHint")}
|
hint={t("channels.syncSubscriptionsHint")}
|
||||||
|
|
@ -232,22 +378,6 @@ export default function Channels({
|
||||||
</button>
|
</button>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
|
|
||||||
<p className="text-xs text-muted mb-4 leading-relaxed">
|
<p className="text-xs text-muted mb-4 leading-relaxed">
|
||||||
|
|
@ -298,42 +428,20 @@ export default function Channels({
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Channel list */}
|
{/* Channel table */}
|
||||||
{channelsQuery.isLoading ? (
|
{channelsQuery.isLoading ? (
|
||||||
<div className="text-muted py-8">{t("channels.loading")}</div>
|
<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">
|
<DataTable
|
||||||
{channels.map((c) => (
|
rows={channels}
|
||||||
<ChannelRow
|
columns={columns}
|
||||||
key={c.id}
|
rowKey={(c) => c.id}
|
||||||
c={c}
|
persistKey="siftlode.channelsTable"
|
||||||
userTags={userTags}
|
controlsPosition="top"
|
||||||
canWrite={canWrite}
|
controlsLeading={statusChips}
|
||||||
onUnsubscribe={async () => {
|
rowClassName={(c) => (c.hidden ? "opacity-60" : "")}
|
||||||
const ok = await confirm({
|
emptyText={t("channels.empty")}
|
||||||
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 })
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
@ -361,46 +469,22 @@ function SyncBadge({ ok, label, hint }: { ok: boolean; label: string; hint?: str
|
||||||
return (
|
return (
|
||||||
<Tooltip hint={hint ?? ""}>
|
<Tooltip hint={hint ?? ""}>
|
||||||
<span
|
<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 ? "border-accent/40 text-accent" : "border-border text-muted"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
|
{ok && <Check className="w-3 h-3" />}
|
||||||
{label}
|
{label}
|
||||||
</span>
|
</span>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function ChannelRow({
|
function PriorityCell({ c, onPriority }: { c: ManagedChannel; onPriority: (delta: number) => void }) {
|
||||||
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;
|
|
||||||
}) {
|
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
return (
|
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")}>
|
<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")}>
|
<button onClick={() => onPriority(1)} className="text-muted hover:text-accent" aria-label={t("channels.row.raisePriority")}>
|
||||||
<ArrowUp className="w-3.5 h-3.5" />
|
<ArrowUp className="w-3.5 h-3.5" />
|
||||||
</button>
|
</button>
|
||||||
|
|
@ -410,22 +494,59 @@ function ChannelRow({
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
<Avatar
|
function NameCell({ c, onView }: { c: ManagedChannel; onView: () => void }) {
|
||||||
src={c.thumbnail_url}
|
const { t } = useTranslation();
|
||||||
fallback={c.title ?? ""}
|
const ytUrl = c.handle
|
||||||
className="w-10 h-10 rounded-full shrink-0"
|
? `https://www.youtube.com/@${c.handle.replace(/^@/, "")}`
|
||||||
/>
|
: `https://www.youtube.com/channel/${c.id}`;
|
||||||
|
return (
|
||||||
<div className="min-w-0 flex-1">
|
<div className="flex items-center gap-2 min-w-0">
|
||||||
<button onClick={onView} className="text-sm font-semibold truncate hover:text-accent block max-w-full text-left">
|
<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}
|
{c.title ?? c.id}
|
||||||
</button>
|
</button>
|
||||||
<div className="flex items-center gap-2 text-[11px] text-muted">
|
<Tooltip hint={t("channels.row.openOnYouTube")}>
|
||||||
<span>{t("channels.row.stored", { count: c.stored_videos, formatted: c.stored_videos.toLocaleString() })}</span>
|
<a
|
||||||
{c.subscriber_count != null && <span>· {t("channels.row.subs", { count: c.subscriber_count, formatted: c.subscriber_count.toLocaleString() })}</span>}
|
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>
|
||||||
<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
|
<SyncBadge
|
||||||
ok={c.recent_synced}
|
ok={c.recent_synced}
|
||||||
label={t("channels.row.recent")}
|
label={t("channels.row.recent")}
|
||||||
|
|
@ -434,60 +555,105 @@ function ChannelRow({
|
||||||
{c.backfill_done ? (
|
{c.backfill_done ? (
|
||||||
<SyncBadge ok label={t("channels.row.full")} hint={t("channels.row.fullHint")} />
|
<SyncBadge ok label={t("channels.row.full")} hint={t("channels.row.fullHint")} />
|
||||||
) : c.deep_requested ? (
|
) : c.deep_requested ? (
|
||||||
<Tooltip hint={t("channels.row.queuedRequestedHint")}>
|
<SyncBadge ok={false} label={t("channels.row.fullHistoryQueued")} 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>
|
|
||||||
) : c.deep_in_queue ? (
|
) : c.deep_in_queue ? (
|
||||||
<Tooltip hint={t("channels.row.queuedByOtherHint")}>
|
<SyncBadge ok={false} label={t("channels.row.fullHistoryComing")} 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>
|
|
||||||
) : (
|
) : (
|
||||||
<Tooltip hint={t("channels.row.getFullHistoryHint")}>
|
<SyncBadge ok={false} label={t("channels.row.full")} hint={t("channels.row.fullNotFetchedHint")} />
|
||||||
<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>
|
|
||||||
)}
|
)}
|
||||||
{userTags.map((t) => {
|
</div>
|
||||||
const on = c.tag_ids.includes(t.id);
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
return (
|
||||||
<button
|
<button
|
||||||
key={t.id}
|
key={tg.id}
|
||||||
onClick={() => onToggleTag(t.id)}
|
onClick={() => onToggleTag(tg.id)}
|
||||||
className={`text-[10px] px-1.5 py-0.5 rounded-full border transition ${
|
className={`text-[10px] px-1.5 py-0.5 rounded-full border transition ${
|
||||||
on
|
on
|
||||||
? "bg-accent text-accent-fg border-accent"
|
? "bg-accent text-accent-fg border-accent"
|
||||||
: "bg-card border-border text-muted hover:border-accent"
|
: "bg-card border-border text-muted hover:border-accent"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{t.name}
|
{tg.name}
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
);
|
||||||
|
}
|
||||||
|
|
||||||
<Tooltip
|
function ActionsCell({
|
||||||
hint={c.hidden ? t("channels.row.hiddenHint") : t("channels.row.hideHint")}
|
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")}>
|
<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" />}
|
{c.hidden ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
|
||||||
</button>
|
</button>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
|
|
||||||
{canWrite && (
|
{canWrite && (
|
||||||
<Tooltip hint={t("channels.row.unsubscribeHint")}>
|
<Tooltip hint={t("channels.row.unsubscribeHint")}>
|
||||||
<button
|
<button
|
||||||
|
|
|
||||||
421
frontend/src/components/DataTable.tsx
Normal file
421
frontend/src/components/DataTable.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -147,7 +147,15 @@ export default function Feed({
|
||||||
api
|
api
|
||||||
.setState(id, status)
|
.setState(id, status)
|
||||||
.then(() => qc.invalidateQueries({ queryKey: ["feed"] }))
|
.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") {
|
if (status === "hidden") {
|
||||||
const v = loadedRef.current.find((x) => x.id === id);
|
const v = loadedRef.current.find((x) => x.id === id);
|
||||||
notify({
|
notify({
|
||||||
|
|
|
||||||
|
|
@ -2,10 +2,7 @@ import { useTranslation } from "react-i18next";
|
||||||
import { Library, Search, User } from "lucide-react";
|
import { Library, Search, User } from "lucide-react";
|
||||||
import type { FeedFilters, Me } from "../lib/api";
|
import type { FeedFilters, Me } from "../lib/api";
|
||||||
import type { Page } from "../lib/urlState";
|
import type { Page } from "../lib/urlState";
|
||||||
import { type LangCode } from "../i18n";
|
|
||||||
import SyncStatus from "./SyncStatus";
|
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
|
// 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
|
// header carries the global sync status, the feed's scope toggle + search (or the current
|
||||||
|
|
@ -15,17 +12,15 @@ export default function Header({
|
||||||
filters,
|
filters,
|
||||||
setFilters,
|
setFilters,
|
||||||
page,
|
page,
|
||||||
onChangeLanguage,
|
|
||||||
onGoToFullHistory,
|
onGoToFullHistory,
|
||||||
}: {
|
}: {
|
||||||
me: Me;
|
me: Me;
|
||||||
filters: FeedFilters;
|
filters: FeedFilters;
|
||||||
setFilters: (f: FeedFilters) => void;
|
setFilters: (f: FeedFilters) => void;
|
||||||
page: Page;
|
page: Page;
|
||||||
onChangeLanguage: (code: LangCode) => void;
|
|
||||||
onGoToFullHistory: () => void;
|
onGoToFullHistory: () => void;
|
||||||
}) {
|
}) {
|
||||||
const { t, i18n } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<header className="glass h-14 shrink-0 border-b border-border flex items-center gap-3 px-4 z-20">
|
<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")}
|
: t("header.channelManager")}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="flex items-center gap-1">
|
|
||||||
<LanguageSwitcher value={i18n.language as LangCode} onChange={onChangeLanguage} />
|
|
||||||
<NotificationCenter filters={filters} setFilters={setFilters} />
|
|
||||||
</div>
|
|
||||||
</header>
|
</header>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 { Check, Globe } from "lucide-react";
|
||||||
import { LANGUAGES, type LangCode } from "../i18n";
|
import { LANGUAGES, type LangCode } from "../i18n";
|
||||||
|
|
||||||
// Compact language picker (globe + current code). Presentational: the parent decides what
|
// Compact language picker (globe + current code). Presentational: the parent decides what
|
||||||
// changing the language does (set it; persist server-side when signed in).
|
// 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({
|
export default function LanguageSwitcher({
|
||||||
value,
|
value,
|
||||||
onChange,
|
onChange,
|
||||||
align = "right",
|
align = "right",
|
||||||
|
variant = "header",
|
||||||
}: {
|
}: {
|
||||||
value: LangCode;
|
value: LangCode;
|
||||||
onChange: (code: LangCode) => void;
|
onChange: (code: LangCode) => void;
|
||||||
align?: "left" | "right";
|
align?: "left" | "right";
|
||||||
|
variant?: "header" | "rail";
|
||||||
}) {
|
}) {
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const closeTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
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];
|
const current = LANGUAGES.find((l) => l.code === value) ?? LANGUAGES[0];
|
||||||
|
|
||||||
function openNow() {
|
function openNow() {
|
||||||
|
|
@ -24,6 +35,77 @@ export default function LanguageSwitcher({
|
||||||
function closeSoon() {
|
function closeSoon() {
|
||||||
closeTimer.current = setTimeout(() => setOpen(false), 200);
|
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 (
|
return (
|
||||||
<div className="relative" onMouseEnter={openNow} onMouseLeave={closeSoon}>
|
<div className="relative" onMouseEnter={openNow} onMouseLeave={closeSoon}>
|
||||||
|
|
|
||||||
|
|
@ -16,9 +16,12 @@ import {
|
||||||
Tv,
|
Tv,
|
||||||
UserPlus,
|
UserPlus,
|
||||||
} from "lucide-react";
|
} 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 { Page } from "../lib/urlState";
|
||||||
|
import { type LangCode } from "../i18n";
|
||||||
import AvatarImg from "./Avatar";
|
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
|
// 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
|
// modules used to hide under the avatar dropdown; they now live here. Collapsed, it becomes
|
||||||
|
|
@ -28,11 +31,19 @@ export default function NavSidebar({
|
||||||
page,
|
page,
|
||||||
setPage,
|
setPage,
|
||||||
onOpenAbout,
|
onOpenAbout,
|
||||||
|
onChangeLanguage,
|
||||||
|
language,
|
||||||
|
filters,
|
||||||
|
setFilters,
|
||||||
}: {
|
}: {
|
||||||
me: Me;
|
me: Me;
|
||||||
page: Page;
|
page: Page;
|
||||||
setPage: (p: Page) => void;
|
setPage: (p: Page) => void;
|
||||||
onOpenAbout: () => void;
|
onOpenAbout: () => void;
|
||||||
|
onChangeLanguage: (code: LangCode) => void;
|
||||||
|
language: LangCode;
|
||||||
|
filters: FeedFilters;
|
||||||
|
setFilters: (f: FeedFilters) => void;
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [collapsed, setCollapsed] = useState<boolean>(
|
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: "feed", icon: Home, label: t("header.account.feed") },
|
||||||
{ page: "channels", icon: Tv, label: t("header.account.channels") },
|
{ page: "channels", icon: Tv, label: t("header.account.channels") },
|
||||||
{ page: "playlists", icon: ListVideo, label: t("header.account.playlists") },
|
{ page: "playlists", icon: ListVideo, label: t("header.account.playlists") },
|
||||||
];
|
];
|
||||||
if (me.role === "admin") {
|
const systemItems: NavItem[] =
|
||||||
items.push({ page: "stats", icon: BarChart3, label: t("header.account.stats") });
|
me.role === "admin"
|
||||||
items.push({ page: "scheduler", icon: Activity, label: t("header.account.scheduler") });
|
? [
|
||||||
}
|
{ page: "stats", icon: BarChart3, label: t("header.account.stats") },
|
||||||
|
{ page: "scheduler", icon: Activity, label: t("header.account.scheduler") },
|
||||||
|
]
|
||||||
|
: [];
|
||||||
|
|
||||||
const rowBase =
|
const rowBase =
|
||||||
"w-full flex items-center gap-3 rounded-lg px-2.5 py-2 text-sm transition";
|
"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 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 (
|
return (
|
||||||
<nav
|
<nav
|
||||||
className={`glass relative shrink-0 border-r border-border flex flex-col py-3 transition-[width] ${
|
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>
|
||||||
|
|
||||||
<div className="flex-1 min-h-0 overflow-y-auto flex flex-col gap-1">
|
<div className="flex-1 min-h-0 overflow-y-auto flex flex-col gap-1">
|
||||||
{items.map(({ page: p, icon: Icon, label }) => (
|
{userItems.map(renderItem)}
|
||||||
<button
|
{systemItems.length > 0 && (
|
||||||
key={p}
|
<div className="my-1.5 border-t border-border/70" />
|
||||||
onClick={() => setPage(p)}
|
)}
|
||||||
title={collapsed ? label : undefined}
|
{systemItems.map(renderItem)}
|
||||||
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>
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-2 pt-2 border-t border-border flex flex-col gap-1">
|
<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
|
<button
|
||||||
onClick={() => setPage("settings")}
|
onClick={() => setPage("settings")}
|
||||||
title={collapsed ? t("header.account.settings") : undefined}
|
title={collapsed ? t("header.account.settings") : undefined}
|
||||||
|
|
@ -257,16 +293,6 @@ export default function NavSidebar({
|
||||||
<UserPlus className="w-4 h-4" />
|
<UserPlus className="w-4 h-4" />
|
||||||
{t("header.account.addAccount")}
|
{t("header.account.addAccount")}
|
||||||
</button>
|
</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
|
<button
|
||||||
onClick={logout}
|
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"
|
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"
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import { useEffect, useRef, useState, useSyncExternalStore } from "react";
|
import { useEffect, useRef, useState, useSyncExternalStore } from "react";
|
||||||
|
import { createPortal } from "react-dom";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import type { TFunction } from "i18next";
|
import type { TFunction } from "i18next";
|
||||||
import { useQueryClient } from "@tanstack/react-query";
|
import { useQueryClient } from "@tanstack/react-query";
|
||||||
|
|
@ -31,26 +32,44 @@ function relTime(ts: number, t: TFunction): string {
|
||||||
export default function NotificationCenter({
|
export default function NotificationCenter({
|
||||||
filters,
|
filters,
|
||||||
setFilters,
|
setFilters,
|
||||||
|
variant = "header",
|
||||||
}: {
|
}: {
|
||||||
filters: FeedFilters;
|
filters: FeedFilters;
|
||||||
setFilters: (f: FeedFilters) => void;
|
setFilters: (f: FeedFilters) => void;
|
||||||
|
variant?: "header" | "rail";
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const notifications = useSyncExternalStore(subscribe, getNotifications, getNotifications);
|
const notifications = useSyncExternalStore(subscribe, getNotifications, getNotifications);
|
||||||
const unread = useSyncExternalStore(subscribe, getUnreadCount, getUnreadCount);
|
const unread = useSyncExternalStore(subscribe, getUnreadCount, getUnreadCount);
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const wrap = useRef<HTMLDivElement>(null);
|
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();
|
const qc = useQueryClient();
|
||||||
|
|
||||||
// Close when clicking anywhere outside the panel.
|
// Close when clicking anywhere outside the button + panel.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open) return;
|
if (!open) return;
|
||||||
function onDown(e: MouseEvent) {
|
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);
|
document.addEventListener("mousedown", onDown);
|
||||||
return () => document.removeEventListener("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.
|
// Opening the center marks everything read.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -81,10 +100,21 @@ export default function NotificationCenter({
|
||||||
.catch(() => {});
|
.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 (
|
return (
|
||||||
<div className="relative" ref={wrap}>
|
<div className="relative" ref={wrap}>
|
||||||
<button
|
<button
|
||||||
onClick={() => setOpen((o) => !o)}
|
ref={btnRef}
|
||||||
|
onClick={toggle}
|
||||||
title={t("notifications.title")}
|
title={t("notifications.title")}
|
||||||
className="relative p-2 rounded-lg text-muted hover:text-fg hover:bg-card transition"
|
className="relative p-2 rounded-lg text-muted hover:text-fg hover:bg-card transition"
|
||||||
>
|
>
|
||||||
|
|
@ -96,8 +126,14 @@ export default function NotificationCenter({
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{open && (
|
{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]">
|
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="flex items-center justify-between px-3 py-2 border-b border-border">
|
||||||
<div className="text-sm font-semibold">{t("notifications.title")}</div>
|
<div className="text-sm font-semibold">{t("notifications.title")}</div>
|
||||||
{notifications.length > 0 && (
|
{notifications.length > 0 && (
|
||||||
|
|
|
||||||
|
|
@ -339,12 +339,14 @@ export default function PlayerModal({
|
||||||
|
|
||||||
// Auto-watch only applies to the active item — not to other videos the player
|
// Auto-watch only applies to the active item — not to other videos the player
|
||||||
// navigated to via description links.
|
// navigated to via description links.
|
||||||
const maybeAutoWatch = (current: number, duration: number) => {
|
const maybeAutoWatch = (current: number, duration: number): boolean => {
|
||||||
if (autoMarkedRef.current || duration <= 0 || currentIdRef.current !== id) return;
|
if (autoMarkedRef.current || duration <= 0 || currentIdRef.current !== id) return false;
|
||||||
if (current > duration - FINISH_MARGIN) {
|
if (current > duration - FINISH_MARGIN) {
|
||||||
autoMarkedRef.current = true;
|
autoMarkedRef.current = true;
|
||||||
setWatched(true);
|
setWatched(true);
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
|
return false;
|
||||||
};
|
};
|
||||||
const persist = (): Promise<unknown> | void => {
|
const persist = (): Promise<unknown> | void => {
|
||||||
const p = playerRef.current;
|
const p = playerRef.current;
|
||||||
|
|
@ -352,7 +354,10 @@ export default function PlayerModal({
|
||||||
try {
|
try {
|
||||||
const cur = p.getCurrentTime();
|
const cur = p.getCurrentTime();
|
||||||
const dur = typeof p.getDuration === "function" ? p.getDuration() : 0;
|
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
|
// 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.
|
// be in this user's library, so the server would 404 on it.
|
||||||
if (currentIdRef.current === id) {
|
if (currentIdRef.current === id) {
|
||||||
|
|
|
||||||
|
|
@ -580,6 +580,21 @@ export default function Playlists({ canWrite }: { canWrite: boolean }) {
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</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 && (
|
{playlists.length === 0 && !listQuery.isLoading && (
|
||||||
<div className="text-xs text-muted px-1 py-2">{t("playlists.noneYet")}</div>
|
<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>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</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>
|
</aside>
|
||||||
|
|
||||||
<main className="flex-1 min-w-0 overflow-y-auto p-4">
|
<main className="flex-1 min-w-0 overflow-y-auto p-4">
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,14 @@ const TABS: { id: TabId; icon: typeof Monitor }[] = [
|
||||||
{ id: "account", icon: User },
|
{ 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
|
// 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
|
// 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.
|
// shown by the Header; the tab rail stays as in-page sub-navigation.
|
||||||
|
|
@ -43,7 +51,11 @@ export default function SettingsPanel({
|
||||||
onOpenWizard: () => void;
|
onOpenWizard: () => void;
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation();
|
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 (
|
return (
|
||||||
<div className="p-4 max-w-3xl w-full mx-auto">
|
<div className="p-4 max-w-3xl w-full mx-auto">
|
||||||
|
|
|
||||||
|
|
@ -63,15 +63,21 @@ export default function Stats() {
|
||||||
) : (
|
) : (
|
||||||
<div className="flex items-end gap-0.5 h-24">
|
<div className="flex items-end gap-0.5 h-24">
|
||||||
{data.daily.map((d) => (
|
{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
|
<div
|
||||||
key={d.day}
|
key={d.day}
|
||||||
className="flex-1 bg-accent/70 hover:bg-accent rounded-t transition"
|
className="flex-1 h-full flex items-end bg-card/50 rounded-t"
|
||||||
style={{ height: `${Math.max(2, (d.total / maxDaily) * 100)}%` }}
|
|
||||||
title={t("stats.dailyTooltip", {
|
title={t("stats.dailyTooltip", {
|
||||||
day: d.day,
|
day: d.day,
|
||||||
units: d.total.toLocaleString(),
|
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>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -31,13 +31,13 @@ export default function Toaster() {
|
||||||
const toasts = useSyncExternalStore(subscribe, getActiveToasts, getActiveToasts);
|
const toasts = useSyncExternalStore(subscribe, getActiveToasts, getActiveToasts);
|
||||||
|
|
||||||
return (
|
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) => {
|
{toasts.map((toast) => {
|
||||||
const { icon: Icon, color, bar } = LEVEL_STYLE[toast.level];
|
const { icon: Icon, color, bar } = LEVEL_STYLE[toast.level];
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={toast.id}
|
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}`} />
|
<Icon className={`w-5 h-5 shrink-0 mt-0.5 ${color}`} />
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
|
|
|
||||||
|
|
@ -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.",
|
"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…",
|
"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.",
|
"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",
|
"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.",
|
"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…",
|
"loading": "Kanäle werden geladen…",
|
||||||
"empty": "Keine Kanäle.",
|
"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": {
|
"stats": {
|
||||||
"channels": "Kanäle",
|
"channels": "Kanäle",
|
||||||
"channelsHint": "Kanäle, die du abonniert hast.",
|
"channelsHint": "Kanäle, die du abonniert hast.",
|
||||||
|
|
@ -36,6 +50,7 @@
|
||||||
"row": {
|
"row": {
|
||||||
"stored": "{{formatted}} gespeichert",
|
"stored": "{{formatted}} gespeichert",
|
||||||
"subs": "{{formatted}} Abonnenten",
|
"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.",
|
"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",
|
||||||
|
|
@ -44,6 +59,13 @@
|
||||||
"recentNotSyncedHint": "Neueste Uploads noch nicht geladen.",
|
"recentNotSyncedHint": "Neueste Uploads noch nicht geladen.",
|
||||||
"full": "vollständig",
|
"full": "vollständig",
|
||||||
"fullHint": "Vollständiger Verlauf geladen.",
|
"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",
|
"fullHistoryQueued": "vollständiger Verlauf in Warteschlange",
|
||||||
"fullHistoryComing": "vollständiger Verlauf unterwegs",
|
"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.",
|
"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",
|
"fullHistoryRequested": "Vollständiger Verlauf für {{count}} Kanäle angefordert",
|
||||||
"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",
|
||||||
|
"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."
|
||||||
}
|
}
|
||||||
|
|
|
||||||
15
frontend/src/i18n/locales/de/datatable.json
Normal file
15
frontend/src/i18n/locales/de/datatable.json
Normal 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}}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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.",
|
"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…",
|
"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.",
|
"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",
|
"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.",
|
"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…",
|
"loading": "Loading channels…",
|
||||||
"empty": "No 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": {
|
"stats": {
|
||||||
"channels": "Channels",
|
"channels": "Channels",
|
||||||
"channelsHint": "Channels you're subscribed to.",
|
"channelsHint": "Channels you're subscribed to.",
|
||||||
|
|
@ -36,6 +50,7 @@
|
||||||
"row": {
|
"row": {
|
||||||
"stored": "{{formatted}} stored",
|
"stored": "{{formatted}} stored",
|
||||||
"subs": "{{formatted}} subs",
|
"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.",
|
"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",
|
||||||
|
|
@ -44,6 +59,13 @@
|
||||||
"recentNotSyncedHint": "Recent uploads not fetched yet.",
|
"recentNotSyncedHint": "Recent uploads not fetched yet.",
|
||||||
"full": "full",
|
"full": "full",
|
||||||
"fullHint": "Full history fetched.",
|
"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",
|
"fullHistoryQueued": "full history queued",
|
||||||
"fullHistoryComing": "full history coming",
|
"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.",
|
"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",
|
"fullHistoryRequested": "Full history requested for {{count}} channels",
|
||||||
"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",
|
||||||
|
"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."
|
||||||
}
|
}
|
||||||
|
|
|
||||||
15
frontend/src/i18n/locales/en/datatable.json
Normal file
15
frontend/src/i18n/locales/en/datatable.json
Normal 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}}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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.",
|
"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…",
|
"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.",
|
"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",
|
"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.",
|
"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…",
|
"loading": "Csatornák betöltése…",
|
||||||
"empty": "Nincsenek csatornák.",
|
"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": {
|
"stats": {
|
||||||
"channels": "Csatorna",
|
"channels": "Csatorna",
|
||||||
"channelsHint": "Csatornák, amelyekre feliratkoztál.",
|
"channelsHint": "Csatornák, amelyekre feliratkoztál.",
|
||||||
|
|
@ -36,6 +50,7 @@
|
||||||
"row": {
|
"row": {
|
||||||
"stored": "{{formatted}} tárolt",
|
"stored": "{{formatted}} tárolt",
|
||||||
"subs": "{{formatted}} feliratkozó",
|
"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.",
|
"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",
|
||||||
|
|
@ -44,6 +59,13 @@
|
||||||
"recentNotSyncedHint": "A legutóbbi feltöltések még nincsenek letöltve.",
|
"recentNotSyncedHint": "A legutóbbi feltöltések még nincsenek letöltve.",
|
||||||
"full": "teljes",
|
"full": "teljes",
|
||||||
"fullHint": "Teljes előzmény letöltve.",
|
"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",
|
"fullHistoryQueued": "teljes előzmény sorban",
|
||||||
"fullHistoryComing": "teljes előzmény úton",
|
"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.",
|
"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",
|
"fullHistoryRequested": "Teljes előzmény kérve {{count}} csatornához",
|
||||||
"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",
|
||||||
|
"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."
|
||||||
}
|
}
|
||||||
|
|
|
||||||
15
frontend/src/i18n/locales/hu/datatable.json
Normal file
15
frontend/src/i18n/locales/hu/datatable.json
Normal 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}}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -93,6 +93,12 @@ html[data-theme="dark"] .glass-menu {
|
||||||
backdrop-filter: blur(30px) saturate(1.7) brightness(1.4);
|
backdrop-filter: blur(30px) saturate(1.7) brightness(1.4);
|
||||||
-webkit-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. */
|
/* Performance mode (Settings → Appearance) drops the expensive blur + soft shadows. */
|
||||||
html[data-perf="1"] .glass,
|
html[data-perf="1"] .glass,
|
||||||
|
|
|
||||||
|
|
@ -381,6 +381,8 @@ export const api = {
|
||||||
id: string,
|
id: string,
|
||||||
patch: { priority?: number; hidden?: boolean; deep_requested?: boolean }
|
patch: { priority?: number; hidden?: boolean; deep_requested?: boolean }
|
||||||
) => req(`/api/channels/${id}`, { method: "PATCH", body: JSON.stringify(patch) }),
|
) => req(`/api/channels/${id}`, { method: "PATCH", body: JSON.stringify(patch) }),
|
||||||
|
resetChannelBackfill: (id: string) =>
|
||||||
|
req(`/api/channels/${id}/reset-backfill`, { method: "POST" }),
|
||||||
deepAll: (on = true) =>
|
deepAll: (on = true) =>
|
||||||
req(`/api/sync/deep-all?on=${on}`, { method: "POST" }),
|
req(`/api/sync/deep-all?on=${on}`, { method: "POST" }),
|
||||||
attachChannelTag: (id: string, tagId: number) =>
|
attachChannelTag: (id: string, tagId: number) =>
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,22 @@ export interface ReleaseEntry {
|
||||||
}
|
}
|
||||||
|
|
||||||
export const RELEASE_NOTES: 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",
|
version: "0.6.0",
|
||||||
date: "2026-06-16",
|
date: "2026-06-16",
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue