feat(views): editable saved-view detail — removable filter chips + undo + save-current (S7)
The Personal-views manager's detail pane becomes editable instead of read-only: - Captured filters render as REMOVABLE chips. Removing one prunes the set and writes it straight back to the stored view (PATCH filters), via the same live-row builders (useActiveFilters / buildPlexActiveFilters) that already compute the pruned snapshot — so the modal and the live chip row can't drift. The prune is wrapped in useUndoable + the shared UndoToolbar, so undo/redo re-persist the same way. The undoable draft is the single source of truth for the chip row, so a chip-prune refetch never clobbers the history. - A "Save current" button beside Apply stores the module's currently-applied filters INTO the view (the live→stored inverse of Apply). Disabled when the view already equals the live filters (it's the active one). On save the draft re-seeds to the saved snapshot and clears the stale undo history. - Rename gains a visible commit affordance: a check button beside the name input that enables once the name changes (Enter and blur still commit). Provider gains two methods mirroring `rename`: updateFilters(id, filters) and saveCurrent(id). One shared provider serves feed + plex, so both get it. PATCH filters was already supported end-to-end — FE-only. New i18n: views.saveCurrent / saveCurrentHint / saveCurrentUpToDate (HU+EN). Tests: 5 new in PersonalViewsModal.dom.test.tsx (chip-remove persists, undo re-persists, save-current fires + disables when up-to-date, rename commit).
This commit is contained in:
@@ -0,0 +1,150 @@
|
||||
// @vitest-environment jsdom
|
||||
//
|
||||
// S7 — the Personal-views manager gains an EDITABLE detail pane: captured filters are now removable
|
||||
// chips that write the pruned set back to the stored view (undoable), an explicit Save-current button
|
||||
// stores the live filters into the view, and rename has a visible commit affordance. This drives the
|
||||
// real modal against a mocked SavedViewsProvider so we can assert exactly which mutation fires; the
|
||||
// captured-filter row uses the REAL useActiveFilters (fed a tag from a seeded react-query cache), so
|
||||
// the prune snapshots are the genuine ones the live chip row computes.
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { render, screen, waitFor, cleanup, fireEvent } from "@testing-library/react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
|
||||
const h = vi.hoisted(() => ({
|
||||
updateFilters: vi.fn(async (_id: number, _filters: Record<string, unknown>) => true),
|
||||
saveCurrent: vi.fn(async (_id: number) => ({ saved: true }) as Record<string, unknown> | null),
|
||||
rename: vi.fn(async (_id: number, _name: string) => true),
|
||||
activeViewId: null as number | null,
|
||||
}));
|
||||
|
||||
vi.mock("react-i18next", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("react-i18next")>();
|
||||
return { ...actual, useTranslation: () => ({ t: (key: string) => key }) };
|
||||
});
|
||||
|
||||
// A full feed filter set that differs from the sidebar default ONLY in `tags`, so useActiveFilters
|
||||
// surfaces exactly one chip (the tag) — a partial object would read missing show/sort/content as
|
||||
// "active" and spray spurious chips.
|
||||
const VIEW_FILTERS = {
|
||||
tags: [1],
|
||||
tagModes: { language: "or", topic: "or", other: "or" },
|
||||
untagged: false,
|
||||
channelIds: [],
|
||||
sort: "newest",
|
||||
includeNormal: true,
|
||||
includeShorts: false,
|
||||
includeLive: false,
|
||||
show: "unwatched",
|
||||
scope: "my",
|
||||
q: "",
|
||||
};
|
||||
|
||||
const CTX = {
|
||||
source: "feed" as const,
|
||||
views: [{ id: 5, name: "Docs", filters: VIEW_FILTERS, position: 0, is_default: false }],
|
||||
loading: false,
|
||||
displayView: null,
|
||||
isDirty: false,
|
||||
modalOpen: true,
|
||||
openModal: vi.fn(),
|
||||
closeModal: vi.fn(),
|
||||
apply: vi.fn(),
|
||||
clearActive: vi.fn(),
|
||||
create: vi.fn(async () => true),
|
||||
reorder: vi.fn(async () => {}),
|
||||
remove: vi.fn(async () => true),
|
||||
toggleDefault: vi.fn(async () => {}),
|
||||
};
|
||||
|
||||
vi.mock("./SavedViewsProvider", () => ({
|
||||
useSavedViews: () => ({
|
||||
...CTX,
|
||||
activeViewId: h.activeViewId,
|
||||
updateFilters: h.updateFilters,
|
||||
saveCurrent: h.saveCurrent,
|
||||
rename: h.rename,
|
||||
}),
|
||||
// Passthrough coercion — the modal only needs tags/channelIds to be arrays before the chip builder.
|
||||
asFilters: (raw: Record<string, unknown>) => ({
|
||||
...raw,
|
||||
tags: (raw.tags as unknown[]) ?? [],
|
||||
channelIds: (raw.channelIds as unknown[]) ?? [],
|
||||
}),
|
||||
}));
|
||||
|
||||
import PersonalViewsModal from "./PersonalViewsModal";
|
||||
import { api } from "../lib/api";
|
||||
import { qk } from "../lib/queryKeys";
|
||||
|
||||
function mount() {
|
||||
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
qc.setQueryData(qk.tags(), [{ id: 1, name: "Docu" }]);
|
||||
vi.spyOn(api, "tags").mockResolvedValue([{ id: 1, name: "Docu" }] as never);
|
||||
return render(
|
||||
<QueryClientProvider client={qc}>
|
||||
<PersonalViewsModal />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
h.updateFilters.mockClear();
|
||||
h.saveCurrent.mockClear();
|
||||
h.rename.mockClear();
|
||||
h.activeViewId = null;
|
||||
});
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe("PersonalViewsModal — S7 editable detail pane", () => {
|
||||
it("removing a captured-filter chip writes the pruned filters back to the stored view", async () => {
|
||||
mount();
|
||||
const removeBtn = await screen.findByLabelText("feed.chips.remove");
|
||||
fireEvent.click(removeBtn);
|
||||
await waitFor(() => expect(h.updateFilters).toHaveBeenCalledTimes(1));
|
||||
expect(h.updateFilters.mock.calls[0]![0]).toBe(5);
|
||||
expect(h.updateFilters.mock.calls[0]![1]).toMatchObject({ tags: [] });
|
||||
});
|
||||
|
||||
it("the prune is undoable — Undo re-persists the original filters", async () => {
|
||||
mount();
|
||||
fireEvent.click(await screen.findByLabelText("feed.chips.remove"));
|
||||
await waitFor(() => expect(h.updateFilters).toHaveBeenCalledTimes(1));
|
||||
// The undo control only appears once there is history.
|
||||
const undo = await screen.findByLabelText("common.undo");
|
||||
fireEvent.click(undo);
|
||||
await waitFor(() => expect(h.updateFilters).toHaveBeenCalledTimes(2));
|
||||
expect(h.updateFilters.mock.calls[1]![1]).toMatchObject({ tags: [1] });
|
||||
});
|
||||
|
||||
it("Save-current stores the live filters into the view when it isn't already the active one", async () => {
|
||||
mount();
|
||||
fireEvent.click(await screen.findByText("views.saveCurrent"));
|
||||
await waitFor(() => expect(h.saveCurrent).toHaveBeenCalledWith(5));
|
||||
});
|
||||
|
||||
it("Save-current is disabled when the selected view is already the active one", async () => {
|
||||
h.activeViewId = 5;
|
||||
mount();
|
||||
const btn = (await screen.findByText("views.saveCurrent")).closest(
|
||||
"button",
|
||||
) as HTMLButtonElement;
|
||||
expect(btn.disabled).toBe(true);
|
||||
fireEvent.click(btn);
|
||||
expect(h.saveCurrent).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rename commits via the visible check button once the name changes", async () => {
|
||||
mount();
|
||||
const input = await screen.findByDisplayValue("Docs");
|
||||
// No change yet → the commit button is disabled.
|
||||
const commit = screen.getByLabelText("views.rename").closest("button") as HTMLButtonElement;
|
||||
expect(commit.disabled).toBe(true);
|
||||
fireEvent.change(input, { target: { value: "Documentaries" } });
|
||||
expect(commit.disabled).toBe(false);
|
||||
fireEvent.click(commit);
|
||||
await waitFor(() => expect(h.rename).toHaveBeenCalledWith(5, "Documentaries"));
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useState } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { BookmarkPlus, GripVertical, Play, Star, Trash2 } from "lucide-react";
|
||||
import { BookmarkPlus, Check, GripVertical, Play, Save, Star, Trash2, X } from "lucide-react";
|
||||
import {
|
||||
closestCenter,
|
||||
DndContext,
|
||||
@@ -16,15 +16,18 @@ import {
|
||||
verticalListSortingStrategy,
|
||||
} from "@dnd-kit/sortable";
|
||||
import { CSS } from "@dnd-kit/utilities";
|
||||
import { coercePlexFilters, type FeedFilters, type SavedView } from "../lib/api";
|
||||
import { coercePlexFilters, type FeedFilters, type PlexFilters, type SavedView } from "../lib/api";
|
||||
import Modal from "./Modal";
|
||||
import { useSavedViews, asFilters } from "./SavedViewsProvider";
|
||||
import { useActiveFilters } from "../lib/useActiveFilters";
|
||||
import { buildPlexActiveFilters } from "./usePlexActiveFilters";
|
||||
import { useUndoable } from "../lib/useUndoable";
|
||||
import UndoToolbar from "./UndoToolbar";
|
||||
|
||||
// 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
|
||||
// reorder) plus a "save the current filters" input; the right column shows the selected view's name
|
||||
// (renamable), its captured filters as REMOVABLE chips (pruning writes back to the stored view,
|
||||
// undoable), its default flag, apply, save-current, and delete. Opened from the filter panel's title
|
||||
// or the saved-views popover; all mutations flow through SavedViewsProvider.
|
||||
function ViewRow({
|
||||
view,
|
||||
@@ -80,6 +83,8 @@ export default function PersonalViewsModal() {
|
||||
apply,
|
||||
create,
|
||||
rename,
|
||||
updateFilters,
|
||||
saveCurrent,
|
||||
toggleDefault,
|
||||
remove,
|
||||
reorder,
|
||||
@@ -89,21 +94,55 @@ export default function PersonalViewsModal() {
|
||||
|
||||
const [selectedId, setSelectedId] = useState<number | null>(activeViewId ?? views[0]?.id ?? null);
|
||||
const selected = views.find((v) => v.id === selectedId) ?? null;
|
||||
const [draftName, setDraftName] = useState("");
|
||||
// Two independent name fields: the left one names a NEW view; the right one renames the selected one.
|
||||
const [createName, setCreateName] = useState("");
|
||||
const [nameDraft, setNameDraft] = useState("");
|
||||
|
||||
// The selected view's captured filters, as the same human labels its module's chip row shows. The
|
||||
// feed hook runs unconditionally (fed empty filters off-feed); Plex uses the pure builder (no-op
|
||||
// removes — this is display-only). A plex view captures its rail filters, not the watch-state.
|
||||
// A local, undoable draft of the SELECTED view's captured filters. Removing a chip prunes the draft
|
||||
// and — via onApply — writes the pruned set straight back to the stored view; Undo/Redo re-persist
|
||||
// the same way. The draft is the single source of truth for the chip row, so a chip-prune refetch
|
||||
// (which returns the value we just wrote) never clobbers the undo history. We only re-seed it on an
|
||||
// external change of the visible filters: switching the selection, or Save-current below.
|
||||
const filtersDraft = useUndoable<Record<string, unknown>>(selected?.filters ?? {}, {
|
||||
onApply: (f) => {
|
||||
if (selected) void updateFilters(selected.id, f);
|
||||
},
|
||||
});
|
||||
const resetDraft = filtersDraft.reset;
|
||||
useEffect(() => {
|
||||
resetDraft(selected?.filters ?? {});
|
||||
setNameDraft(selected?.name ?? "");
|
||||
// Re-seed only when the selection changes (not on every same-view refetch), so pruning keeps its
|
||||
// undo history. Save-current re-seeds explicitly in its own handler.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [selectedId]);
|
||||
|
||||
// The selected view's captured filters, as the same human labels its module's chip row shows, read
|
||||
// from the undoable draft. The feed hook runs unconditionally (fed empty filters off-feed); Plex
|
||||
// uses the pure builder. A plex view captures its rail filters, not the watch-state (show="all").
|
||||
const noop = () => {};
|
||||
const setDraftFeed = (f: FeedFilters) =>
|
||||
filtersDraft.set(f as unknown as Record<string, unknown>);
|
||||
const feedCaptured = useActiveFilters(
|
||||
source === "feed" && selected
|
||||
? asFilters(selected.filters as unknown as FeedFilters)
|
||||
? asFilters(filtersDraft.value as unknown as FeedFilters)
|
||||
: ({ tags: [], channelIds: [] } as never),
|
||||
noop,
|
||||
source === "feed" && selected ? setDraftFeed : noop,
|
||||
);
|
||||
const patchDraftPlex = (p: Partial<PlexFilters>) =>
|
||||
filtersDraft.set({
|
||||
...coercePlexFilters(filtersDraft.value),
|
||||
...p,
|
||||
} as unknown as Record<string, unknown>);
|
||||
const captured =
|
||||
source === "plex" && selected
|
||||
? buildPlexActiveFilters(coercePlexFilters(selected.filters), "all", noop, noop, t)
|
||||
? buildPlexActiveFilters(
|
||||
coercePlexFilters(filtersDraft.value),
|
||||
"all",
|
||||
patchDraftPlex,
|
||||
noop,
|
||||
t,
|
||||
)
|
||||
: feedCaptured;
|
||||
|
||||
async function onDragEnd(e: DragEndEvent) {
|
||||
@@ -115,11 +154,20 @@ export default function PersonalViewsModal() {
|
||||
}
|
||||
|
||||
async function onCreate() {
|
||||
const name = draftName.trim();
|
||||
const name = createName.trim();
|
||||
if (!name) return;
|
||||
if (await create(name)) setDraftName("");
|
||||
if (await create(name)) setCreateName("");
|
||||
}
|
||||
|
||||
const nameChanged = !!selected && nameDraft.trim() !== "" && nameDraft.trim() !== selected.name;
|
||||
async function onRename() {
|
||||
if (!selected || !nameChanged) return;
|
||||
await rename(selected.id, nameDraft.trim());
|
||||
}
|
||||
|
||||
// Save-current is a no-op when the live filters already equal this exact view (it's the active one).
|
||||
const saveCurrentDisabled = selected != null && selected.id === activeViewId;
|
||||
|
||||
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">
|
||||
@@ -148,15 +196,15 @@ export default function PersonalViewsModal() {
|
||||
</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)}
|
||||
value={createName}
|
||||
onChange={(e) => setCreateName(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()}
|
||||
disabled={!createName.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"
|
||||
>
|
||||
@@ -168,18 +216,37 @@ export default function PersonalViewsModal() {
|
||||
{/* 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 className="flex items-center gap-1.5">
|
||||
<input
|
||||
value={nameDraft}
|
||||
onChange={(e) => setNameDraft(e.target.value)}
|
||||
onKeyDown={(e) => e.key === "Enter" && void onRename()}
|
||||
onBlur={() => void onRename()}
|
||||
className="min-w-0 flex-1 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"
|
||||
/>
|
||||
<button
|
||||
onClick={() => void onRename()}
|
||||
disabled={!nameChanged}
|
||||
title={t("views.rename")}
|
||||
aria-label={t("views.rename")}
|
||||
className="inline-flex shrink-0 items-center rounded-lg border border-border p-2 text-muted transition enabled:hover:border-accent enabled:hover:text-accent disabled:opacity-30"
|
||||
>
|
||||
<Check className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
<div>
|
||||
<div className="mb-1.5 text-2xs font-semibold uppercase tracking-wider text-muted">
|
||||
{t("views.capturedFilters")}
|
||||
<div className="mb-1.5 flex items-center justify-between gap-2">
|
||||
<span className="text-2xs font-semibold uppercase tracking-wider text-muted">
|
||||
{t("views.capturedFilters")}
|
||||
</span>
|
||||
{(filtersDraft.canUndo || filtersDraft.canRedo) && (
|
||||
<UndoToolbar
|
||||
canUndo={filtersDraft.canUndo}
|
||||
canRedo={filtersDraft.canRedo}
|
||||
onUndo={filtersDraft.undo}
|
||||
onRedo={filtersDraft.redo}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{captured.length === 0 ? (
|
||||
<p className="text-xs text-muted">{t("views.capturedNone")}</p>
|
||||
@@ -188,15 +255,23 @@ export default function PersonalViewsModal() {
|
||||
{captured.map((f) => (
|
||||
<span
|
||||
key={f.key}
|
||||
className="rounded-full bg-accent/10 px-2 py-0.5 text-2xs text-accent"
|
||||
className="group inline-flex items-center gap-1 rounded-full bg-accent/10 py-0.5 pl-2 pr-1 text-2xs text-accent"
|
||||
>
|
||||
{f.label}
|
||||
<button
|
||||
onClick={f.remove}
|
||||
title={t("feed.chips.remove", { name: f.label })}
|
||||
aria-label={t("feed.chips.remove", { name: f.label })}
|
||||
className="rounded-full p-0.5 opacity-60 transition hover:bg-accent/20 hover:opacity-100"
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-auto flex items-center gap-2 border-t border-border/60 pt-3">
|
||||
<div className="mt-auto flex flex-wrap 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"
|
||||
@@ -204,6 +279,22 @@ export default function PersonalViewsModal() {
|
||||
<Play className="h-3.5 w-3.5" />
|
||||
{t("views.apply")}
|
||||
</button>
|
||||
<button
|
||||
onClick={async () => {
|
||||
const saved = await saveCurrent(selected.id);
|
||||
// Re-seed the draft to what we just stored (the live filters) so the chip row
|
||||
// reflects the save; clears the now-stale undo history.
|
||||
if (saved) resetDraft(saved);
|
||||
}}
|
||||
disabled={saveCurrentDisabled}
|
||||
title={
|
||||
saveCurrentDisabled ? t("views.saveCurrentUpToDate") : t("views.saveCurrentHint")
|
||||
}
|
||||
className="inline-flex items-center gap-1.5 rounded-lg border border-border px-3 py-1.5 text-sm text-muted transition enabled:hover:border-accent enabled:hover:text-accent disabled:opacity-40"
|
||||
>
|
||||
<Save className="h-3.5 w-3.5" />
|
||||
{t("views.saveCurrent")}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => void toggleDefault(selected)}
|
||||
title={t("views.default")}
|
||||
|
||||
@@ -103,6 +103,11 @@ export interface SavedViewsContext {
|
||||
clearActive: () => void;
|
||||
create: (name: string) => Promise<boolean>;
|
||||
rename: (id: number, name: string) => Promise<boolean>;
|
||||
/** Overwrite a view's captured filters (used by the modal's removable chips). */
|
||||
updateFilters: (id: number, filters: Record<string, unknown>) => Promise<boolean>;
|
||||
/** Save the module's CURRENTLY-applied filters into a view (the live→stored inverse of `apply`);
|
||||
* resolves to the saved snapshot on success (so the caller can resync its editor) or null. */
|
||||
saveCurrent: (id: number) => Promise<Record<string, unknown> | null>;
|
||||
toggleDefault: (v: SavedView) => Promise<void>;
|
||||
remove: (v: SavedView) => Promise<boolean>;
|
||||
reorder: (nextIds: number[]) => Promise<void>;
|
||||
@@ -258,6 +263,16 @@ export function SavedViewsProvider({ children }: { children: ReactNode }) {
|
||||
if (!next) return false;
|
||||
return runViewMutation(() => api.updateView(id, { name: next }), t("views.actionFailed"));
|
||||
},
|
||||
updateFilters: (id, filters) =>
|
||||
runViewMutation(() => api.updateView(id, { filters }), t("views.actionFailed")),
|
||||
saveCurrent: async (id) => {
|
||||
const filters = cfg.stripForSave(cfg.filters);
|
||||
const ok = await runViewMutation(
|
||||
() => api.updateView(id, { filters }),
|
||||
t("views.actionFailed"),
|
||||
);
|
||||
return ok ? filters : null;
|
||||
},
|
||||
toggleDefault: (v) =>
|
||||
runViewMutation(
|
||||
() => api.updateView(v.id, { is_default: !v.is_default }),
|
||||
|
||||
@@ -19,6 +19,9 @@
|
||||
"capturedFilters": "Captured filters",
|
||||
"capturedNone": "No filters — matches every video.",
|
||||
"default": "Default",
|
||||
"saveCurrent": "Save current",
|
||||
"saveCurrentHint": "Overwrite this view with the filters currently applied",
|
||||
"saveCurrentUpToDate": "This view already matches the current filters",
|
||||
"selectPrompt": "Select a view to see and edit it.",
|
||||
"modified": "Modified — the current filters differ from this saved view."
|
||||
}
|
||||
|
||||
@@ -19,6 +19,9 @@
|
||||
"capturedFilters": "Rögzített szűrők",
|
||||
"capturedNone": "Nincs szűrő — minden videóra illeszkedik.",
|
||||
"default": "Alapértelmezett",
|
||||
"saveCurrent": "Aktuális mentése",
|
||||
"saveCurrentHint": "Felülírja ezt a nézetet a jelenleg alkalmazott szűrőkkel",
|
||||
"saveCurrentUpToDate": "Ez a nézet már megegyezik a jelenlegi szűrőkkel",
|
||||
"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."
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user