feat(feed): move sort + view into the chip row with responsive overflow
Sort and the view switcher are tightly bound to the grid, so pin them to the right of the applied-filter row (directly above the cards) instead of the filter toolbar; they render there on both the feed and channel-scoped views. ActiveFilterChips now shares that row with the controls and trims itself so it never crowds them, measuring fit off a hidden mirror of the full chip set: - wide -> show as many chips as fit, then a "+N" pill whose popover holds the rest; - narrow -> collapse every chip into one "N filters" pill whose popover holds them all. Both popovers list the (overflowed / all) chips, each removable, plus Clear all.
This commit is contained in:
@@ -1,51 +1,262 @@
|
|||||||
|
import { useLayoutEffect, useRef, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { X } from "lucide-react";
|
import { Filter, X } from "lucide-react";
|
||||||
import type { ActiveFilter } from "../lib/useActiveFilters";
|
import type { ActiveFilter } from "../lib/useActiveFilters";
|
||||||
|
import { PopoverItem, PopoverSurface, useAnchoredPopover } from "./AnchoredPopover";
|
||||||
|
|
||||||
// The applied-filter row above the feed: every narrowing currently in force, each removable on its
|
// The applied-filter row above the feed: every narrowing currently in force, each removable on its
|
||||||
// own. It exists because the filter panel now sits collapsed as a tab — without this you'd have to
|
// own. It exists because the filter panel now sits collapsed as a tab — without this you'd have to
|
||||||
// open the panel just to see what you'd filtered by.
|
// open the panel just to see what you'd filtered by.
|
||||||
|
//
|
||||||
|
// The row is a single flex line that shares its width with the Sort/View controls pinned to its right,
|
||||||
|
// so the chips must never push those off-screen. When they'd overflow it trims responsively:
|
||||||
|
// - wide -> show as many chips as fit, then a "+N" pill whose popover holds the rest;
|
||||||
|
// - narrow -> collapse every chip into one "N filters" pill whose popover holds them all.
|
||||||
|
// Fit is measured off a hidden mirror of the full chip set (so slicing the visible lane can't feed
|
||||||
|
// back into the measurement and oscillate).
|
||||||
|
|
||||||
|
// Below this lane width an overflow collapses all the way to a single count pill (mobile), instead of
|
||||||
|
// the "fit some + N more" treatment used on wider lanes.
|
||||||
|
const NARROW_PX = 420;
|
||||||
|
const GAP_PX = 6; // matches gap-1.5 (px)
|
||||||
|
|
||||||
|
const chipCls =
|
||||||
|
"chip group inline-flex items-center gap-1 pl-2.5 pr-1.5 py-1 text-sm whitespace-nowrap hover:border-accent hover:bg-accent/20 transition";
|
||||||
|
|
||||||
|
function ChipButton({ f, testId }: { f: ActiveFilter; testId?: string }) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
data-testid={testId}
|
||||||
|
data-filter-key={f.key}
|
||||||
|
onClick={f.remove}
|
||||||
|
title={t("feed.chips.remove", { name: f.label })}
|
||||||
|
aria-label={t("feed.chips.remove", { name: f.label })}
|
||||||
|
className={chipCls}
|
||||||
|
>
|
||||||
|
<span className="max-w-56 truncate">{f.label}</span>
|
||||||
|
<X className="w-3 h-3 shrink-0 opacity-60 group-hover:opacity-100" />
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// The "+N" / "N filters" pill and its popover listing the (overflowed / all) chips, each removable.
|
||||||
|
function OverflowPopover({
|
||||||
|
label,
|
||||||
|
chips,
|
||||||
|
onClearAll,
|
||||||
|
icon,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
chips: ActiveFilter[];
|
||||||
|
onClearAll: () => void;
|
||||||
|
icon?: boolean;
|
||||||
|
}) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const pop = useAnchoredPopover({
|
||||||
|
open,
|
||||||
|
onOpenChange: setOpen,
|
||||||
|
mode: "menu",
|
||||||
|
placement: "bottom-end",
|
||||||
|
});
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
ref={pop.refs.setReference}
|
||||||
|
{...pop.getReferenceProps()}
|
||||||
|
className={`chip inline-flex shrink-0 items-center gap-1 px-2.5 py-1 text-sm whitespace-nowrap transition ${
|
||||||
|
open ? "border-accent text-accent" : "hover:border-accent"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{icon && <Filter className="w-3.5 h-3.5 shrink-0" aria-hidden="true" />}
|
||||||
|
{label}
|
||||||
|
</button>
|
||||||
|
<PopoverSurface
|
||||||
|
popover={pop}
|
||||||
|
ariaLabel={label}
|
||||||
|
className="glass-menu min-w-48 max-w-72 rounded-xl p-1.5 shadow-lg animate-[popIn_0.16s_ease]"
|
||||||
|
>
|
||||||
|
<div role="group" aria-label={label} className="max-h-72 overflow-y-auto no-scrollbar">
|
||||||
|
{chips.map((f) => (
|
||||||
|
<PopoverItem
|
||||||
|
key={f.key}
|
||||||
|
role="menuitem"
|
||||||
|
onSelect={f.remove}
|
||||||
|
label={f.label}
|
||||||
|
className="group flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left text-sm text-fg transition"
|
||||||
|
>
|
||||||
|
<span className="flex-1 truncate">{f.label}</span>
|
||||||
|
<X className="w-3.5 h-3.5 shrink-0 opacity-60 group-hover:opacity-100" />
|
||||||
|
</PopoverItem>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="mt-1 border-t border-border px-1 pt-1.5">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
data-testid="active-filter-clear-all"
|
||||||
|
onClick={() => {
|
||||||
|
onClearAll();
|
||||||
|
setOpen(false);
|
||||||
|
}}
|
||||||
|
className="text-xs text-muted hover:text-fg underline underline-offset-2 transition"
|
||||||
|
>
|
||||||
|
{t("sidebar.clearAll")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</PopoverSurface>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export default function ActiveFilterChips({
|
export default function ActiveFilterChips({
|
||||||
filters,
|
filters,
|
||||||
onClearAll,
|
onClearAll,
|
||||||
|
className,
|
||||||
}: {
|
}: {
|
||||||
filters: ActiveFilter[];
|
filters: ActiveFilter[];
|
||||||
onClearAll: () => void;
|
onClearAll: () => void;
|
||||||
|
className?: string;
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
if (filters.length === 0) return null;
|
const laneRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
const labelRef = useRef<HTMLSpanElement | null>(null);
|
||||||
|
const chipRefs = useRef<Array<HTMLSpanElement | null>>([]);
|
||||||
|
const plusRef = useRef<HTMLSpanElement | null>(null);
|
||||||
|
const clearRef = useRef<HTMLSpanElement | null>(null);
|
||||||
|
// How the row is currently laid out: all chips, a fit-count + "+N", or a single count pill.
|
||||||
|
const [layout, setLayout] = useState<{ mode: "all" | "some" | "count"; visible: number }>({
|
||||||
|
mode: "all",
|
||||||
|
visible: filters.length,
|
||||||
|
});
|
||||||
|
|
||||||
|
const n = filters.length;
|
||||||
|
// Re-measure whenever the chip set changes (labels or count) — captures both in one dep.
|
||||||
|
const chipsKey = filters.map((f) => f.key + f.label).join("|");
|
||||||
|
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
const lane = laneRef.current;
|
||||||
|
const labelEl = labelRef.current;
|
||||||
|
if (!lane || !labelEl || n === 0) return;
|
||||||
|
|
||||||
|
const measure = () => {
|
||||||
|
const avail = lane.clientWidth;
|
||||||
|
const rightOf = (el: HTMLElement | null | undefined) =>
|
||||||
|
el ? el.offsetLeft + el.offsetWidth : 0;
|
||||||
|
const labelRight = rightOf(labelEl);
|
||||||
|
const chips = chipRefs.current.slice(0, n);
|
||||||
|
const plusW = plusRef.current?.offsetWidth ?? 0;
|
||||||
|
// "all" mode also renders a Clear all button after the chips (when n > 1), so reserve its width
|
||||||
|
// in the fit-all check — otherwise it spills past the lane into the Sort/View controls.
|
||||||
|
const clearW = n > 1 ? GAP_PX + (clearRef.current?.offsetWidth ?? 0) : 0;
|
||||||
|
|
||||||
|
// Do all chips (plus the Clear all button) fit? (last chip's right edge within the lane.)
|
||||||
|
const allRight = rightOf(chips[n - 1]);
|
||||||
|
if (allRight + clearW <= avail) {
|
||||||
|
setLayout({ mode: "all", visible: n });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Overflow. On a narrow lane, collapse everything into one count pill.
|
||||||
|
if (avail < NARROW_PX) {
|
||||||
|
setLayout({ mode: "count", visible: 0 });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Otherwise fit as many chips as leave room for the "+N" pill after them.
|
||||||
|
let k = 0;
|
||||||
|
for (let i = 0; i < n; i++) {
|
||||||
|
const right = rightOf(chips[i]);
|
||||||
|
if (right + GAP_PX + plusW <= avail) k = i + 1;
|
||||||
|
else break;
|
||||||
|
}
|
||||||
|
// If not even one chip fits beside the pill, fall back to the count pill.
|
||||||
|
if (k === 0 && labelRight + GAP_PX + plusW > avail) {
|
||||||
|
setLayout({ mode: "count", visible: 0 });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setLayout({ mode: "some", visible: k });
|
||||||
|
};
|
||||||
|
|
||||||
|
measure();
|
||||||
|
const ro = new ResizeObserver(measure);
|
||||||
|
ro.observe(lane);
|
||||||
|
return () => ro.disconnect();
|
||||||
|
}, [n, chipsKey]);
|
||||||
|
|
||||||
|
if (n === 0) return null;
|
||||||
|
|
||||||
|
const hiddenCount = n - layout.visible;
|
||||||
|
const overflowChips = filters.slice(layout.visible);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
|
ref={laneRef}
|
||||||
data-testid="active-filter-chips"
|
data-testid="active-filter-chips"
|
||||||
className="flex flex-wrap items-center gap-1.5 pb-3"
|
className={`relative flex min-w-0 items-center gap-1.5 ${className ?? ""}`}
|
||||||
aria-label={t("feed.chips.label")}
|
aria-label={t("feed.chips.label")}
|
||||||
>
|
>
|
||||||
<span className="text-xs uppercase tracking-wider text-muted mr-0.5">
|
{/* Hidden mirror of the full set, used only to measure natural widths. Kept out of the a11y tree
|
||||||
{t("feed.chips.label")}
|
and without test ids so it never doubles the visible chip count. */}
|
||||||
</span>
|
<div
|
||||||
{filters.map((f) => (
|
aria-hidden="true"
|
||||||
<button
|
className="pointer-events-none invisible absolute left-0 top-0 flex items-center gap-1.5"
|
||||||
key={f.key}
|
>
|
||||||
data-testid="active-filter-chip"
|
<span ref={labelRef} className="text-xs uppercase tracking-wider text-muted mr-0.5">
|
||||||
data-filter-key={f.key}
|
{t("feed.chips.label")}
|
||||||
onClick={f.remove}
|
</span>
|
||||||
title={t("feed.chips.remove", { name: f.label })}
|
{filters.map((f, i) => (
|
||||||
aria-label={t("feed.chips.remove", { name: f.label })}
|
<span
|
||||||
className="chip group pl-2.5 pr-1.5 py-1 text-sm hover:border-accent hover:bg-accent/20 transition"
|
key={f.key}
|
||||||
>
|
ref={(el) => {
|
||||||
<span className="max-w-56 truncate">{f.label}</span>
|
chipRefs.current[i] = el;
|
||||||
<X className="w-3 h-3 shrink-0 opacity-60 group-hover:opacity-100" />
|
}}
|
||||||
</button>
|
className={chipCls}
|
||||||
))}
|
>
|
||||||
{filters.length > 1 && (
|
<span className="max-w-56 truncate">{f.label}</span>
|
||||||
<button
|
<X className="w-3 h-3 shrink-0" />
|
||||||
data-testid="active-filter-clear-all"
|
</span>
|
||||||
onClick={onClearAll}
|
))}
|
||||||
className="ml-0.5 text-sm text-muted hover:text-fg underline underline-offset-2 transition"
|
<span ref={plusRef} className="chip inline-flex items-center gap-1 px-2.5 py-1 text-sm">
|
||||||
>
|
+{n}
|
||||||
|
</span>
|
||||||
|
<span ref={clearRef} className="ml-0.5 text-sm underline underline-offset-2">
|
||||||
{t("sidebar.clearAll")}
|
{t("sidebar.clearAll")}
|
||||||
</button>
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Visible lane. */}
|
||||||
|
{layout.mode === "count" ? (
|
||||||
|
<OverflowPopover
|
||||||
|
label={t("feed.chips.count", { count: n })}
|
||||||
|
chips={filters}
|
||||||
|
onClearAll={onClearAll}
|
||||||
|
icon
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<span className="text-xs uppercase tracking-wider text-muted mr-0.5 shrink-0">
|
||||||
|
{t("feed.chips.label")}
|
||||||
|
</span>
|
||||||
|
{filters.slice(0, layout.visible).map((f) => (
|
||||||
|
<ChipButton key={f.key} f={f} testId="active-filter-chip" />
|
||||||
|
))}
|
||||||
|
{layout.mode === "some" && hiddenCount > 0 && (
|
||||||
|
<OverflowPopover
|
||||||
|
label={`+${hiddenCount}`}
|
||||||
|
chips={overflowChips}
|
||||||
|
onClearAll={onClearAll}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{layout.mode === "all" && n > 1 && (
|
||||||
|
<button
|
||||||
|
data-testid="active-filter-clear-all"
|
||||||
|
onClick={onClearAll}
|
||||||
|
className="ml-0.5 shrink-0 text-sm text-muted hover:text-fg underline underline-offset-2 transition"
|
||||||
|
>
|
||||||
|
{t("sidebar.clearAll")}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -570,6 +570,54 @@ export default function Feed({
|
|||||||
: SORT_DEFAULT_DIR[k as Exclude<SortKey, "shuffle" | "relevance">],
|
: SORT_DEFAULT_DIR[k as Exclude<SortKey, "shuffle" | "relevance">],
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
// Sort + view mode are tightly bound to the grid below, so they live pinned to the right of the
|
||||||
|
// applied-filter row (directly above the cards), not up in the filter toolbar. Extracted here so the
|
||||||
|
// same controls render on both the feed and channel-scoped rows.
|
||||||
|
const sortView = (
|
||||||
|
<>
|
||||||
|
<SortControl
|
||||||
|
options={sortOptions}
|
||||||
|
sort={{ key: sortKey, dir: sortDir }}
|
||||||
|
onChange={(next) => {
|
||||||
|
if (!next) return; // the feed is always sorted — no "no sort" option is offered
|
||||||
|
setFilters({
|
||||||
|
...filters,
|
||||||
|
sort: buildSort(next.key as SortKey, next.dir),
|
||||||
|
seed: next.key === "shuffle" ? rollSeed() : undefined,
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
label={t("feed.sortLabel")}
|
||||||
|
ascLabel={t("feed.dirAsc")}
|
||||||
|
descLabel={t("feed.dirDesc")}
|
||||||
|
trailing={
|
||||||
|
sortKey === "shuffle" ? (
|
||||||
|
<button
|
||||||
|
onClick={() => setFilters({ ...filters, seed: rollSeed() })}
|
||||||
|
title={t("sidebar.reshuffle")}
|
||||||
|
aria-label={t("sidebar.reshuffle")}
|
||||||
|
className="shrink-0 p-1.5 rounded-lg border border-border bg-card text-fg hover:border-accent hover:text-accent active:translate-y-px transition"
|
||||||
|
>
|
||||||
|
<RefreshCw className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
) : undefined
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<span className="mx-0.5 h-5 w-px bg-border" aria-hidden="true" />
|
||||||
|
<ViewSwitcher
|
||||||
|
value={view}
|
||||||
|
options={FEED_VIEWS.map((id) => ({
|
||||||
|
id,
|
||||||
|
label: t("feed.view." + id),
|
||||||
|
icon: VIEW_ICON[id],
|
||||||
|
}))}
|
||||||
|
onChange={setView}
|
||||||
|
label={t("feed.viewLabel")}
|
||||||
|
// The mode's name only earns its place once the window is wide.
|
||||||
|
labelClassName="hidden xl:inline"
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
const toolbar = (
|
const toolbar = (
|
||||||
<div className="pb-1.5">
|
<div className="pb-1.5">
|
||||||
<div className="flex items-center gap-1.5 gap-y-2 flex-wrap">
|
<div className="flex items-center gap-1.5 gap-y-2 flex-wrap">
|
||||||
@@ -619,49 +667,6 @@ export default function Feed({
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<div className="ml-auto flex items-center gap-1.5">
|
|
||||||
<SortControl
|
|
||||||
options={sortOptions}
|
|
||||||
sort={{ key: sortKey, dir: sortDir }}
|
|
||||||
onChange={(next) => {
|
|
||||||
if (!next) return; // the feed is always sorted — no "no sort" option is offered
|
|
||||||
setFilters({
|
|
||||||
...filters,
|
|
||||||
sort: buildSort(next.key as SortKey, next.dir),
|
|
||||||
seed: next.key === "shuffle" ? rollSeed() : undefined,
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
label={t("feed.sortLabel")}
|
|
||||||
ascLabel={t("feed.dirAsc")}
|
|
||||||
descLabel={t("feed.dirDesc")}
|
|
||||||
trailing={
|
|
||||||
sortKey === "shuffle" ? (
|
|
||||||
<button
|
|
||||||
onClick={() => setFilters({ ...filters, seed: rollSeed() })}
|
|
||||||
title={t("sidebar.reshuffle")}
|
|
||||||
aria-label={t("sidebar.reshuffle")}
|
|
||||||
className="shrink-0 p-1.5 rounded-lg border border-border bg-card text-fg hover:border-accent hover:text-accent active:translate-y-px transition"
|
|
||||||
>
|
|
||||||
<RefreshCw className="w-4 h-4" />
|
|
||||||
</button>
|
|
||||||
) : undefined
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<span className="mx-0.5 h-5 w-px bg-border" aria-hidden="true" />
|
|
||||||
<ViewSwitcher
|
|
||||||
value={view}
|
|
||||||
options={FEED_VIEWS.map((id) => ({
|
|
||||||
id,
|
|
||||||
label: t("feed.view." + id),
|
|
||||||
icon: VIEW_ICON[id],
|
|
||||||
}))}
|
|
||||||
onChange={setView}
|
|
||||||
label={t("feed.viewLabel")}
|
|
||||||
// This toolbar is packed — show/content chips, source, count, sort — so the mode's name
|
|
||||||
// only earns its place once the window is wide.
|
|
||||||
labelClassName="hidden xl:inline"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -677,17 +682,24 @@ export default function Feed({
|
|||||||
<PageToolbar>
|
<PageToolbar>
|
||||||
<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 && (
|
{/* The controls row sits directly above the grid: the applied-filter chips flow from the left
|
||||||
<div className="flex flex-wrap items-start gap-2">
|
(overflowing to a "+N"/count pill so they never crowd the controls), while Sort + View stay
|
||||||
{/* The active personal view (if any) leads the applied-filter row; ActiveFilterChips
|
pinned to the right. Channel-scoped feeds show no chips (their baseline isn't a "filter you
|
||||||
brings the row's own bottom padding. */}
|
applied"), but still get the right-pinned Sort/View. */}
|
||||||
<SelectedViewChip />
|
<div className="flex items-center gap-2 pb-2">
|
||||||
<ActiveFilterChips
|
{!channelScoped && (
|
||||||
filters={activeFilters}
|
<>
|
||||||
onClearAll={() => setFilters(clearedFilters(filters))}
|
{/* The active personal view (if any) leads the applied-filter row. */}
|
||||||
/>
|
<SelectedViewChip />
|
||||||
</div>
|
<ActiveFilterChips
|
||||||
)}
|
filters={activeFilters}
|
||||||
|
onClearAll={() => setFilters(clearedFilters(filters))}
|
||||||
|
className="flex-1"
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<div className="ml-auto flex shrink-0 items-center gap-1.5">{sortView}</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</PageToolbar>
|
</PageToolbar>
|
||||||
{items.length === 0 ? (
|
{items.length === 0 ? (
|
||||||
|
|||||||
@@ -3,6 +3,8 @@
|
|||||||
"label": "Filtered by",
|
"label": "Filtered by",
|
||||||
"remove": "Remove filter: {{name}}",
|
"remove": "Remove filter: {{name}}",
|
||||||
"contentNone": "No content types",
|
"contentNone": "No content types",
|
||||||
|
"count_one": "{{count}} filter",
|
||||||
|
"count_other": "{{count}} filters",
|
||||||
"maxAge_one": "Last {{count}} day",
|
"maxAge_one": "Last {{count}} day",
|
||||||
"maxAge_other": "Last {{count}} days"
|
"maxAge_other": "Last {{count}} days"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -3,6 +3,8 @@
|
|||||||
"label": "Szűrve",
|
"label": "Szűrve",
|
||||||
"remove": "Szűrő eltávolítása: {{name}}",
|
"remove": "Szűrő eltávolítása: {{name}}",
|
||||||
"contentNone": "Nincs tartalomtípus",
|
"contentNone": "Nincs tartalomtípus",
|
||||||
|
"count_one": "{{count}} szűrő",
|
||||||
|
"count_other": "{{count}} szűrő",
|
||||||
"maxAge_one": "Utolsó {{count}} nap",
|
"maxAge_one": "Utolsó {{count}} nap",
|
||||||
"maxAge_other": "Utolsó {{count}} nap"
|
"maxAge_other": "Utolsó {{count}} nap"
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user