feat(feed): saved views as a header popover + Personal Views modal + a view chip
Retire the standalone 'Saved views' sidebar panel. The saved-views feature now
lives in three lighter surfaces, coordinated by a new SavedViewsProvider (query +
CRUD + the active/sticky view + modal state):
- the filter panel's header ICON opens a quick-pick popover (apply a view, active
one checked, plus 'Manage views…');
- the header TITLE ('Filters') opens a new Personal Views modal — a master/detail
manager: left = the reorderable list + a save-current-filters input, right = the
selected view's name, its captured filters (shown as the same chips the toolbar
row uses), default toggle, apply, and delete;
- a SelectedViewChip leads the applied-filter row, naming the active view with a
warn-coloured 'modified' dot once the live filters diverge from it; clicking it
reopens the picker.
SidePanel/FilterRail gain optional iconExpanded + onTitleClick slots (feed-only,
hidden for the demo account); the collapsed tab keeps its plain pin behaviour.
SavedViewsWidget is deleted and dropped from the panel layout.
Verified live: icon popover lists HH + Manage; applying HH surfaces the chip and
switches scope; removing a filter lights the dirty dot; the title opens the
master/detail modal with the captured-filters summary. 374 vitest, tsc, eslint
(0 err).
This commit is contained in:
@@ -37,6 +37,7 @@ import { type SortOption } from "../lib/columnSort";
|
|||||||
import { FEED_VIEWS, type FeedView } from "../lib/feedView";
|
import { FEED_VIEWS, type FeedView } from "../lib/feedView";
|
||||||
import { clearedFilters, useActiveFilters } from "../lib/useActiveFilters";
|
import { clearedFilters, useActiveFilters } from "../lib/useActiveFilters";
|
||||||
import ActiveFilterChips from "./ActiveFilterChips";
|
import ActiveFilterChips from "./ActiveFilterChips";
|
||||||
|
import SelectedViewChip from "./SelectedViewChip";
|
||||||
import { LoadingState, StateMessage } from "./QueryState";
|
import { LoadingState, StateMessage } from "./QueryState";
|
||||||
import { PageToolbar } from "./PageShell";
|
import { PageToolbar } from "./PageShell";
|
||||||
import { useWizard } from "./WizardProvider";
|
import { useWizard } from "./WizardProvider";
|
||||||
@@ -677,10 +678,15 @@ export default function Feed({
|
|||||||
<div className={channelScoped ? "px-4 sm:px-6" : "px-4 pt-2 pb-1"}>
|
<div className={channelScoped ? "px-4 sm:px-6" : "px-4 pt-2 pb-1"}>
|
||||||
{toolbar}
|
{toolbar}
|
||||||
{!channelScoped && (
|
{!channelScoped && (
|
||||||
<ActiveFilterChips
|
<div className="flex flex-wrap items-start gap-2">
|
||||||
filters={activeFilters}
|
{/* The active personal view (if any) leads the applied-filter row; ActiveFilterChips
|
||||||
onClearAll={() => setFilters(clearedFilters(filters))}
|
brings the row's own bottom padding. */}
|
||||||
/>
|
<SelectedViewChip />
|
||||||
|
<ActiveFilterChips
|
||||||
|
filters={activeFilters}
|
||||||
|
onClearAll={() => setFilters(clearedFilters(filters))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</PageToolbar>
|
</PageToolbar>
|
||||||
|
|||||||
@@ -22,6 +22,8 @@ export default function FilterRail({
|
|||||||
layout,
|
layout,
|
||||||
setLayout,
|
setLayout,
|
||||||
items,
|
items,
|
||||||
|
iconExpanded,
|
||||||
|
onTitleClick,
|
||||||
children,
|
children,
|
||||||
}: {
|
}: {
|
||||||
kind: PanelId;
|
kind: PanelId;
|
||||||
@@ -34,6 +36,8 @@ export default function FilterRail({
|
|||||||
layout: PanelLayout;
|
layout: PanelLayout;
|
||||||
setLayout: (l: PanelLayout) => void;
|
setLayout: (l: PanelLayout) => void;
|
||||||
items: PanelItem[];
|
items: PanelItem[];
|
||||||
|
iconExpanded?: ReactNode;
|
||||||
|
onTitleClick?: () => void;
|
||||||
children?: ReactNode;
|
children?: ReactNode;
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
@@ -50,6 +54,8 @@ export default function FilterRail({
|
|||||||
onReset={() => setLayout(defaultLayout(kind))}
|
onReset={() => setLayout(defaultLayout(kind))}
|
||||||
editHint={t("sidebar.editHint")}
|
editHint={t("sidebar.editHint")}
|
||||||
actions={actions}
|
actions={actions}
|
||||||
|
iconExpanded={iconExpanded}
|
||||||
|
onTitleClick={onTitleClick}
|
||||||
>
|
>
|
||||||
<PanelGroups items={items} layout={layout} setLayout={setLayout} editing={editing} />
|
<PanelGroups items={items} layout={layout} setLayout={setLayout} editing={editing} />
|
||||||
{children}
|
{children}
|
||||||
|
|||||||
@@ -0,0 +1,220 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { BookmarkPlus, GripVertical, Play, Star, Trash2 } from "lucide-react";
|
||||||
|
import {
|
||||||
|
closestCenter,
|
||||||
|
DndContext,
|
||||||
|
PointerSensor,
|
||||||
|
useSensor,
|
||||||
|
useSensors,
|
||||||
|
type DragEndEvent,
|
||||||
|
} from "@dnd-kit/core";
|
||||||
|
import {
|
||||||
|
arrayMove,
|
||||||
|
SortableContext,
|
||||||
|
useSortable,
|
||||||
|
verticalListSortingStrategy,
|
||||||
|
} from "@dnd-kit/sortable";
|
||||||
|
import { CSS } from "@dnd-kit/utilities";
|
||||||
|
import type { SavedView } from "../lib/api";
|
||||||
|
import Modal from "./Modal";
|
||||||
|
import { useSavedViews, asFilters } from "./SavedViewsProvider";
|
||||||
|
import { useActiveFilters } from "../lib/useActiveFilters";
|
||||||
|
|
||||||
|
// Master–detail manager for saved ("personal") views: the left column lists the views (drag to
|
||||||
|
// reorder) plus a "save the current filters" input; the right column shows the selected view's name,
|
||||||
|
// what filters it captures, its default flag, apply, and delete. Opened from the filter panel's title
|
||||||
|
// or the saved-views popover; all mutations flow through SavedViewsProvider.
|
||||||
|
function ViewRow({
|
||||||
|
view,
|
||||||
|
selected,
|
||||||
|
active,
|
||||||
|
onSelect,
|
||||||
|
}: {
|
||||||
|
view: SavedView;
|
||||||
|
selected: boolean;
|
||||||
|
active: boolean;
|
||||||
|
onSelect: () => void;
|
||||||
|
}) {
|
||||||
|
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
|
||||||
|
id: view.id,
|
||||||
|
});
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={setNodeRef}
|
||||||
|
style={{ transform: CSS.Transform.toString(transform), transition }}
|
||||||
|
className={`flex items-center gap-1.5 rounded-lg pr-1.5 ${isDragging ? "opacity-60" : ""} ${
|
||||||
|
selected ? "bg-accent/15" : "hover:bg-card"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
{...attributes}
|
||||||
|
{...listeners}
|
||||||
|
aria-label="reorder"
|
||||||
|
className="cursor-grab p-1.5 text-muted hover:text-fg"
|
||||||
|
>
|
||||||
|
<GripVertical className="h-3.5 w-3.5" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={onSelect}
|
||||||
|
className={`flex-1 min-w-0 truncate py-1.5 text-left text-sm ${
|
||||||
|
selected ? "text-accent" : "text-fg"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{view.name}
|
||||||
|
</button>
|
||||||
|
{view.is_default && <Star className="h-3 w-3 shrink-0 fill-current text-accent" />}
|
||||||
|
{active && <span className="mr-1 h-1.5 w-1.5 shrink-0 rounded-full bg-accent" />}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function PersonalViewsModal() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const { views, activeViewId, apply, create, rename, toggleDefault, remove, reorder, closeModal } =
|
||||||
|
useSavedViews();
|
||||||
|
const sensors = useSensors(useSensor(PointerSensor, { activationConstraint: { distance: 4 } }));
|
||||||
|
|
||||||
|
const [selectedId, setSelectedId] = useState<number | null>(activeViewId ?? views[0]?.id ?? null);
|
||||||
|
const selected = views.find((v) => v.id === selectedId) ?? null;
|
||||||
|
const [draftName, setDraftName] = useState("");
|
||||||
|
|
||||||
|
// The selected view's captured filters, as the same human labels the toolbar chip row shows.
|
||||||
|
const captured = useActiveFilters(
|
||||||
|
selected ? asFilters(selected.filters) : ({ tags: [], channelIds: [] } as never),
|
||||||
|
() => {},
|
||||||
|
);
|
||||||
|
|
||||||
|
async function onDragEnd(e: DragEndEvent) {
|
||||||
|
const { active, over } = e;
|
||||||
|
if (!over || active.id === over.id) return;
|
||||||
|
const ids = views.map((v) => v.id);
|
||||||
|
const next = arrayMove(ids, ids.indexOf(active.id as number), ids.indexOf(over.id as number));
|
||||||
|
await reorder(next);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onCreate() {
|
||||||
|
const name = draftName.trim();
|
||||||
|
if (!name) return;
|
||||||
|
if (await create(name)) setDraftName("");
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal title={t("views.manageTitle")} onClose={closeModal} maxWidth="max-w-2xl">
|
||||||
|
<div className="grid min-h-[20rem] grid-cols-[minmax(0,14rem)_1fr] gap-4">
|
||||||
|
{/* Left: the list + create */}
|
||||||
|
<div className="flex flex-col border-r border-border/60 pr-4">
|
||||||
|
<div className="flex-1 space-y-1 overflow-y-auto">
|
||||||
|
{views.length === 0 && (
|
||||||
|
<p className="px-1 py-2 text-2xs leading-snug text-muted">{t("views.empty")}</p>
|
||||||
|
)}
|
||||||
|
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={onDragEnd}>
|
||||||
|
<SortableContext
|
||||||
|
items={views.map((v) => v.id)}
|
||||||
|
strategy={verticalListSortingStrategy}
|
||||||
|
>
|
||||||
|
{views.map((v) => (
|
||||||
|
<ViewRow
|
||||||
|
key={v.id}
|
||||||
|
view={v}
|
||||||
|
selected={v.id === selectedId}
|
||||||
|
active={v.id === activeViewId}
|
||||||
|
onSelect={() => setSelectedId(v.id)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</SortableContext>
|
||||||
|
</DndContext>
|
||||||
|
</div>
|
||||||
|
<div className="mt-2 flex items-center gap-1.5 border-t border-border/60 pt-2">
|
||||||
|
<input
|
||||||
|
value={draftName}
|
||||||
|
onChange={(e) => setDraftName(e.target.value)}
|
||||||
|
onKeyDown={(e) => e.key === "Enter" && onCreate()}
|
||||||
|
placeholder={t("views.namePlaceholder")}
|
||||||
|
className="min-w-0 flex-1 rounded-lg border border-border bg-surface px-2 py-1.5 text-sm focus:border-accent focus:outline-hidden focus:ring-2 focus:ring-accent/30"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={onCreate}
|
||||||
|
disabled={!draftName.trim()}
|
||||||
|
title={t("views.save")}
|
||||||
|
className="inline-flex shrink-0 items-center gap-1 rounded-lg btn-primary px-2.5 py-1.5 text-sm font-semibold disabled:opacity-40"
|
||||||
|
>
|
||||||
|
<BookmarkPlus className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Right: the selected view's detail */}
|
||||||
|
{selected ? (
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
<input
|
||||||
|
key={selected.id}
|
||||||
|
defaultValue={selected.name}
|
||||||
|
onBlur={(e) => {
|
||||||
|
const next = e.target.value.trim();
|
||||||
|
if (next && next !== selected.name) void rename(selected.id, next);
|
||||||
|
}}
|
||||||
|
className="rounded-lg border border-border bg-surface px-3 py-2 text-sm font-medium focus:border-accent focus:outline-hidden focus:ring-2 focus:ring-accent/30"
|
||||||
|
/>
|
||||||
|
<div>
|
||||||
|
<div className="mb-1.5 text-2xs font-semibold uppercase tracking-wider text-muted">
|
||||||
|
{t("views.capturedFilters")}
|
||||||
|
</div>
|
||||||
|
{captured.length === 0 ? (
|
||||||
|
<p className="text-xs text-muted">{t("views.capturedNone")}</p>
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-wrap gap-1.5">
|
||||||
|
{captured.map((f) => (
|
||||||
|
<span
|
||||||
|
key={f.key}
|
||||||
|
className="rounded-full bg-accent/10 px-2 py-0.5 text-2xs text-accent"
|
||||||
|
>
|
||||||
|
{f.label}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="mt-auto flex items-center gap-2 border-t border-border/60 pt-3">
|
||||||
|
<button
|
||||||
|
onClick={() => apply(selected)}
|
||||||
|
className="inline-flex items-center gap-1.5 rounded-lg btn-primary px-3 py-1.5 text-sm font-semibold"
|
||||||
|
>
|
||||||
|
<Play className="h-3.5 w-3.5" />
|
||||||
|
{t("views.apply")}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => void toggleDefault(selected)}
|
||||||
|
title={t("views.default")}
|
||||||
|
className={`inline-flex items-center gap-1.5 rounded-lg border px-3 py-1.5 text-sm transition ${
|
||||||
|
selected.is_default
|
||||||
|
? "border-accent text-accent"
|
||||||
|
: "border-border text-muted hover:text-fg"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Star className={`h-3.5 w-3.5 ${selected.is_default ? "fill-current" : ""}`} />
|
||||||
|
{t("views.default")}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
const next = views.find((v) => v.id !== selected.id);
|
||||||
|
void remove(selected);
|
||||||
|
setSelectedId(next?.id ?? null);
|
||||||
|
}}
|
||||||
|
title={t("views.delete")}
|
||||||
|
className="ml-auto inline-flex items-center gap-1.5 rounded-lg border border-border px-3 py-1.5 text-sm text-danger transition hover:border-danger"
|
||||||
|
>
|
||||||
|
<Trash2 className="h-3.5 w-3.5" />
|
||||||
|
{t("views.delete")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex items-center justify-center text-sm text-muted">
|
||||||
|
{t("views.selectPrompt")}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
import { useState, type ReactNode } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { Bookmark, Check, Settings2 } from "lucide-react";
|
||||||
|
import { PopoverItem, PopoverSurface, useAnchoredPopover } from "./AnchoredPopover";
|
||||||
|
import { useSavedViews } from "./SavedViewsProvider";
|
||||||
|
|
||||||
|
// The saved-views quick-picker. The filter-panel header's icon (and the toolbar's view chip) use it as
|
||||||
|
// their trigger: click → a popover list of the user's saved views (apply on click, the active one
|
||||||
|
// checked) + a "Manage views…" entry that opens the Personal Views modal, where the editing lives.
|
||||||
|
export default function SavedViewsPopover({
|
||||||
|
trigger,
|
||||||
|
className,
|
||||||
|
ariaLabel,
|
||||||
|
placement = "bottom-start",
|
||||||
|
}: {
|
||||||
|
trigger: ReactNode;
|
||||||
|
className?: string;
|
||||||
|
ariaLabel?: string;
|
||||||
|
placement?: "bottom-start" | "bottom-end";
|
||||||
|
}) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const { views, activeViewId, apply, openModal } = useSavedViews();
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const pop = useAnchoredPopover({ open, onOpenChange: setOpen, placement });
|
||||||
|
const label = ariaLabel ?? t("views.saved");
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
ref={pop.refs.setReference}
|
||||||
|
{...pop.getReferenceProps()}
|
||||||
|
aria-label={label}
|
||||||
|
className={className ?? "shrink-0 flex text-accent transition hover:text-fg"}
|
||||||
|
>
|
||||||
|
{trigger}
|
||||||
|
</button>
|
||||||
|
<PopoverSurface
|
||||||
|
popover={pop}
|
||||||
|
ariaLabel={label}
|
||||||
|
className="glass-menu min-w-56 rounded-xl p-1.5 shadow-lg animate-[popIn_0.16s_ease]"
|
||||||
|
>
|
||||||
|
{views.length === 0 ? (
|
||||||
|
<p className="px-2 py-1.5 text-2xs leading-snug text-muted">{t("views.empty")}</p>
|
||||||
|
) : (
|
||||||
|
<div role="group" aria-label={label}>
|
||||||
|
{views.map((v) => (
|
||||||
|
<PopoverItem
|
||||||
|
key={v.id}
|
||||||
|
role="menuitemradio"
|
||||||
|
checked={v.id === activeViewId}
|
||||||
|
onSelect={() => {
|
||||||
|
apply(v);
|
||||||
|
setOpen(false);
|
||||||
|
}}
|
||||||
|
label={v.name}
|
||||||
|
className={`flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left text-sm transition ${
|
||||||
|
v.id === activeViewId ? "text-fg" : "text-muted"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Bookmark className="h-3.5 w-3.5 shrink-0 text-muted" aria-hidden="true" />
|
||||||
|
<span className="flex-1 truncate">{v.name}</span>
|
||||||
|
{v.id === activeViewId && <Check className="h-3.5 w-3.5 shrink-0 text-accent" />}
|
||||||
|
</PopoverItem>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="mt-1 border-t border-border pt-1">
|
||||||
|
<PopoverItem
|
||||||
|
role="menuitem"
|
||||||
|
onSelect={() => {
|
||||||
|
openModal();
|
||||||
|
setOpen(false);
|
||||||
|
}}
|
||||||
|
label={t("views.manage")}
|
||||||
|
className="flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left text-sm text-muted transition hover:text-fg"
|
||||||
|
>
|
||||||
|
<Settings2 className="h-3.5 w-3.5 shrink-0" aria-hidden="true" />
|
||||||
|
<span className="flex-1">{t("views.manage")}</span>
|
||||||
|
</PopoverItem>
|
||||||
|
</div>
|
||||||
|
</PopoverSurface>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,176 @@
|
|||||||
|
import { createContext, useContext, useEffect, useMemo, useState, type ReactNode } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { qk } from "../lib/queryKeys";
|
||||||
|
import {
|
||||||
|
api,
|
||||||
|
getActiveAccount,
|
||||||
|
coerceTagModes,
|
||||||
|
type FeedFilters,
|
||||||
|
type SavedView,
|
||||||
|
} from "../lib/api";
|
||||||
|
import { filtersToParams } from "../lib/urlState";
|
||||||
|
import { notifyActionError } from "../lib/actionErrors";
|
||||||
|
import { accountKey, LS, writeJSON } from "../lib/storage";
|
||||||
|
import { useConfirm } from "./ConfirmProvider";
|
||||||
|
import { useFeedFilters, useFeedFiltersActions } from "./FeedFiltersProvider";
|
||||||
|
|
||||||
|
// A saved view's `filters` is an opaque JSON blob from the DB — the FeedFilters type says nothing about
|
||||||
|
// what an older build wrote there, so coerce the array/shape fields (and migrate the legacy global
|
||||||
|
// tagMode) before the blob reaches app state or a serializer. NOT a migration: a view saved against a
|
||||||
|
// dropped field just loses that filter.
|
||||||
|
export const asFilters = (raw: FeedFilters): FeedFilters => ({
|
||||||
|
...raw,
|
||||||
|
tags: raw.tags ?? [],
|
||||||
|
channelIds: raw.channelIds ?? [],
|
||||||
|
tagModes: coerceTagModes(raw),
|
||||||
|
});
|
||||||
|
|
||||||
|
// A stable signature of a filter set (only non-default keys, shuffle seed ignored) so a view can be
|
||||||
|
// matched against the live filters regardless of key order or transient fields.
|
||||||
|
const keyOf = (f: FeedFilters) => filtersToParams(asFilters(f)).toString();
|
||||||
|
|
||||||
|
// Mirror the default view's filters to localStorage so the app can seed them on next load before the
|
||||||
|
// network returns (read by FeedFiltersProvider).
|
||||||
|
function syncDefaultMirror(views: SavedView[]): void {
|
||||||
|
const key = accountKey(LS.defaultViewFilters, getActiveAccount());
|
||||||
|
if (!key) return;
|
||||||
|
const def = views.find((v) => v.is_default);
|
||||||
|
writeJSON(key, def ? def.filters : null);
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SavedViewsContext {
|
||||||
|
views: SavedView[];
|
||||||
|
loading: boolean;
|
||||||
|
/** The view whose saved filters EXACTLY match the live filters (null if none) — highlighted as active. */
|
||||||
|
activeViewId: number | null;
|
||||||
|
/** The view to name in the chip: the exact match, else the last-applied ("sticky") one it diverged from. */
|
||||||
|
displayView: SavedView | null;
|
||||||
|
/** True when a sticky view is shown but the live filters no longer match it (the chip's "modified" dot). */
|
||||||
|
isDirty: boolean;
|
||||||
|
modalOpen: boolean;
|
||||||
|
openModal: () => void;
|
||||||
|
closeModal: () => void;
|
||||||
|
/** Apply a view's filters and remember it as the active/sticky one. */
|
||||||
|
apply: (v: SavedView) => void;
|
||||||
|
/** Forget the active/sticky view (the chip disappears) without touching the filters. */
|
||||||
|
clearActive: () => void;
|
||||||
|
/** Save the CURRENT filters as a new view; returns success so a caller can keep its editor open on fail. */
|
||||||
|
create: (name: string) => Promise<boolean>;
|
||||||
|
rename: (id: number, name: string) => Promise<boolean>;
|
||||||
|
toggleDefault: (v: SavedView) => Promise<void>;
|
||||||
|
remove: (v: SavedView) => Promise<void>;
|
||||||
|
reorder: (nextIds: number[]) => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const Ctx = createContext<SavedViewsContext | null>(null);
|
||||||
|
|
||||||
|
export function SavedViewsProvider({ children }: { children: ReactNode }) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const qc = useQueryClient();
|
||||||
|
const confirm = useConfirm();
|
||||||
|
const filters = useFeedFilters();
|
||||||
|
const { setFilters } = useFeedFiltersActions();
|
||||||
|
|
||||||
|
const viewsQuery = useQuery({ queryKey: qk.savedViews(), queryFn: api.savedViews });
|
||||||
|
const views = useMemo(() => viewsQuery.data ?? [], [viewsQuery.data]);
|
||||||
|
|
||||||
|
const [stickyId, setStickyId] = useState<number | null>(null);
|
||||||
|
const [modalOpen, setModalOpen] = useState(false);
|
||||||
|
|
||||||
|
// Keep the default-view mirror (read on load) in sync with the server state.
|
||||||
|
useEffect(() => {
|
||||||
|
if (viewsQuery.data) syncDefaultMirror(viewsQuery.data);
|
||||||
|
}, [viewsQuery.data]);
|
||||||
|
|
||||||
|
const currentKey = keyOf(filters);
|
||||||
|
const activeView = useMemo(
|
||||||
|
() => views.find((v) => keyOf(v.filters) === currentKey) ?? null,
|
||||||
|
[views, currentKey],
|
||||||
|
);
|
||||||
|
// An exact match becomes the sticky selection, so filters that happen to equal a view (deep-link,
|
||||||
|
// manual, or an applied view) name that view in the chip and keep naming it after a later tweak.
|
||||||
|
useEffect(() => {
|
||||||
|
if (activeView) setStickyId(activeView.id);
|
||||||
|
}, [activeView]);
|
||||||
|
|
||||||
|
const stickyView = useMemo(
|
||||||
|
() => (stickyId == null ? null : (views.find((v) => v.id === stickyId) ?? null)),
|
||||||
|
[views, stickyId],
|
||||||
|
);
|
||||||
|
const displayView = activeView ?? stickyView;
|
||||||
|
const isDirty = displayView != null && activeView == null;
|
||||||
|
|
||||||
|
const refresh = () => qc.invalidateQueries({ queryKey: qk.savedViews() });
|
||||||
|
async function runViewMutation(fn: () => Promise<unknown>, failMsg: string): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
await fn();
|
||||||
|
return true;
|
||||||
|
} catch (e) {
|
||||||
|
notifyActionError(e, failMsg);
|
||||||
|
return false;
|
||||||
|
} finally {
|
||||||
|
refresh();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const value: SavedViewsContext = {
|
||||||
|
views,
|
||||||
|
loading: viewsQuery.isLoading,
|
||||||
|
activeViewId: activeView?.id ?? null,
|
||||||
|
displayView,
|
||||||
|
isDirty,
|
||||||
|
modalOpen,
|
||||||
|
openModal: () => setModalOpen(true),
|
||||||
|
closeModal: () => setModalOpen(false),
|
||||||
|
apply: (v) => {
|
||||||
|
setFilters(asFilters(v.filters));
|
||||||
|
setStickyId(v.id);
|
||||||
|
},
|
||||||
|
clearActive: () => setStickyId(null),
|
||||||
|
create: async (name) => {
|
||||||
|
const clean = name.trim();
|
||||||
|
if (!clean) return false;
|
||||||
|
// Strip the transient shuffle seed so a saved "shuffle" view re-rolls fresh on apply.
|
||||||
|
const { seed: _seed, ...rest } = filters;
|
||||||
|
return runViewMutation(() => api.createView(clean, rest), t("views.actionFailed"));
|
||||||
|
},
|
||||||
|
rename: async (id, name) => {
|
||||||
|
const next = name.trim();
|
||||||
|
if (!next) return false;
|
||||||
|
return runViewMutation(() => api.updateView(id, { name: next }), t("views.actionFailed"));
|
||||||
|
},
|
||||||
|
toggleDefault: (v) =>
|
||||||
|
runViewMutation(
|
||||||
|
() => api.updateView(v.id, { is_default: !v.is_default }),
|
||||||
|
t("views.actionFailed"),
|
||||||
|
).then(() => undefined),
|
||||||
|
remove: async (v) => {
|
||||||
|
const ok = await confirm({
|
||||||
|
message: t("views.deleteConfirm", { name: v.name }),
|
||||||
|
confirmLabel: t("views.delete"),
|
||||||
|
danger: true,
|
||||||
|
});
|
||||||
|
if (!ok) return;
|
||||||
|
if (stickyId === v.id) setStickyId(null);
|
||||||
|
await runViewMutation(() => api.deleteView(v.id), t("views.actionFailed"));
|
||||||
|
},
|
||||||
|
reorder: async (nextIds) => {
|
||||||
|
// Optimistic reorder so the drop sticks visually before the round-trip; the refetch reverts to
|
||||||
|
// the server's truth on failure (with a toast, so it doesn't silently snap back).
|
||||||
|
qc.setQueryData<SavedView[]>(
|
||||||
|
qk.savedViews(),
|
||||||
|
nextIds.map((id) => views.find((v) => v.id === id)!).filter(Boolean),
|
||||||
|
);
|
||||||
|
await runViewMutation(() => api.reorderViews(nextIds), t("views.reorderFailed"));
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
return <Ctx.Provider value={value}>{children}</Ctx.Provider>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useSavedViews(): SavedViewsContext {
|
||||||
|
const ctx = useContext(Ctx);
|
||||||
|
if (!ctx) throw new Error("useSavedViews must be used within a SavedViewsProvider");
|
||||||
|
return ctx;
|
||||||
|
}
|
||||||
@@ -1,352 +0,0 @@
|
|||||||
import { useEffect, useState } from "react";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
|
||||||
import { qk } from "../lib/queryKeys";
|
|
||||||
import { BookmarkPlus, Check, GripVertical, Pencil, Star, Trash2, X } from "lucide-react";
|
|
||||||
import {
|
|
||||||
closestCenter,
|
|
||||||
DndContext,
|
|
||||||
PointerSensor,
|
|
||||||
useSensor,
|
|
||||||
useSensors,
|
|
||||||
type DragEndEvent,
|
|
||||||
} from "@dnd-kit/core";
|
|
||||||
import {
|
|
||||||
arrayMove,
|
|
||||||
SortableContext,
|
|
||||||
useSortable,
|
|
||||||
verticalListSortingStrategy,
|
|
||||||
} from "@dnd-kit/sortable";
|
|
||||||
import { CSS } from "@dnd-kit/utilities";
|
|
||||||
import {
|
|
||||||
api,
|
|
||||||
getActiveAccount,
|
|
||||||
coerceTagModes,
|
|
||||||
type FeedFilters,
|
|
||||||
type SavedView,
|
|
||||||
} from "../lib/api";
|
|
||||||
import { filtersToParams } from "../lib/urlState";
|
|
||||||
import { notifyActionError } from "../lib/actionErrors";
|
|
||||||
import { accountKey, LS, writeJSON } from "../lib/storage";
|
|
||||||
import { useConfirm } from "./ConfirmProvider";
|
|
||||||
|
|
||||||
// Compact, order-independent signature of a filter set (reuses the share serializer, which
|
|
||||||
// emits only non-default values and ignores the transient shuffle seed). Used to highlight the
|
|
||||||
// saved view that matches the feed's current filters.
|
|
||||||
const keyOf = (f: FeedFilters) => filtersToParams(asFilters(f)).toString();
|
|
||||||
|
|
||||||
// A saved view's `filters` is a JSON blob straight from the DB — the FeedFilters type says nothing
|
|
||||||
// about what an older build actually wrote there, so coerce the array fields before the blob reaches
|
|
||||||
// app state or a serializer. Deliberately NOT a migration: a view saved against a dropped field just
|
|
||||||
// loses that filter.
|
|
||||||
const asFilters = (raw: FeedFilters): FeedFilters => ({
|
|
||||||
...raw,
|
|
||||||
tags: raw.tags ?? [],
|
|
||||||
channelIds: raw.channelIds ?? [],
|
|
||||||
// Migrate the legacy global `tagMode` (older blobs) into the per-category shape.
|
|
||||||
tagModes: coerceTagModes(raw),
|
|
||||||
});
|
|
||||||
|
|
||||||
function syncDefaultMirror(views: SavedView[]): void {
|
|
||||||
const key = accountKey(LS.defaultViewFilters, getActiveAccount());
|
|
||||||
if (!key) return;
|
|
||||||
const def = views.find((v) => v.is_default);
|
|
||||||
writeJSON(key, def ? def.filters : null);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function SavedViewsWidget({
|
|
||||||
filters,
|
|
||||||
onApply,
|
|
||||||
}: {
|
|
||||||
filters: FeedFilters;
|
|
||||||
onApply: (f: FeedFilters) => void;
|
|
||||||
}) {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const qc = useQueryClient();
|
|
||||||
const confirm = useConfirm();
|
|
||||||
const viewsQuery = useQuery({ queryKey: qk.savedViews(), queryFn: api.savedViews });
|
|
||||||
const views = viewsQuery.data ?? [];
|
|
||||||
const [naming, setNaming] = useState(false);
|
|
||||||
const [draftName, setDraftName] = useState("");
|
|
||||||
const [renameId, setRenameId] = useState<number | null>(null);
|
|
||||||
|
|
||||||
const sensors = useSensors(useSensor(PointerSensor, { activationConstraint: { distance: 4 } }));
|
|
||||||
|
|
||||||
// Keep the default-view mirror (read on load) in sync with the server state.
|
|
||||||
useEffect(() => {
|
|
||||||
if (viewsQuery.data) syncDefaultMirror(viewsQuery.data);
|
|
||||||
}, [viewsQuery.data]);
|
|
||||||
|
|
||||||
const refresh = () => qc.invalidateQueries({ queryKey: qk.savedViews() });
|
|
||||||
|
|
||||||
// Run a saved-view mutation with uniform feedback: on failure surface the server's reason instead
|
|
||||||
// of a silent swallow, and always refetch afterwards (which also reverts an optimistic change to
|
|
||||||
// the server's truth). Returns whether it succeeded, so callers can gate success-only side effects
|
|
||||||
// (e.g. closing an editor) and keep the input open — with the user's text — on failure.
|
|
||||||
async function runViewMutation(fn: () => Promise<unknown>, failMsg: string): Promise<boolean> {
|
|
||||||
try {
|
|
||||||
await fn();
|
|
||||||
return true;
|
|
||||||
} catch (e) {
|
|
||||||
notifyActionError(e, failMsg);
|
|
||||||
return false;
|
|
||||||
} finally {
|
|
||||||
refresh();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const currentKey = keyOf(filters);
|
|
||||||
|
|
||||||
async function save() {
|
|
||||||
const name = draftName.trim();
|
|
||||||
if (!name) return;
|
|
||||||
// Strip the transient shuffle seed so a saved "shuffle" view re-rolls fresh on apply.
|
|
||||||
const { seed: _seed, ...clean } = filters;
|
|
||||||
// Close the naming input only on success, so a failed create keeps the user's typed name.
|
|
||||||
if (await runViewMutation(() => api.createView(name, clean), t("views.actionFailed"))) {
|
|
||||||
setNaming(false);
|
|
||||||
setDraftName("");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function rename(id: number, name: string) {
|
|
||||||
const next = name.trim();
|
|
||||||
if (!next) {
|
|
||||||
setRenameId(null);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
// Close the rename editor only on success, so a failed rename stays open for a retry.
|
|
||||||
if (await runViewMutation(() => api.updateView(id, { name: next }), t("views.actionFailed"))) {
|
|
||||||
setRenameId(null);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function toggleDefault(v: SavedView) {
|
|
||||||
await runViewMutation(
|
|
||||||
() => api.updateView(v.id, { is_default: !v.is_default }),
|
|
||||||
t("views.actionFailed"),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function remove(v: SavedView) {
|
|
||||||
const ok = await confirm({
|
|
||||||
message: t("views.deleteConfirm", { name: v.name }),
|
|
||||||
confirmLabel: t("views.delete"),
|
|
||||||
danger: true,
|
|
||||||
});
|
|
||||||
if (!ok) return;
|
|
||||||
await runViewMutation(() => api.deleteView(v.id), t("views.actionFailed"));
|
|
||||||
}
|
|
||||||
|
|
||||||
async function onDragEnd(e: DragEndEvent) {
|
|
||||||
const { active, over } = e;
|
|
||||||
if (!over || active.id === over.id) return;
|
|
||||||
const ids = views.map((v) => v.id);
|
|
||||||
const oldIndex = ids.indexOf(active.id as number);
|
|
||||||
const newIndex = ids.indexOf(over.id as number);
|
|
||||||
if (oldIndex < 0 || newIndex < 0) return;
|
|
||||||
const next = arrayMove(ids, oldIndex, newIndex);
|
|
||||||
// Optimistic reorder so the drop sticks visually before the round-trip.
|
|
||||||
qc.setQueryData<SavedView[]>(
|
|
||||||
qk.savedViews(),
|
|
||||||
next.map((id) => views.find((v) => v.id === id)!),
|
|
||||||
);
|
|
||||||
// On failure runViewMutation's refetch reverts the optimistic order to the server's truth;
|
|
||||||
// without the toast the drop would silently snap back with no explanation.
|
|
||||||
await runViewMutation(() => api.reorderViews(next), t("views.reorderFailed"));
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-2">
|
|
||||||
{views.length === 0 && !naming && (
|
|
||||||
<p className="text-2xs text-muted leading-snug">{t("views.empty")}</p>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={onDragEnd}>
|
|
||||||
<SortableContext items={views.map((v) => v.id)} strategy={verticalListSortingStrategy}>
|
|
||||||
<div className="space-y-1">
|
|
||||||
{views.map((v) => (
|
|
||||||
<ViewRow
|
|
||||||
key={v.id}
|
|
||||||
view={v}
|
|
||||||
active={keyOf(v.filters) === currentKey}
|
|
||||||
renaming={renameId === v.id}
|
|
||||||
onApply={() => onApply(asFilters(v.filters))}
|
|
||||||
onStartRename={() => setRenameId(v.id)}
|
|
||||||
onRename={(name) => rename(v.id, name)}
|
|
||||||
onCancelRename={() => setRenameId(null)}
|
|
||||||
onToggleDefault={() => toggleDefault(v)}
|
|
||||||
onDelete={() => remove(v)}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</SortableContext>
|
|
||||||
</DndContext>
|
|
||||||
|
|
||||||
{naming ? (
|
|
||||||
<div className="flex items-center gap-1">
|
|
||||||
<input
|
|
||||||
autoFocus
|
|
||||||
value={draftName}
|
|
||||||
onChange={(e) => setDraftName(e.target.value)}
|
|
||||||
onKeyDown={(e) => {
|
|
||||||
if (e.key === "Enter") void save();
|
|
||||||
if (e.key === "Escape") setNaming(false);
|
|
||||||
}}
|
|
||||||
placeholder={t("views.namePlaceholder")}
|
|
||||||
maxLength={80}
|
|
||||||
className="flex-1 min-w-0 bg-surface border border-border rounded-lg px-2 py-1 text-xs focus:outline-hidden focus:ring-2 focus:ring-accent/40"
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
onClick={() => void save()}
|
|
||||||
title={t("views.saveAction")}
|
|
||||||
className="p-1 rounded-lg text-muted hover:text-accent hover:bg-card transition"
|
|
||||||
>
|
|
||||||
<Check className="w-4 h-4" />
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => setNaming(false)}
|
|
||||||
title={t("views.cancel")}
|
|
||||||
className="p-1 rounded-lg text-muted hover:text-fg hover:bg-card transition"
|
|
||||||
>
|
|
||||||
<X className="w-4 h-4" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<button
|
|
||||||
onClick={() => {
|
|
||||||
setDraftName("");
|
|
||||||
setNaming(true);
|
|
||||||
}}
|
|
||||||
className="w-full inline-flex items-center justify-center gap-1.5 text-xs px-2 py-1.5 rounded-lg border border-dashed border-border text-muted hover:text-accent hover:border-accent transition"
|
|
||||||
>
|
|
||||||
<BookmarkPlus className="w-3.5 h-3.5" />
|
|
||||||
{t("views.save")}
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function ViewRow({
|
|
||||||
view,
|
|
||||||
active,
|
|
||||||
renaming,
|
|
||||||
onApply,
|
|
||||||
onStartRename,
|
|
||||||
onRename,
|
|
||||||
onCancelRename,
|
|
||||||
onToggleDefault,
|
|
||||||
onDelete,
|
|
||||||
}: {
|
|
||||||
view: SavedView;
|
|
||||||
active: boolean;
|
|
||||||
renaming: boolean;
|
|
||||||
onApply: () => void;
|
|
||||||
onStartRename: () => void;
|
|
||||||
onRename: (name: string) => void;
|
|
||||||
onCancelRename: () => void;
|
|
||||||
onToggleDefault: () => void;
|
|
||||||
onDelete: () => void;
|
|
||||||
}) {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
|
|
||||||
id: view.id,
|
|
||||||
});
|
|
||||||
const [draft, setDraft] = useState(view.name);
|
|
||||||
// Reset the edit draft to the current name each time rename mode opens (so a second rename
|
|
||||||
// doesn't start from a stale value).
|
|
||||||
useEffect(() => {
|
|
||||||
if (renaming) setDraft(view.name);
|
|
||||||
}, [renaming, view.name]);
|
|
||||||
const style = {
|
|
||||||
transform: CSS.Transform.toString(transform),
|
|
||||||
transition,
|
|
||||||
opacity: isDragging ? 0.5 : 1,
|
|
||||||
};
|
|
||||||
|
|
||||||
if (renaming) {
|
|
||||||
return (
|
|
||||||
<div ref={setNodeRef} style={style} className="flex items-center gap-1">
|
|
||||||
<input
|
|
||||||
autoFocus
|
|
||||||
value={draft}
|
|
||||||
onChange={(e) => setDraft(e.target.value)}
|
|
||||||
onKeyDown={(e) => {
|
|
||||||
if (e.key === "Enter") onRename(draft);
|
|
||||||
if (e.key === "Escape") onCancelRename();
|
|
||||||
}}
|
|
||||||
maxLength={80}
|
|
||||||
className="flex-1 min-w-0 bg-surface border border-border rounded-lg px-2 py-1 text-xs focus:outline-hidden focus:ring-2 focus:ring-accent/40"
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
onClick={() => onRename(draft)}
|
|
||||||
title={t("views.saveAction")}
|
|
||||||
className="p-1 rounded-lg text-muted hover:text-accent hover:bg-card transition"
|
|
||||||
>
|
|
||||||
<Check className="w-4 h-4" />
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={onCancelRename}
|
|
||||||
title={t("views.cancel")}
|
|
||||||
className="p-1 rounded-lg text-muted hover:text-fg hover:bg-card transition"
|
|
||||||
>
|
|
||||||
<X className="w-4 h-4" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
ref={setNodeRef}
|
|
||||||
style={style}
|
|
||||||
className={`group flex items-center gap-0.5 rounded-lg pr-1 ${
|
|
||||||
active ? "bg-accent/10" : "hover:bg-card"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<button
|
|
||||||
{...attributes}
|
|
||||||
{...listeners}
|
|
||||||
title={t("views.reorder")}
|
|
||||||
className="p-1 text-muted/60 hover:text-muted cursor-grab touch-none shrink-0"
|
|
||||||
>
|
|
||||||
<GripVertical className="w-3.5 h-3.5" />
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={onApply}
|
|
||||||
title={t("views.apply")}
|
|
||||||
aria-current={active ? "true" : undefined}
|
|
||||||
className={`flex-1 min-w-0 text-left text-xs py-1 truncate ${
|
|
||||||
active ? "text-accent font-medium" : "text-fg"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{view.name}
|
|
||||||
</button>
|
|
||||||
<div className="flex items-center shrink-0">
|
|
||||||
<button
|
|
||||||
onClick={onToggleDefault}
|
|
||||||
title={view.is_default ? t("views.isDefault") : t("views.makeDefault")}
|
|
||||||
className={`p-1 rounded transition ${
|
|
||||||
view.is_default ? "text-accent" : "text-muted reveal hover:text-accent"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<Star className={`w-3.5 h-3.5 ${view.is_default ? "fill-current" : ""}`} />
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={onStartRename}
|
|
||||||
title={t("views.rename")}
|
|
||||||
className="p-1 rounded-sm text-muted reveal hover:text-fg transition"
|
|
||||||
>
|
|
||||||
<Pencil className="w-3.5 h-3.5" />
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={onDelete}
|
|
||||||
title={t("views.delete")}
|
|
||||||
className="p-1 rounded-sm text-muted reveal hover:text-danger transition"
|
|
||||||
>
|
|
||||||
<Trash2 className="w-3.5 h-3.5" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { Bookmark, ChevronDown } from "lucide-react";
|
||||||
|
import SavedViewsPopover from "./SavedViewsPopover";
|
||||||
|
import { useSavedViews } from "./SavedViewsProvider";
|
||||||
|
|
||||||
|
// The active personal view, shown as the leading pill before the active-filter chip row. Styled like
|
||||||
|
// the toolbar's FilterSelect pills; a warn-coloured dot appears when the live filters have diverged
|
||||||
|
// from the saved view. Clicking it opens the saved-views picker to switch views. Renders nothing when
|
||||||
|
// no view is active (nothing applied and the filters match no saved view).
|
||||||
|
export default function SelectedViewChip() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const { displayView, isDirty } = useSavedViews();
|
||||||
|
if (!displayView) return null;
|
||||||
|
return (
|
||||||
|
<SavedViewsPopover
|
||||||
|
ariaLabel={t("views.saved")}
|
||||||
|
className="glass-card glass-hover inline-flex items-center gap-1.5 rounded-full border border-border px-3 py-1 text-sm text-fg transition hover:border-accent/60"
|
||||||
|
trigger={
|
||||||
|
<>
|
||||||
|
<Bookmark className="h-3.5 w-3.5 shrink-0 text-accent" aria-hidden="true" />
|
||||||
|
<span className="max-w-40 truncate">{displayView.name}</span>
|
||||||
|
{isDirty && (
|
||||||
|
<span
|
||||||
|
className="h-1.5 w-1.5 shrink-0 rounded-full bg-warn"
|
||||||
|
title={t("views.modified")}
|
||||||
|
aria-label={t("views.modified")}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<ChevronDown className="h-3.5 w-3.5 shrink-0 text-muted" aria-hidden="true" />
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -20,6 +20,8 @@ export default function SidePanel({
|
|||||||
onReset,
|
onReset,
|
||||||
editHint,
|
editHint,
|
||||||
actions,
|
actions,
|
||||||
|
iconExpanded,
|
||||||
|
onTitleClick,
|
||||||
children,
|
children,
|
||||||
}: {
|
}: {
|
||||||
title: string;
|
title: string;
|
||||||
@@ -34,6 +36,11 @@ export default function SidePanel({
|
|||||||
editHint?: string;
|
editHint?: string;
|
||||||
// Extra header controls shown when NOT editing (e.g. the feed's Clear all + Share view).
|
// Extra header controls shown when NOT editing (e.g. the feed's Clear all + Share view).
|
||||||
actions?: ReactNode;
|
actions?: ReactNode;
|
||||||
|
// Optional override for the icon in the EXPANDED header only (the collapsed tab keeps the plain
|
||||||
|
// `icon`) — the feed passes its saved-views popover trigger here so the header icon opens the picker.
|
||||||
|
iconExpanded?: ReactNode;
|
||||||
|
// When set, the expanded-header title becomes a button (the feed opens its Personal Views modal).
|
||||||
|
onTitleClick?: () => void;
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
@@ -99,10 +106,19 @@ export default function SidePanel({
|
|||||||
>
|
>
|
||||||
{pinned ? <PinOff className="w-4 h-4" /> : <Pin className="w-4 h-4" />}
|
{pinned ? <PinOff className="w-4 h-4" /> : <Pin className="w-4 h-4" />}
|
||||||
</button>
|
</button>
|
||||||
<span className="shrink-0 flex text-accent">{icon}</span>
|
{iconExpanded ?? <span className="shrink-0 flex text-accent">{icon}</span>}
|
||||||
<span className="flex-1 min-w-0 truncate text-sm font-bold uppercase tracking-[0.12em]">
|
{onTitleClick ? (
|
||||||
{title}
|
<button
|
||||||
</span>
|
onClick={onTitleClick}
|
||||||
|
className="flex-1 min-w-0 truncate text-left text-sm font-bold uppercase tracking-[0.12em] transition hover:text-accent"
|
||||||
|
>
|
||||||
|
{title}
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<span className="flex-1 min-w-0 truncate text-sm font-bold uppercase tracking-[0.12em]">
|
||||||
|
{title}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
{count > 0 && (
|
{count > 0 && (
|
||||||
<span
|
<span
|
||||||
title={t("sidebar.activeCount", { count })}
|
title={t("sidebar.activeCount", { count })}
|
||||||
|
|||||||
@@ -9,7 +9,9 @@ import { useDebounced } from "../lib/useDebounced";
|
|||||||
import { clearedFilters, useActiveFilters } from "../lib/useActiveFilters";
|
import { clearedFilters, useActiveFilters } from "../lib/useActiveFilters";
|
||||||
import { applyDateChip, deriveDateKey } from "../lib/dateFilter";
|
import { applyDateChip, deriveDateKey } from "../lib/dateFilter";
|
||||||
import TagManager from "./TagManager";
|
import TagManager from "./TagManager";
|
||||||
import SavedViewsWidget from "./SavedViewsWidget";
|
import SavedViewsPopover from "./SavedViewsPopover";
|
||||||
|
import PersonalViewsModal from "./PersonalViewsModal";
|
||||||
|
import { useSavedViews } from "./SavedViewsProvider";
|
||||||
import FilterRail from "./FilterRail";
|
import FilterRail from "./FilterRail";
|
||||||
import { type PanelItem } from "./PanelGroups";
|
import { type PanelItem } from "./PanelGroups";
|
||||||
import { type MediaSourceKind } from "../lib/mediaSource";
|
import { type MediaSourceKind } from "../lib/mediaSource";
|
||||||
@@ -42,6 +44,7 @@ export default function Sidebar({
|
|||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const isDemo = useMe().is_demo;
|
const isDemo = useMe().is_demo;
|
||||||
|
const { modalOpen, openModal } = useSavedViews();
|
||||||
const filters = useFeedFilters();
|
const filters = useFeedFilters();
|
||||||
const { setFilters } = useFeedFiltersActions();
|
const { setFilters } = useFeedFiltersActions();
|
||||||
// "Focus this channel in the manager" (from the tag manager here) lives in ChannelsProvider.
|
// "Focus this channel in the manager" (from the tag manager here) lives in ChannelsProvider.
|
||||||
@@ -111,7 +114,6 @@ export default function Sidebar({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const available: Record<string, boolean> = {
|
const available: Record<string, boolean> = {
|
||||||
savedviews: !isDemo,
|
|
||||||
date: true,
|
date: true,
|
||||||
channel: true,
|
channel: true,
|
||||||
language: languages.length > 0,
|
language: languages.length > 0,
|
||||||
@@ -121,8 +123,6 @@ export default function Sidebar({
|
|||||||
|
|
||||||
function widgetBody(id: string): ReactNode {
|
function widgetBody(id: string): ReactNode {
|
||||||
switch (id) {
|
switch (id) {
|
||||||
case "savedviews":
|
|
||||||
return <SavedViewsWidget filters={filters} onApply={setFilters} />;
|
|
||||||
case "channel":
|
case "channel":
|
||||||
return <ChannelPicker filters={filters} setFilters={setFilters} />;
|
return <ChannelPicker filters={filters} setFilters={setFilters} />;
|
||||||
case "date": {
|
case "date": {
|
||||||
@@ -278,10 +278,19 @@ export default function Sidebar({
|
|||||||
setLayout={setLayout}
|
setLayout={setLayout}
|
||||||
items={items}
|
items={items}
|
||||||
actions={actions}
|
actions={actions}
|
||||||
|
// Saved ("personal") views: the header icon opens a quick-pick popover, the title opens the
|
||||||
|
// full manager modal. Hidden for the demo account (no per-user views). Feed rail only.
|
||||||
|
iconExpanded={
|
||||||
|
!isDemo && source === "feed" ? (
|
||||||
|
<SavedViewsPopover trigger={<SlidersHorizontal className="w-4 h-4" />} />
|
||||||
|
) : undefined
|
||||||
|
}
|
||||||
|
onTitleClick={!isDemo && source === "feed" ? openModal : undefined}
|
||||||
>
|
>
|
||||||
{tagManagerOpen && (
|
{tagManagerOpen && (
|
||||||
<TagManager onClose={() => setTagManagerOpen(false)} onFocusChannel={onFocusChannel} />
|
<TagManager onClose={() => setTagManagerOpen(false)} onFocusChannel={onFocusChannel} />
|
||||||
)}
|
)}
|
||||||
|
{modalOpen && source === "feed" && <PersonalViewsModal />}
|
||||||
</FilterRail>
|
</FilterRail>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,5 +12,13 @@
|
|||||||
"deleteConfirm": "Delete the saved view “{{name}}”?",
|
"deleteConfirm": "Delete the saved view “{{name}}”?",
|
||||||
"reorder": "Drag to reorder",
|
"reorder": "Drag to reorder",
|
||||||
"actionFailed": "Couldn't update your saved views — please try again.",
|
"actionFailed": "Couldn't update your saved views — please try again.",
|
||||||
"reorderFailed": "Couldn't save the new order — please try again."
|
"reorderFailed": "Couldn't save the new order — please try again.",
|
||||||
|
"saved": "Saved views",
|
||||||
|
"manage": "Manage views…",
|
||||||
|
"manageTitle": "Personal views",
|
||||||
|
"capturedFilters": "Captured filters",
|
||||||
|
"capturedNone": "No filters — matches every video.",
|
||||||
|
"default": "Default",
|
||||||
|
"selectPrompt": "Select a view to see and edit it.",
|
||||||
|
"modified": "Modified — the current filters differ from this saved view."
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,5 +12,13 @@
|
|||||||
"deleteConfirm": "Törlöd a(z) „{{name}}” mentett nézetet?",
|
"deleteConfirm": "Törlöd a(z) „{{name}}” mentett nézetet?",
|
||||||
"reorder": "Húzd az átrendezéshez",
|
"reorder": "Húzd az átrendezéshez",
|
||||||
"actionFailed": "Nem sikerült frissíteni a mentett nézeteket — próbáld újra.",
|
"actionFailed": "Nem sikerült frissíteni a mentett nézeteket — próbáld újra.",
|
||||||
"reorderFailed": "Nem sikerült menteni az új sorrendet — próbáld újra."
|
"reorderFailed": "Nem sikerült menteni az új sorrendet — próbáld újra.",
|
||||||
|
"saved": "Mentett nézetek",
|
||||||
|
"manage": "Nézetek kezelése…",
|
||||||
|
"manageTitle": "Személyes nézetek",
|
||||||
|
"capturedFilters": "Rögzített szűrők",
|
||||||
|
"capturedNone": "Nincs szűrő — minden videóra illeszkedik.",
|
||||||
|
"default": "Alapértelmezett",
|
||||||
|
"selectPrompt": "Válassz egy nézetet a megtekintéshez és szerkesztéshez.",
|
||||||
|
"modified": "Módosítva — a jelenlegi szűrők eltérnek a mentett nézettől."
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ export interface PanelLayout {
|
|||||||
// Unknown/stale ids are dropped and any missing (e.g. a group added in a later version) are
|
// Unknown/stale ids are dropped and any missing (e.g. a group added in a later version) are
|
||||||
// appended by normalizeLayout, so nothing silently disappears.
|
// appended by normalizeLayout, so nothing silently disappears.
|
||||||
const PANEL_ORDER: Record<PanelId, string[]> = {
|
const PANEL_ORDER: Record<PanelId, string[]> = {
|
||||||
feed: ["savedviews", "date", "channel", "tags", "language", "topic"],
|
feed: ["date", "channel", "tags", "language", "topic"],
|
||||||
plex: [
|
plex: [
|
||||||
"playlists",
|
"playlists",
|
||||||
"watch",
|
"watch",
|
||||||
|
|||||||
+16
-13
@@ -6,6 +6,7 @@ import ErrorBoundary from "./components/ErrorBoundary";
|
|||||||
import { ConfirmProvider } from "./components/ConfirmProvider";
|
import { ConfirmProvider } from "./components/ConfirmProvider";
|
||||||
import { NavigationProvider } from "./components/NavigationProvider";
|
import { NavigationProvider } from "./components/NavigationProvider";
|
||||||
import { FeedFiltersProvider } from "./components/FeedFiltersProvider";
|
import { FeedFiltersProvider } from "./components/FeedFiltersProvider";
|
||||||
|
import { SavedViewsProvider } from "./components/SavedViewsProvider";
|
||||||
import { PlaylistsProvider } from "./components/PlaylistsProvider";
|
import { PlaylistsProvider } from "./components/PlaylistsProvider";
|
||||||
import { PlexProvider } from "./components/PlexProvider";
|
import { PlexProvider } from "./components/PlexProvider";
|
||||||
import { ChannelsProvider } from "./components/ChannelsProvider";
|
import { ChannelsProvider } from "./components/ChannelsProvider";
|
||||||
@@ -57,19 +58,21 @@ const root =
|
|||||||
<ConfirmProvider>
|
<ConfirmProvider>
|
||||||
<NavigationProvider>
|
<NavigationProvider>
|
||||||
<FeedFiltersProvider>
|
<FeedFiltersProvider>
|
||||||
<PlaylistsProvider>
|
<SavedViewsProvider>
|
||||||
<PlexProvider>
|
<PlaylistsProvider>
|
||||||
<ChannelsProvider>
|
<PlexProvider>
|
||||||
<FeedViewProvider>
|
<ChannelsProvider>
|
||||||
<PrefsProvider>
|
<FeedViewProvider>
|
||||||
<WizardProvider>
|
<PrefsProvider>
|
||||||
<App />
|
<WizardProvider>
|
||||||
</WizardProvider>
|
<App />
|
||||||
</PrefsProvider>
|
</WizardProvider>
|
||||||
</FeedViewProvider>
|
</PrefsProvider>
|
||||||
</ChannelsProvider>
|
</FeedViewProvider>
|
||||||
</PlexProvider>
|
</ChannelsProvider>
|
||||||
</PlaylistsProvider>
|
</PlexProvider>
|
||||||
|
</PlaylistsProvider>
|
||||||
|
</SavedViewsProvider>
|
||||||
</FeedFiltersProvider>
|
</FeedFiltersProvider>
|
||||||
</NavigationProvider>
|
</NavigationProvider>
|
||||||
</ConfirmProvider>
|
</ConfirmProvider>
|
||||||
|
|||||||
Reference in New Issue
Block a user