Initial commit

This commit is contained in:
admin
2026-05-26 22:46:00 +02:00
commit 7e2c2ff89c
256 changed files with 51523 additions and 0 deletions
+205
View File
@@ -0,0 +1,205 @@
/**
* 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",
};
}