feat(layout): full-height collapsible filter sidebar; sync status + scope relocated
Restructure the app shell into three top-level columns: - The per-user sync status (video counts + live sync state) moves from the top bar to a compact block at the top of the left nav rail (icon-only with a tooltip when collapsed). - The feed's Mine/Library scope toggle moves to the top of the filter sidebar. - The filter sidebar becomes a full-height sibling column with its own collapse control (a thin rail carrying the active-filter count), mirroring the nav rail. The top bar is now just the feed search / page title. - Both panels' collapsed state is persisted to the user's preferences (server-side, so it follows the account across devices), seeded from a localStorage cache to avoid a flash. Default: both panels open.
This commit is contained in:
parent
c6fe94450b
commit
072b3296a3
9 changed files with 255 additions and 78 deletions
|
|
@ -19,7 +19,7 @@ import {
|
|||
} from "./lib/sidebarLayout";
|
||||
import { configureNotifications, getNotifSettings, notify, removeByMetaKind, type NotifSettings } from "./lib/notifications";
|
||||
import { hintsEnabled, setHintsEnabled } from "./lib/hints";
|
||||
import { LS, readJSON, readMerged, usePersistedState } from "./lib/storage";
|
||||
import { LS, readJSON, readMerged, usePersistedState, writeJSON } from "./lib/storage";
|
||||
import { useConfirm } from "./components/ConfirmProvider";
|
||||
import type { PrefsController } from "./components/SettingsPanel";
|
||||
import Welcome from "./components/Welcome";
|
||||
|
|
@ -257,6 +257,26 @@ export default function App() {
|
|||
api.savePrefs({ sidebarLayout: next }).catch(() => {});
|
||||
}
|
||||
|
||||
// Collapse state for the two full-height panels (left nav + filter sidebar). Persisted to the
|
||||
// user's preferences so it follows the account across devices; a localStorage cache seeds the
|
||||
// initial render synchronously so nothing flashes open before the server prefs arrive.
|
||||
const [navCollapsed, setNavCollapsedState] = useState<boolean>(() =>
|
||||
Boolean(readJSON(LS.navCollapsed, false))
|
||||
);
|
||||
const [filterCollapsed, setFilterCollapsedState] = useState<boolean>(() =>
|
||||
Boolean(readJSON(LS.filterCollapsed, false))
|
||||
);
|
||||
function setNavCollapsed(next: boolean) {
|
||||
setNavCollapsedState(next);
|
||||
writeJSON(LS.navCollapsed, next);
|
||||
api.savePrefs({ navCollapsed: next }).catch(() => {});
|
||||
}
|
||||
function setFilterCollapsed(next: boolean) {
|
||||
setFilterCollapsedState(next);
|
||||
writeJSON(LS.filterCollapsed, next);
|
||||
api.savePrefs({ filterCollapsed: next }).catch(() => {});
|
||||
}
|
||||
|
||||
useEffect(() => applyTheme(theme), [theme]);
|
||||
|
||||
// Apply the draft prefs locally for instant preview (their localStorage mirrors update via
|
||||
|
|
@ -423,6 +443,14 @@ export default function App() {
|
|||
setSidebarLayoutState(l);
|
||||
saveLayoutLocal(l);
|
||||
}
|
||||
if (typeof prefs.navCollapsed === "boolean") {
|
||||
setNavCollapsedState(prefs.navCollapsed);
|
||||
writeJSON(LS.navCollapsed, prefs.navCollapsed);
|
||||
}
|
||||
if (typeof prefs.filterCollapsed === "boolean") {
|
||||
setFilterCollapsedState(prefs.filterCollapsed);
|
||||
writeJSON(LS.filterCollapsed, prefs.filterCollapsed);
|
||||
}
|
||||
if (isSupported(prefs.language)) setLanguage(prefs.language);
|
||||
// The demo account is shared: there are no subscriptions, so default it to the whole
|
||||
// library (the "my" feed would be empty). The communal-state warning is a permanent
|
||||
|
|
@ -564,7 +592,29 @@ export default function App() {
|
|||
onOpenAbout={() => setAboutOpen(true)}
|
||||
onChangeLanguage={changeLanguage}
|
||||
language={i18n.language as LangCode}
|
||||
collapsed={navCollapsed}
|
||||
onToggleCollapse={() => setNavCollapsed(!navCollapsed)}
|
||||
onGoToFullHistory={() => {
|
||||
setChannelFilter("needs_full");
|
||||
setChannelsView("subscribed"); // the status filter applies to the subscriptions tab
|
||||
setChannelsFilterReset((n) => n + 1); // drop any stale column filter hiding the rows
|
||||
setPage("channels");
|
||||
}}
|
||||
/>
|
||||
{/* Full-height filter sidebar: feed only, and hidden while a channel page overlays the
|
||||
content column. Sits beside the nav rail as its own collapsible column. */}
|
||||
{page === "feed" && !channelView && (
|
||||
<Sidebar
|
||||
filters={filters}
|
||||
setFilters={setFilters}
|
||||
layout={sidebarLayout}
|
||||
setLayout={setSidebarLayout}
|
||||
onFocusChannel={focusChannel}
|
||||
isDemo={meQuery.data!.is_demo}
|
||||
collapsed={filterCollapsed}
|
||||
onToggleCollapse={() => setFilterCollapsed(!filterCollapsed)}
|
||||
/>
|
||||
)}
|
||||
<div className="relative flex-1 min-w-0 flex flex-col">
|
||||
{channelView ? (
|
||||
<ChannelPage
|
||||
|
|
@ -584,26 +634,9 @@ export default function App() {
|
|||
setFilters={setFilters}
|
||||
page={page}
|
||||
onYtSearch={enterYtSearch}
|
||||
onGoToFullHistory={() => {
|
||||
setChannelFilter("needs_full");
|
||||
setChannelsView("subscribed"); // the status filter applies to the subscriptions tab
|
||||
setChannelsFilterReset((n) => n + 1); // drop any stale column filter hiding the rows
|
||||
setPage("channels");
|
||||
}}
|
||||
/>
|
||||
{meQuery.data!.is_demo && <DemoBanner />}
|
||||
<VersionBanner onOpen={() => openReleaseNotes(CURRENT_VERSION)} />
|
||||
<div className="flex flex-1 min-h-0">
|
||||
{page === "feed" && (
|
||||
<Sidebar
|
||||
filters={filters}
|
||||
setFilters={setFilters}
|
||||
layout={sidebarLayout}
|
||||
setLayout={setSidebarLayout}
|
||||
onFocusChannel={focusChannel}
|
||||
isDemo={meQuery.data!.is_demo}
|
||||
/>
|
||||
)}
|
||||
<main className="flex-1 min-w-0 overflow-y-auto">
|
||||
{page === "channels" ? (
|
||||
<Channels
|
||||
|
|
@ -661,7 +694,6 @@ export default function App() {
|
|||
/>
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{/* Toasts rise from the bottom-left, by the notification bell in the nav rail.
|
||||
|
|
|
|||
|
|
@ -1,25 +1,22 @@
|
|||
import { useTranslation } from "react-i18next";
|
||||
import { Library, Search, User, X, Youtube } from "lucide-react";
|
||||
import { Search, X, Youtube } from "lucide-react";
|
||||
import type { FeedFilters, Me } from "../lib/api";
|
||||
import type { Page } from "../lib/urlState";
|
||||
import SyncStatus from "./SyncStatus";
|
||||
|
||||
// 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
|
||||
// page title), the language switcher and the notification bell.
|
||||
// Contextual top bar over the content column. Primary navigation, account and the per-user sync
|
||||
// status live in the left NavSidebar; the feed's scope toggle now lives in the filter sidebar.
|
||||
// The header carries just the feed search (or the current page title).
|
||||
export default function Header({
|
||||
me,
|
||||
filters,
|
||||
setFilters,
|
||||
page,
|
||||
onGoToFullHistory,
|
||||
onYtSearch,
|
||||
}: {
|
||||
me: Me;
|
||||
filters: FeedFilters;
|
||||
setFilters: (f: FeedFilters) => void;
|
||||
page: Page;
|
||||
onGoToFullHistory: () => void;
|
||||
// Trigger a live YouTube search for the current term (hidden for the demo account, which
|
||||
// can't spend the shared quota).
|
||||
onYtSearch: (q: string) => void;
|
||||
|
|
@ -30,37 +27,6 @@ export default function Header({
|
|||
|
||||
return (
|
||||
<header className="glass h-14 shrink-0 border-b border-border flex items-center gap-3 px-4 z-20">
|
||||
<SyncStatus isAdmin={me.role === "admin"} onGoToFullHistory={onGoToFullHistory} />
|
||||
|
||||
{page === "feed" && (
|
||||
<div
|
||||
className="flex items-center rounded-full border border-border bg-card p-0.5 text-xs shrink-0"
|
||||
role="group"
|
||||
aria-label={t("header.scope.label")}
|
||||
>
|
||||
{(["my", "all"] as const).map((s) => (
|
||||
<button
|
||||
key={s}
|
||||
onClick={() => setFilters({ ...filters, scope: s })}
|
||||
title={t("header.scope." + s + "Tip")}
|
||||
aria-pressed={filters.scope === s}
|
||||
className={`inline-flex items-center gap-1 px-2.5 py-1 rounded-full transition ${
|
||||
filters.scope === s
|
||||
? "bg-accent text-accent-fg"
|
||||
: "text-muted hover:text-fg"
|
||||
}`}
|
||||
>
|
||||
{s === "my" ? (
|
||||
<User className="w-3.5 h-3.5" />
|
||||
) : (
|
||||
<Library className="w-3.5 h-3.5" />
|
||||
)}
|
||||
<span className="hidden sm:inline">{t("header.scope." + s)}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{page === "feed" ? (
|
||||
<div className="flex-1 max-w-xl mx-auto flex items-center gap-2">
|
||||
<div className="flex-1 relative">
|
||||
|
|
|
|||
|
|
@ -23,11 +23,11 @@ import {
|
|||
import { api, type Me } from "../lib/api";
|
||||
import { useLiveQuery } from "../lib/useLiveQuery";
|
||||
import { getUnreadCount, subscribe } from "../lib/notifications";
|
||||
import { LS } from "../lib/storage";
|
||||
import type { Page } from "../lib/urlState";
|
||||
import { type LangCode } from "../i18n";
|
||||
import AvatarImg from "./Avatar";
|
||||
import LanguageSwitcher from "./LanguageSwitcher";
|
||||
import SyncStatus from "./SyncStatus";
|
||||
|
||||
// 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
|
||||
|
|
@ -39,6 +39,9 @@ export default function NavSidebar({
|
|||
onOpenAbout,
|
||||
onChangeLanguage,
|
||||
language,
|
||||
collapsed,
|
||||
onToggleCollapse,
|
||||
onGoToFullHistory,
|
||||
}: {
|
||||
me: Me;
|
||||
page: Page;
|
||||
|
|
@ -46,11 +49,13 @@ export default function NavSidebar({
|
|||
onOpenAbout: () => void;
|
||||
onChangeLanguage: (code: LangCode) => void;
|
||||
language: LangCode;
|
||||
// Collapse state is owned by App (persisted to the user's preferences); the rail just reflects
|
||||
// it and asks App to flip it.
|
||||
collapsed: boolean;
|
||||
onToggleCollapse: () => void;
|
||||
onGoToFullHistory: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [collapsed, setCollapsed] = useState<boolean>(
|
||||
() => localStorage.getItem(LS.navCollapsed) === "1"
|
||||
);
|
||||
const [acctOpen, setAcctOpen] = useState(false);
|
||||
const acctBtnRef = useRef<HTMLButtonElement | null>(null);
|
||||
const acctPanelRef = useRef<HTMLDivElement | null>(null);
|
||||
|
|
@ -88,14 +93,6 @@ export default function NavSidebar({
|
|||
};
|
||||
}, [acctOpen]);
|
||||
|
||||
function toggleCollapsed() {
|
||||
setCollapsed((c) => {
|
||||
const next = !c;
|
||||
localStorage.setItem(LS.navCollapsed, next ? "1" : "0");
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
async function logout() {
|
||||
await fetch("/auth/logout", { method: "POST", credentials: "include" });
|
||||
location.reload();
|
||||
|
|
@ -247,7 +244,7 @@ export default function NavSidebar({
|
|||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={toggleCollapsed}
|
||||
onClick={onToggleCollapse}
|
||||
title={collapsed ? t("nav.expand") : t("nav.collapse")}
|
||||
aria-label={collapsed ? t("nav.expand") : t("nav.collapse")}
|
||||
className="p-1 rounded-md text-muted hover:text-fg hover:bg-card transition"
|
||||
|
|
@ -260,6 +257,16 @@ export default function NavSidebar({
|
|||
</button>
|
||||
</div>
|
||||
|
||||
{/* Per-user sync status (video counts + live sync state), moved out of the old top bar. */}
|
||||
<div className="mb-3 pb-3 border-b border-border">
|
||||
<SyncStatus
|
||||
isAdmin={me.role === "admin"}
|
||||
onGoToFullHistory={onGoToFullHistory}
|
||||
variant="rail"
|
||||
collapsed={collapsed}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-h-0 overflow-y-auto flex flex-col gap-1">
|
||||
{userItems.map(renderItem)}
|
||||
{systemItems.length > 0 &&
|
||||
|
|
|
|||
|
|
@ -4,13 +4,17 @@ import { keepPreviousData, useQuery } from "@tanstack/react-query";
|
|||
import {
|
||||
Check,
|
||||
ChevronDown,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
Eye,
|
||||
EyeOff,
|
||||
GripVertical,
|
||||
Library,
|
||||
Pencil,
|
||||
RefreshCw,
|
||||
RotateCcw,
|
||||
Share2,
|
||||
SlidersHorizontal,
|
||||
User,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
|
|
@ -120,6 +124,8 @@ export default function Sidebar({
|
|||
setLayout,
|
||||
onFocusChannel,
|
||||
isDemo = false,
|
||||
collapsed,
|
||||
onToggleCollapse,
|
||||
}: {
|
||||
filters: FeedFilters;
|
||||
setFilters: (f: FeedFilters) => void;
|
||||
|
|
@ -127,6 +133,10 @@ export default function Sidebar({
|
|||
setLayout: (l: SidebarLayout) => void;
|
||||
onFocusChannel: (name: string) => void;
|
||||
isDemo?: boolean;
|
||||
// Full-height collapse (state owned by App, persisted to the user's preferences), mirroring
|
||||
// the left nav rail.
|
||||
collapsed: boolean;
|
||||
onToggleCollapse: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const tagsQuery = useQuery({ queryKey: ["tags"], queryFn: api.tags });
|
||||
|
|
@ -464,10 +474,74 @@ export default function Sidebar({
|
|||
? orderedAvailable
|
||||
: orderedAvailable.filter((id) => !layout.hidden[id]);
|
||||
|
||||
// Collapsed: a thin rail (mirroring the nav) — an expand control plus a filter icon that
|
||||
// carries the active-filter count so you can tell filters are on without opening it.
|
||||
if (collapsed) {
|
||||
return (
|
||||
<aside className="w-64 shrink-0 border-r border-border bg-surface/40 overflow-y-auto p-4 space-y-3 hidden md:block">
|
||||
<aside className="hidden md:flex w-[46px] shrink-0 flex-col items-center gap-2 border-r border-border bg-surface/40 py-3">
|
||||
<button
|
||||
onClick={onToggleCollapse}
|
||||
title={t("sidebar.expandPanel")}
|
||||
aria-label={t("sidebar.expandPanel")}
|
||||
className="p-1 rounded-md text-muted hover:text-fg hover:bg-card transition"
|
||||
>
|
||||
<ChevronRight className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={onToggleCollapse}
|
||||
title={t("sidebar.filters")}
|
||||
className="relative p-2 rounded-lg text-muted hover:text-fg hover:bg-card transition"
|
||||
>
|
||||
<SlidersHorizontal className="w-5 h-5" />
|
||||
{activeCount > 0 && (
|
||||
<span className="absolute -top-1 -right-1 min-w-[16px] h-4 px-1 rounded-full bg-accent text-accent-fg text-[10px] font-bold leading-none grid place-items-center ring-2 ring-bg">
|
||||
{activeCount > 9 ? "9+" : activeCount}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<aside className="hidden md:block w-64 shrink-0 border-r border-border bg-surface/40 overflow-y-auto p-4 space-y-3">
|
||||
{/* Scope: your own subscriptions (Mine) vs the shared Library — moved out of the top bar. */}
|
||||
<div
|
||||
className="flex items-center rounded-full border border-border bg-card p-0.5 text-xs"
|
||||
role="group"
|
||||
aria-label={t("header.scope.label")}
|
||||
>
|
||||
{(["my", "all"] as const).map((s) => (
|
||||
<button
|
||||
key={s}
|
||||
onClick={() => setFilters({ ...filters, scope: s })}
|
||||
title={t("header.scope." + s + "Tip")}
|
||||
aria-pressed={filters.scope === s}
|
||||
className={`flex-1 inline-flex items-center justify-center gap-1 px-2.5 py-1 rounded-full transition ${
|
||||
filters.scope === s ? "bg-accent text-accent-fg" : "text-muted hover:text-fg"
|
||||
}`}
|
||||
>
|
||||
{s === "my" ? (
|
||||
<User className="w-3.5 h-3.5" />
|
||||
) : (
|
||||
<Library className="w-3.5 h-3.5" />
|
||||
)}
|
||||
<span>{t("header.scope." + s)}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="text-sm font-semibold">
|
||||
<div className="flex items-center gap-1.5 min-w-0">
|
||||
<button
|
||||
onClick={onToggleCollapse}
|
||||
title={t("sidebar.collapsePanel")}
|
||||
aria-label={t("sidebar.collapsePanel")}
|
||||
className="shrink-0 -ml-1 p-1 rounded-md text-muted hover:text-fg hover:bg-card transition"
|
||||
>
|
||||
<ChevronLeft className="w-4 h-4" />
|
||||
</button>
|
||||
<div className="text-sm font-semibold truncate">
|
||||
{t("sidebar.filters")}
|
||||
{activeCount > 0 && (
|
||||
<span className="ml-1.5 text-xs font-medium text-accent">
|
||||
|
|
@ -475,6 +549,7 @@ export default function Sidebar({
|
|||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-0.5">
|
||||
{!editing && (
|
||||
<button
|
||||
|
|
|
|||
|
|
@ -11,9 +11,15 @@ import Tooltip from "./Tooltip";
|
|||
export default function SyncStatus({
|
||||
isAdmin,
|
||||
onGoToFullHistory,
|
||||
variant = "bar",
|
||||
collapsed = false,
|
||||
}: {
|
||||
isAdmin: boolean;
|
||||
onGoToFullHistory: () => void;
|
||||
// "bar" = the legacy horizontal top-bar row. "rail" = a compact block for the top of the
|
||||
// left nav sidebar (stacked; icon-only with a tooltip when the rail is collapsed).
|
||||
variant?: "bar" | "rail";
|
||||
collapsed?: boolean;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const qc = useQueryClient();
|
||||
|
|
@ -51,6 +57,90 @@ export default function SyncStatus({
|
|||
const active = data.sync_active;
|
||||
const showMain = data.paused || active || syncing > 0 || notFull === 0;
|
||||
|
||||
// Calm one-liner describing the current sync state; shared by the bar and rail layouts.
|
||||
const stateNode = data.paused ? (
|
||||
<span className="text-accent font-medium">{t("header.sync.paused")}</span>
|
||||
) : active ? (
|
||||
<span className="flex items-center gap-1">
|
||||
<Loader2 className="w-3.5 h-3.5 animate-spin" />
|
||||
{syncing > 0
|
||||
? t("header.sync.syncing", { count: syncing })
|
||||
: notFull > 0
|
||||
? t("header.sync.backfillingHistory")
|
||||
: t("header.sync.working")}
|
||||
</span>
|
||||
) : syncing > 0 ? (
|
||||
<span className="flex items-center gap-1">
|
||||
<Clock className="w-3.5 h-3.5" />
|
||||
{t("header.sync.recentQueued", { count: syncing })}
|
||||
</span>
|
||||
) : (
|
||||
<span>{t("header.sync.allSynced")}</span>
|
||||
);
|
||||
|
||||
const pauseBtn = isAdmin && (data.paused || syncing > 0 || notFull > 0) && (
|
||||
<button
|
||||
onClick={() => toggle.mutate()}
|
||||
disabled={toggle.isPending}
|
||||
title={data.paused ? t("header.sync.resume") : t("header.sync.pause")}
|
||||
className="p-1 rounded-md hover:bg-card hover:text-fg transition"
|
||||
>
|
||||
{data.paused ? <Play className="w-3.5 h-3.5" /> : <Pause className="w-3.5 h-3.5" />}
|
||||
</button>
|
||||
);
|
||||
|
||||
// Rail: a compact block at the top of the left nav. Collapsed → a single icon (spinner while
|
||||
// syncing) with the counts in a tooltip; a small accent dot flags paused / missing-history.
|
||||
if (variant === "rail") {
|
||||
const countsText = `${formatViews(data.my_videos)} ${t("header.sync.yours")} / ${formatViews(
|
||||
data.total_videos
|
||||
)} ${t("header.sync.total")}`;
|
||||
if (collapsed) {
|
||||
return (
|
||||
<Tooltip hint={countsText}>
|
||||
<div className="relative grid place-items-center py-1 text-muted">
|
||||
{active ? (
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
) : (
|
||||
<Database className="w-4 h-4" />
|
||||
)}
|
||||
{(data.paused || notFull > 0) && (
|
||||
<span className="absolute top-0 right-1 w-2 h-2 rounded-full bg-accent ring-2 ring-bg" />
|
||||
)}
|
||||
</div>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className="text-[11px] text-muted leading-snug space-y-1">
|
||||
<Tooltip hint={t("header.sync.countTooltip")}>
|
||||
<span className="flex items-center gap-1.5 cursor-default">
|
||||
<Database className="w-3.5 h-3.5 shrink-0" />
|
||||
<span className="truncate">
|
||||
<span className="text-fg font-medium">{formatViews(data.my_videos)}</span>{" "}
|
||||
{t("header.sync.yours")}
|
||||
<span className="opacity-40"> / </span>
|
||||
{formatViews(data.total_videos)} {t("header.sync.total")}
|
||||
</span>
|
||||
</span>
|
||||
</Tooltip>
|
||||
{showMain && <div className="flex items-center gap-1.5">{stateNode}</div>}
|
||||
{notFull > 0 && (
|
||||
<Tooltip hint={t("header.sync.fullHistoryTooltip")}>
|
||||
<button
|
||||
onClick={onGoToFullHistory}
|
||||
className="flex items-center gap-1 hover:text-fg underline decoration-dotted decoration-muted/40 underline-offset-4 transition cursor-pointer"
|
||||
>
|
||||
<History className="w-3.5 h-3.5" />
|
||||
{t("header.sync.withoutFullHistory", { count: notFull })}
|
||||
</button>
|
||||
</Tooltip>
|
||||
)}
|
||||
{pauseBtn && <div className="pt-0.5">{pauseBtn}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="hidden lg:flex items-center gap-2 text-xs text-muted">
|
||||
<Tooltip hint={t("header.sync.countTooltip")}>
|
||||
|
|
|
|||
|
|
@ -16,6 +16,8 @@
|
|||
"hideWidget": "Widget ausblenden",
|
||||
"expand": "Ausklappen",
|
||||
"collapse": "Einklappen",
|
||||
"collapsePanel": "Filter einklappen",
|
||||
"expandPanel": "Filter ausklappen",
|
||||
"any": "Beliebig",
|
||||
"all": "Alle",
|
||||
"tagModeTooltip": "Beliebige vs. alle ausgewählten Tags treffen",
|
||||
|
|
|
|||
|
|
@ -16,6 +16,8 @@
|
|||
"hideWidget": "Hide widget",
|
||||
"expand": "Expand",
|
||||
"collapse": "Collapse",
|
||||
"collapsePanel": "Collapse filters",
|
||||
"expandPanel": "Expand filters",
|
||||
"any": "Any",
|
||||
"all": "All",
|
||||
"tagModeTooltip": "Match any vs all selected tags",
|
||||
|
|
|
|||
|
|
@ -16,6 +16,8 @@
|
|||
"hideWidget": "Modul elrejtése",
|
||||
"expand": "Kibontás",
|
||||
"collapse": "Összecsukás",
|
||||
"collapsePanel": "Szűrők összecsukása",
|
||||
"expandPanel": "Szűrők kibontása",
|
||||
"any": "Bármelyik",
|
||||
"all": "Összes",
|
||||
"tagModeTooltip": "Bármelyik vagy az összes kijelölt címke illeszkedjen",
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ export const LS = {
|
|||
statsTab: "siftlode.statsTab",
|
||||
adminUsersTab: "siftlode.adminUsersTab",
|
||||
navCollapsed: "siftlode.navCollapsed",
|
||||
filterCollapsed: "siftlode.filterCollapsed",
|
||||
playlist: "siftlode.playlist",
|
||||
plSort: "siftlode.plSort",
|
||||
notifHistory: "siftlode.notifications",
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue