206 lines
7.2 KiB
TypeScript
206 lines
7.2 KiB
TypeScript
/**
|
|
* Shared types + URL helpers for the multi-criteria filter bar.
|
|
*/
|
|
|
|
export type FilterTabKey = "actresses" | "studios" | "series" | "genres" | "collections" | "tags" | "categories";
|
|
export type FilterMode = "or" | "and";
|
|
|
|
export type WatchedState = "all" | "watched" | "unwatched";
|
|
export type RatedState = "all" | "rated" | "unrated";
|
|
export type PresenceState = "all" | "has" | "missing";
|
|
|
|
export interface FilterStatus {
|
|
watched: WatchedState;
|
|
rated: RatedState;
|
|
collection: PresenceState;
|
|
tags: PresenceState;
|
|
video: PresenceState;
|
|
}
|
|
|
|
export type MarkState = "all" | "vip" | "favorite" | "owned" | "unmarked";
|
|
export type MarkOption = "vip" | "favorite" | "owned" | "unmarked";
|
|
export const MARK_OPTIONS: MarkOption[] = ["vip", "favorite", "owned", "unmarked"];
|
|
|
|
export interface FilterCriteria {
|
|
ids: Record<FilterTabKey, number[]>;
|
|
mode: Record<FilterTabKey, FilterMode>;
|
|
status: FilterStatus;
|
|
// Empty array == "ALL" (no mark filter). Otherwise the OR of the listed marks.
|
|
marks: MarkOption[];
|
|
}
|
|
|
|
export const EMPTY_STATUS: FilterStatus = {
|
|
watched: "all",
|
|
rated: "all",
|
|
collection: "all",
|
|
tags: "all",
|
|
video: "all",
|
|
};
|
|
|
|
export const EMPTY_CRITERIA: FilterCriteria = {
|
|
ids: { actresses: [], studios: [], series: [], genres: [], collections: [], tags: [], categories: [] },
|
|
mode: { actresses: "or", studios: "or", series: "or", genres: "or", collections: "or", tags: "or", categories: "or" },
|
|
status: EMPTY_STATUS,
|
|
marks: [],
|
|
};
|
|
|
|
export type StatusAxisKey = keyof FilterStatus;
|
|
|
|
const TAB_KEYS: FilterTabKey[] = ["actresses", "studios", "series", "genres", "collections", "tags", "categories"];
|
|
// Studios and series can't AND (a cover has at most one).
|
|
const SUPPORTS_AND: Record<FilterTabKey, boolean> = {
|
|
actresses: true,
|
|
studios: false,
|
|
series: false,
|
|
genres: true,
|
|
collections: true,
|
|
tags: true,
|
|
categories: true,
|
|
};
|
|
|
|
export function tabSupportsAnd(k: FilterTabKey): boolean {
|
|
return SUPPORTS_AND[k];
|
|
}
|
|
|
|
export function parseFilterCriteria(sp: Record<string, string | string[] | undefined>): FilterCriteria {
|
|
const c: FilterCriteria = {
|
|
ids: { actresses: [], studios: [], series: [], genres: [], collections: [], tags: [], categories: [] },
|
|
mode: { actresses: "or", studios: "or", series: "or", genres: "or", collections: "or", tags: "or", categories: "or" },
|
|
status: { ...EMPTY_STATUS },
|
|
marks: [],
|
|
};
|
|
for (const k of TAB_KEYS) {
|
|
const raw = sp[k];
|
|
if (typeof raw === "string" && raw.trim()) {
|
|
c.ids[k] = raw.split(",").map((x) => Number(x)).filter((n) => Number.isFinite(n) && n > 0);
|
|
}
|
|
const m = sp[`${k}_mode`];
|
|
if (typeof m === "string" && m.toLowerCase() === "and" && SUPPORTS_AND[k]) c.mode[k] = "and";
|
|
}
|
|
|
|
// Watched axis. Accept both new (?watched=watched|unwatched) and legacy (?watched=1, ?unwatched=1).
|
|
const watched = sp.watched;
|
|
if (typeof watched === "string") {
|
|
if (watched === "watched" || watched === "1") c.status.watched = "watched";
|
|
else if (watched === "unwatched") c.status.watched = "unwatched";
|
|
}
|
|
if (sp.unwatched === "1") c.status.watched = "unwatched";
|
|
|
|
const rated = sp.rated;
|
|
if (typeof rated === "string") {
|
|
if (rated === "rated" || rated === "1") c.status.rated = "rated";
|
|
else if (rated === "unrated") c.status.rated = "unrated";
|
|
}
|
|
if (sp.unrated === "1") c.status.rated = "unrated";
|
|
|
|
const collection = sp.collection;
|
|
if (typeof collection === "string") {
|
|
if (collection === "has") c.status.collection = "has";
|
|
else if (collection === "missing") c.status.collection = "missing";
|
|
}
|
|
if (sp.uncollected === "1") c.status.collection = "missing";
|
|
|
|
const tagsStatus = sp.tags_status;
|
|
if (typeof tagsStatus === "string") {
|
|
if (tagsStatus === "has") c.status.tags = "has";
|
|
else if (tagsStatus === "missing") c.status.tags = "missing";
|
|
}
|
|
if (sp.untagged === "1") c.status.tags = "missing";
|
|
|
|
const video = sp.video;
|
|
if (typeof video === "string") {
|
|
if (video === "has") c.status.video = "has";
|
|
else if (video === "missing") c.status.video = "missing";
|
|
}
|
|
|
|
// marks: comma-separated list. Legacy `?mark=vip` (singular) is also honored
|
|
// so old bookmarked URLs keep working.
|
|
const marksRaw = (typeof sp.marks === "string" && sp.marks) || (typeof sp.mark === "string" ? sp.mark : "");
|
|
if (marksRaw) {
|
|
const wanted = marksRaw.split(",").map((s) => s.trim()).filter(Boolean);
|
|
const valid = new Set<MarkOption>(MARK_OPTIONS);
|
|
const out: MarkOption[] = [];
|
|
const seen = new Set<MarkOption>();
|
|
for (const w of wanted) {
|
|
if (valid.has(w as MarkOption) && !seen.has(w as MarkOption)) {
|
|
out.push(w as MarkOption);
|
|
seen.add(w as MarkOption);
|
|
}
|
|
}
|
|
c.marks = out;
|
|
}
|
|
|
|
return c;
|
|
}
|
|
|
|
/** Apply a `FilterCriteria` onto a `URLSearchParams`. Existing keys are overwritten. */
|
|
export function writeFilterCriteria(sp: URLSearchParams, c: FilterCriteria): void {
|
|
for (const k of TAB_KEYS) {
|
|
if (c.ids[k].length === 0) sp.delete(k);
|
|
else sp.set(k, c.ids[k].join(","));
|
|
if (c.mode[k] === "and" && SUPPORTS_AND[k]) sp.set(`${k}_mode`, "and");
|
|
else sp.delete(`${k}_mode`);
|
|
}
|
|
// Always write canonical status keys; clear the legacy ones so they don't fight us.
|
|
if (c.status.watched === "all") sp.delete("watched"); else sp.set("watched", c.status.watched);
|
|
if (c.status.rated === "all") sp.delete("rated"); else sp.set("rated", c.status.rated);
|
|
if (c.status.collection === "all") sp.delete("collection"); else sp.set("collection", c.status.collection);
|
|
if (c.status.tags === "all") sp.delete("tags_status"); else sp.set("tags_status", c.status.tags);
|
|
if (c.status.video === "all") sp.delete("video"); else sp.set("video", c.status.video);
|
|
sp.delete("unwatched");
|
|
sp.delete("unrated");
|
|
sp.delete("uncollected");
|
|
sp.delete("untagged");
|
|
// Single canonical key: `marks=vip,favorite`. Drop legacy `mark` if present.
|
|
sp.delete("mark");
|
|
if (c.marks.length === 0) sp.delete("marks");
|
|
else sp.set("marks", c.marks.join(","));
|
|
}
|
|
|
|
export function totalSelected(c: FilterCriteria): number {
|
|
let n = 0;
|
|
for (const k of TAB_KEYS) n += c.ids[k].length;
|
|
return n;
|
|
}
|
|
|
|
export function totalStatusActive(c: FilterCriteria): number {
|
|
let n = 0;
|
|
if (c.status.watched !== "all") n++;
|
|
if (c.status.rated !== "all") n++;
|
|
if (c.status.collection !== "all") n++;
|
|
if (c.status.tags !== "all") n++;
|
|
if (c.status.video !== "all") n++;
|
|
return n;
|
|
}
|
|
|
|
export function anyActive(c: FilterCriteria): boolean {
|
|
return totalSelected(c) > 0 || totalStatusActive(c) > 0 || c.marks.length > 0;
|
|
}
|
|
|
|
/** Translate the tri-state status into the boolean flags consumed by listImages / libraryLetterCounts. */
|
|
export function statusToFlags(s: FilterStatus): {
|
|
watched?: boolean;
|
|
unwatched?: boolean;
|
|
rated?: boolean;
|
|
unrated?: boolean;
|
|
hasCollection?: boolean;
|
|
uncollected?: boolean;
|
|
hasTags?: boolean;
|
|
untagged?: boolean;
|
|
hasVideo?: boolean;
|
|
noVideo?: boolean;
|
|
} {
|
|
return {
|
|
watched: s.watched === "watched",
|
|
unwatched: s.watched === "unwatched",
|
|
rated: s.rated === "rated",
|
|
unrated: s.rated === "unrated",
|
|
hasCollection: s.collection === "has",
|
|
uncollected: s.collection === "missing",
|
|
hasTags: s.tags === "has",
|
|
untagged: s.tags === "missing",
|
|
hasVideo: s.video === "has",
|
|
noVideo: s.video === "missing",
|
|
};
|
|
}
|