feat(filters): the rule builder — composed engines over the field registry (faceted plan Phase 9)
- Field registry (services/filter_registry.go): ~55 typed fields — every book column, file/lifecycle facts, the three HOLDING STRUCTURES (library, collections with excluded-aware membership, series with FULL primary+alternate_info membership), and the whole user-relative class (status, progress, last read, my rating, review, notes/highlights presence, note/highlight text, time spent, pages read, collections, added by) reading the VIEWER's rows. Operators are typed constants; negation is an operator. - Composed engines (services/filter_query.go): one Go clause builder over raw pgx feeds the search (identical SearchMediaItemsUnifiedRow shape), the field-aware suggestions, and a true-COUNT live count. Mandatory WHERE head everywhere (family lens + integrity facts). Values are always bound parameters — no user string reaches a field/operator position, ever. - Wire: the qf JSON param (validated strictly — unknown fields/operators/malformed values are 400); qf wins over flat params on hand-crafted URLs; flat deep links keep working unchanged. - Endpoints: GET /api/media-items/filter-count (live count); field-values autocomplete upgraded in place — any registry field key, qf binds the OTHER rules (symmetric under-filter suggestions, zero-match values never offered); classic six without a tree keep their static pinned contract. - UI: the drawer becomes the rule builder — the ONLY filter UI (templ markup, Alpine tree state, sanctioned island; grouped field menus, operator menus with red negations, field-aware value menus, live count, chips per rule with red negations, URL rehydration, smart serialization flat-when-simple else qf). - Tests: composed semantics (AND/OR/negation/nesting/enums/bools/ numbers/dates/empty), every user-relative field class with the my-data weld (two readers, different worlds), note-text substring pin (catharsis ≠ Carthage), family lens (hidden libraries nowhere, 403s), suggestion-under-rules exactness, qf validation + precedence, flat-vs-tree equivalence; registry completeness unit test.
This commit is contained in:
1 parent
ebb7f4a5b0
commit
4ee24e7152
18 files changed
+3788
-376
No files matched your search
+112
-213
@@ -2,26 +2,44 @@
|
||||
|
||||
import { Alpine } from "./alpine";
|
||||
import { showToast } from "./toast";
|
||||
import { searchTagSuggestions, type TagSuggestion } from "./tag-dropdown";
|
||||
import type {
|
||||
FilterGroupState,
|
||||
FilterRuleState,
|
||||
} from "./filter_builder";
|
||||
|
||||
function clearFilters(): void {
|
||||
// Clear all form state (reuses helper)
|
||||
clearFormWithoutSubmit();
|
||||
|
||||
// Reset the rule builder tree (the builder's wire inputs are
|
||||
// serializer-managed and already dropped by cleanupDynamicHiddenInputs)
|
||||
builderData()?.clearBuilder();
|
||||
|
||||
// Trigger form submit with cleared filters
|
||||
const filterForm = document.getElementById("filter-form") as HTMLFormElement;
|
||||
if (!filterForm) return;
|
||||
window.htmx.trigger(filterForm, "submit");
|
||||
}
|
||||
|
||||
// builderData returns the rule builder's Alpine data (nested x-data
|
||||
// inside the drawer) — the same DOM-lookup pattern the bookshelf
|
||||
// component uses.
|
||||
function builderData(): any | null {
|
||||
const el = document.querySelector('[x-data*="filterBuilderExtra"]');
|
||||
if (!el || !(el as any)._x_dataStack) return null;
|
||||
return (el as any)._x_dataStack[0] ?? null;
|
||||
}
|
||||
|
||||
// Remove dynamically created hidden inputs from filter form
|
||||
// Preserves: limit, offset, has_cover (static/managed inputs)
|
||||
// Preserves: limit, offset (static/managed inputs). The builder's wire
|
||||
// inputs (flat facet params + qf) are serializer-managed and dropped so
|
||||
// the next serialization starts clean.
|
||||
function cleanupDynamicHiddenInputs(): void {
|
||||
const filterForm = document.getElementById("filter-form") as HTMLFormElement;
|
||||
if (!filterForm) return;
|
||||
|
||||
// Names of inputs to preserve (static or Alpine-managed)
|
||||
const preserveNames = ["limit", "offset", "has_cover"];
|
||||
const preserveNames = ["limit", "offset"];
|
||||
|
||||
// Find all hidden inputs
|
||||
const hiddenInputs = filterForm.querySelectorAll('input[type="hidden"]');
|
||||
@@ -43,23 +61,6 @@ function clearFormWithoutSubmit(): void {
|
||||
// Reset all form fields to their HTML defaults
|
||||
filterForm.reset();
|
||||
|
||||
// Remove has_cover input entirely
|
||||
const hasCoverInput = document.querySelector(
|
||||
'[name="has_cover"]',
|
||||
) as HTMLInputElement;
|
||||
if (hasCoverInput) {
|
||||
hasCoverInput.remove();
|
||||
}
|
||||
|
||||
// Reset Alpine hasCoverState to null
|
||||
const bookshelfComponent = document.querySelector('[x-data*="bookshelf"]');
|
||||
if (bookshelfComponent && (bookshelfComponent as any)._x_dataStack) {
|
||||
const data = (bookshelfComponent as any)._x_dataStack[0];
|
||||
if (data && typeof data.hasCoverState !== "undefined") {
|
||||
data.hasCoverState = null;
|
||||
}
|
||||
}
|
||||
|
||||
// Remove any hidden inputs from previous filter loads
|
||||
cleanupDynamicHiddenInputs();
|
||||
|
||||
@@ -81,12 +82,8 @@ Alpine.data("bookshelf", () => ({
|
||||
showSaveModal: false,
|
||||
showFiltersDropdown: false,
|
||||
filtersOpen: false,
|
||||
hasCoverState: null as boolean | null,
|
||||
dropdownAlign: "right" as "left" | "right",
|
||||
resizeTimeout: null as number | null,
|
||||
tagSuggestions: [] as TagSuggestion[],
|
||||
showTagDropdown: false,
|
||||
tagHighlightIndex: -1,
|
||||
// Active-filter chips (Batch B): Alpine renders them from this state;
|
||||
// refreshed by refreshFilterChips after every grid refetch.
|
||||
activeFilters: [] as FilterChip[],
|
||||
@@ -107,9 +104,12 @@ Alpine.data("bookshelf", () => ({
|
||||
"filter-form",
|
||||
) as HTMLFormElement;
|
||||
if (!filterForm) return;
|
||||
if (param === "has_cover") {
|
||||
filterForm.querySelector('[name="has_cover"]')?.remove();
|
||||
this.hasCoverState = null;
|
||||
// Tree chips (qf mode): "rule:<path>" — remove that rule from the
|
||||
// builder tree, resync the wire inputs, re-apply.
|
||||
if (param.startsWith("rule:")) {
|
||||
const builder = builderData();
|
||||
const path = param.slice(5).split("-").map((n) => parseInt(n, 10));
|
||||
builder?.removeNode(path);
|
||||
window.htmx.trigger(filterForm, "submit");
|
||||
return;
|
||||
}
|
||||
@@ -172,18 +172,6 @@ Alpine.data("bookshelf", () => ({
|
||||
});
|
||||
});
|
||||
},
|
||||
// Cycle through has_cover states: null -> true -> false -> null
|
||||
cycleHasCover(): void {
|
||||
if (this.hasCoverState === null) {
|
||||
this.hasCoverState = true;
|
||||
} else if (this.hasCoverState === true) {
|
||||
this.hasCoverState = false;
|
||||
} else {
|
||||
this.hasCoverState = null;
|
||||
}
|
||||
// NOTE: We don't trigger form submission here
|
||||
// The user must click "Search" or press Enter to apply the filter
|
||||
},
|
||||
// Handle post-HTMX swap cleanup for saved filters
|
||||
afterFilterSave(): void {
|
||||
// Hide empty state if it exists
|
||||
@@ -257,32 +245,11 @@ Alpine.data("bookshelf", () => ({
|
||||
clearFormWithoutSubmit();
|
||||
|
||||
// Don't clear form - just update values
|
||||
// Populate form fields from filter data
|
||||
// Populate form fields from filter data. Every saved param lands
|
||||
// as a (hidden) form input — the builder's hydration reads them
|
||||
// (qf wins over flat, the pinned precedence).
|
||||
Object.entries(filterData).forEach(([key, value]) => {
|
||||
if (key === "has_cover") {
|
||||
const bookshelfComponent = document.querySelector(
|
||||
'[x-data*="bookshelf"]',
|
||||
);
|
||||
if (bookshelfComponent && (bookshelfComponent as any)._x_dataStack) {
|
||||
const data = (bookshelfComponent as any)._x_dataStack[0];
|
||||
if (data && typeof data.hasCoverState !== "undefined") {
|
||||
// Convert string value to boolean|null
|
||||
if (value === "true") {
|
||||
data.hasCoverState = true;
|
||||
} else if (value === "false") {
|
||||
data.hasCoverState = false;
|
||||
} else {
|
||||
data.hasCoverState = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
return; // Don't process has_cover like other fields
|
||||
}
|
||||
|
||||
if (!value) return; // Skip empty values
|
||||
// Special handling for has_cover - update Alpine state directly
|
||||
|
||||
// Handle all other fields normally
|
||||
const visibleField = filterForm.querySelector(
|
||||
`[name="${key}"]`,
|
||||
) as HTMLInputElement;
|
||||
@@ -302,6 +269,9 @@ Alpine.data("bookshelf", () => ({
|
||||
}
|
||||
});
|
||||
|
||||
// Rehydrate the rule-builder tree from the refilled form
|
||||
builderData()?.hydrateFromForm();
|
||||
|
||||
// Trigger HTMX to apply the filter by submitting the form
|
||||
window.htmx.trigger(filterForm, "submit");
|
||||
|
||||
@@ -349,139 +319,6 @@ Alpine.data("bookshelf", () => ({
|
||||
showToast("Error deleting filter", "error");
|
||||
}
|
||||
},
|
||||
|
||||
// Autocomplete helper function
|
||||
async fetchFieldValues(
|
||||
field: string,
|
||||
search: string,
|
||||
datalistId: string,
|
||||
): Promise<void> {
|
||||
const token = localStorage.getItem("token");
|
||||
if (!token) {
|
||||
console.error("Not authenticated");
|
||||
return;
|
||||
}
|
||||
|
||||
if (search.length < 2) return;
|
||||
|
||||
const currentLibraryId = (
|
||||
document.getElementById("library-select") as HTMLSelectElement
|
||||
)?.value;
|
||||
|
||||
const libParam = currentLibraryId ? `&library_id=${currentLibraryId}` : "";
|
||||
|
||||
try {
|
||||
const response = await fetch(
|
||||
`/api/media-items/search?${field}=${encodeURIComponent(
|
||||
search,
|
||||
)}${libParam}&limit=50`,
|
||||
{
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
},
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
console.error("Failed to fetch field values");
|
||||
return;
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
// Update datalist via DOM manipulation
|
||||
const datalist = document.getElementById(datalistId);
|
||||
if (!datalist) {
|
||||
console.error(`Datalist ${datalistId} not found`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Clear existing options
|
||||
datalist.innerHTML = "";
|
||||
|
||||
// Add new options
|
||||
data.results.forEach((item: { value: string; count: number }) => {
|
||||
const option = document.createElement("option");
|
||||
option.value = item.value;
|
||||
option.textContent = `${item.value} (${item.count})`;
|
||||
datalist.appendChild(option);
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Error fetching field values:", error);
|
||||
}
|
||||
},
|
||||
|
||||
// Fetch author values for autocomplete
|
||||
async fetchAuthorValues(input: HTMLInputElement): Promise<void> {
|
||||
await this.fetchFieldValues("author", input.value, "author-datalist");
|
||||
},
|
||||
|
||||
// Fetch tag values for autocomplete (custom dropdown)
|
||||
async fetchTagValues(input: HTMLInputElement): Promise<void> {
|
||||
const libraryId = (document.getElementById("library-select") as HTMLSelectElement)?.value;
|
||||
if (!libraryId || input.value.length < 2) {
|
||||
this.tagSuggestions = [];
|
||||
this.showTagDropdown = false;
|
||||
return;
|
||||
}
|
||||
const results = await searchTagSuggestions(input.value, libraryId);
|
||||
this.tagSuggestions = results;
|
||||
this.showTagDropdown = results.length > 0;
|
||||
this.tagHighlightIndex = -1;
|
||||
},
|
||||
|
||||
selectTagSuggestion(tag: string) {
|
||||
const input = document.querySelector('input[name="tags_filter"]') as HTMLInputElement;
|
||||
if (input) input.value = tag;
|
||||
this.showTagDropdown = false;
|
||||
this.tagSuggestions = [];
|
||||
this.tagHighlightIndex = -1;
|
||||
},
|
||||
|
||||
hideTagDropdown() {
|
||||
setTimeout(() => { this.showTagDropdown = false; this.tagHighlightIndex = -1; }, 200);
|
||||
},
|
||||
|
||||
onTagFilterKeydown(event: KeyboardEvent) {
|
||||
if (event.key === "ArrowDown") {
|
||||
event.preventDefault();
|
||||
if (this.showTagDropdown && this.tagSuggestions.length > 0) {
|
||||
this.tagHighlightIndex = (this.tagHighlightIndex + 1) % this.tagSuggestions.length;
|
||||
}
|
||||
} else if (event.key === "ArrowUp") {
|
||||
event.preventDefault();
|
||||
if (this.showTagDropdown && this.tagSuggestions.length > 0) {
|
||||
this.tagHighlightIndex = this.tagHighlightIndex <= 0
|
||||
? this.tagSuggestions.length - 1
|
||||
: this.tagHighlightIndex - 1;
|
||||
}
|
||||
} else if (event.key === "Enter" && this.tagHighlightIndex >= 0 && this.showTagDropdown) {
|
||||
event.preventDefault();
|
||||
this.selectTagSuggestion(this.tagSuggestions[this.tagHighlightIndex].value);
|
||||
} else if (event.key === "Escape") {
|
||||
this.showTagDropdown = false;
|
||||
this.tagSuggestions = [];
|
||||
this.tagHighlightIndex = -1;
|
||||
}
|
||||
},
|
||||
|
||||
// // Fetch genre values for autocomplete
|
||||
// async fetchGenreValues(input: HTMLInputElement): Promise<void> {
|
||||
// await this.fetchFieldValues("genre", input.value, "genre-datalist");
|
||||
// },
|
||||
|
||||
// Fetch series values for autocomplete
|
||||
async fetchSeriesValues(input: HTMLInputElement): Promise<void> {
|
||||
await this.fetchFieldValues("series", input.value, "series-datalist");
|
||||
},
|
||||
|
||||
// Fetch narrator values for autocomplete (A2a)
|
||||
async fetchNarratorValues(input: HTMLInputElement): Promise<void> {
|
||||
await this.fetchFieldValues("narrator", input.value, "narrator-datalist");
|
||||
},
|
||||
|
||||
// Fetch language values for autocomplete
|
||||
async fetchLanguageValues(input: HTMLInputElement): Promise<void> {
|
||||
await this.fetchFieldValues("language", input.value, "language-datalist");
|
||||
},
|
||||
}));
|
||||
|
||||
// Standalone function - initialize the bookshelf (NOT async, like dashboard.ts)
|
||||
@@ -494,6 +331,26 @@ Alpine.data("bookshelf", () => ({
|
||||
function applyURLFilters(filterForm: HTMLFormElement | null): boolean {
|
||||
if (!filterForm) return false;
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
// Rule-builder trees ride the qf param (faceted plan Phase 9).
|
||||
// Precedence (pinned): when qf is present the flat params are
|
||||
// ignored — the tree IS the query.
|
||||
const qf = params.get("qf");
|
||||
if (qf) {
|
||||
const input = filterForm.querySelector(
|
||||
'[name="qf"]',
|
||||
) as HTMLInputElement | null;
|
||||
if (input) {
|
||||
input.value = qf;
|
||||
} else {
|
||||
const hidden = document.createElement("input");
|
||||
hidden.type = "hidden";
|
||||
hidden.name = "qf";
|
||||
hidden.value = qf;
|
||||
filterForm.appendChild(hidden);
|
||||
}
|
||||
builderData()?.hydrateFromForm();
|
||||
return true;
|
||||
}
|
||||
const filterNames = [
|
||||
"author_filter",
|
||||
"series_filter",
|
||||
@@ -513,6 +370,7 @@ function applyURLFilters(filterForm: HTMLFormElement | null): boolean {
|
||||
"abridged_filter",
|
||||
"year_min",
|
||||
"year_max",
|
||||
"qf",
|
||||
];
|
||||
let applied = false;
|
||||
for (const name of filterNames) {
|
||||
@@ -545,6 +403,8 @@ function applyURLFilters(filterForm: HTMLFormElement | null): boolean {
|
||||
interface FilterChip {
|
||||
param: string;
|
||||
label: string;
|
||||
// Tree chips only: negated operators render red.
|
||||
negated: boolean;
|
||||
}
|
||||
|
||||
// prettifyGenreLabel ports templates.prettifyGenre (Batch B transform):
|
||||
@@ -610,23 +470,55 @@ function refreshFilterChips(): void {
|
||||
if (typeof data.activeFilters === "undefined") return;
|
||||
|
||||
const chips: FilterChip[] = [];
|
||||
filterForm
|
||||
.querySelectorAll<HTMLInputElement>(
|
||||
'[name$="_filter"], [name="year_min"], [name="year_max"]',
|
||||
)
|
||||
.forEach((input) => {
|
||||
if (input.value) {
|
||||
chips.push({
|
||||
param: input.name,
|
||||
label: facetChipLabel(input.name, input.value),
|
||||
});
|
||||
}
|
||||
});
|
||||
const hasCoverInput = filterForm.querySelector(
|
||||
'[name="has_cover"]',
|
||||
const qfInput = filterForm.querySelector(
|
||||
'[name="qf"]',
|
||||
) as HTMLInputElement | null;
|
||||
if (hasCoverInput && hasCoverInput.value) {
|
||||
chips.push({ param: "has_cover", label: facetChipLabel("has_cover", hasCoverInput.value) });
|
||||
|
||||
if (qfInput && qfInput.value) {
|
||||
// Tree mode: one chip per rule (red for negations — the chips
|
||||
// template styles them via chip.negated). Values that are IDs
|
||||
// (collection/library) label through the builder's id→name cache.
|
||||
const builder = builderData();
|
||||
try {
|
||||
const tree = JSON.parse(qfInput.value) as FilterGroupState;
|
||||
const walk = (group: FilterGroupState, path: number[]) => {
|
||||
(group.children as (FilterRuleState | FilterGroupState)[]).forEach(
|
||||
(child, idx) => {
|
||||
const childPath = [...path, idx];
|
||||
if ("children" in (child as FilterGroupState)) {
|
||||
walk(child as FilterGroupState, childPath);
|
||||
return;
|
||||
}
|
||||
const rule = child as FilterRuleState;
|
||||
const label = builder
|
||||
? builder.chipLabelFor(rule)
|
||||
: `${rule.field} ${rule.op}`;
|
||||
chips.push({
|
||||
param: "rule:" + childPath.join("-"),
|
||||
label,
|
||||
negated: builder?.isNegated(rule) ?? false,
|
||||
});
|
||||
},
|
||||
);
|
||||
};
|
||||
walk(tree, []);
|
||||
} catch {
|
||||
// malformed qf — no tree chips
|
||||
}
|
||||
} else {
|
||||
filterForm
|
||||
.querySelectorAll<HTMLInputElement>(
|
||||
'[name$="_filter"], [name="year_min"], [name="year_max"], [name="has_cover"]',
|
||||
)
|
||||
.forEach((input) => {
|
||||
if (input.value) {
|
||||
chips.push({
|
||||
param: input.name,
|
||||
label: facetChipLabel(input.name, input.value),
|
||||
negated: false,
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
data.activeFilters = chips;
|
||||
@@ -640,7 +532,7 @@ function refreshFilterChips(): void {
|
||||
"imprint_filter", "edition_filter", "age_rating_filter",
|
||||
"format_group_filter", "manga_type_filter", "reading_direction_filter",
|
||||
"explicit_filter", "abridged_filter", "year_min", "year_max",
|
||||
"has_cover", "library_id",
|
||||
"has_cover", "library_id", "qf",
|
||||
]);
|
||||
const next = new URLSearchParams();
|
||||
url.searchParams.forEach((value, key) => {
|
||||
@@ -674,8 +566,15 @@ function initBookshelf(): void {
|
||||
// pre-fills the form and applies it to the first grid load. Author
|
||||
// links on the detail page (and anything else) can share filtered
|
||||
// views; a comma-joined value is a multi-select (server splits it).
|
||||
// qf deep links rehydrate as the rule tree (qf wins over flat — the
|
||||
// pinned precedence).
|
||||
const urlApplied = applyURLFilters(filterForm);
|
||||
|
||||
// The builder's tree mirrors the applied wire state (URL or empty)
|
||||
// so the drawer opens with the current rules; the wire inputs are
|
||||
// then serializer-owned.
|
||||
builderData()?.hydrateFromForm();
|
||||
|
||||
// Check if books were already rendered server-side
|
||||
const booksGrid = document.getElementById("books-grid");
|
||||
const hasServerBooks =
|
||||
|
||||
@@ -0,0 +1,774 @@
|
||||
// Filter rule builder (faceted plan Phase 9) — the drawer's ONLY filter
|
||||
// UI. Alpine owns the rule-tree state; templ owns the markup (the
|
||||
// sanctioned island: same pattern as the chips bar, NO innerHTML
|
||||
// templating). The server renders nothing per keystroke: value menus
|
||||
// are server JSON (field-aware, computed under the OTHER rules),
|
||||
// rendered by Alpine.
|
||||
//
|
||||
// The tree serializes to the FLAT facet params when it is simple (one
|
||||
// rule per field, flat-expressible operators — readable deep links
|
||||
// preserved), else to one compact `qf` JSON param. saved_filters store
|
||||
// either (no schema change). On a hand-crafted URL carrying both, qf
|
||||
// wins server-side.
|
||||
|
||||
import { Alpine } from "./alpine";
|
||||
|
||||
// ─── operator + field metadata (client mirror of
|
||||
// internal/services/filter_registry.go — labels drive the menus) ────
|
||||
|
||||
export interface OpMeta {
|
||||
key: string;
|
||||
label: string;
|
||||
negated: boolean;
|
||||
arity: "none" | "one" | "two" | "many";
|
||||
}
|
||||
|
||||
const op = (
|
||||
key: string,
|
||||
label: string,
|
||||
negated: boolean,
|
||||
arity: OpMeta["arity"],
|
||||
): OpMeta => ({ key, label, negated, arity });
|
||||
|
||||
export const OPS: Record<string, OpMeta> = {
|
||||
contains: op("contains", "contains", false, "many"),
|
||||
not_contains: op("doesn't contain", "doesn't contain", true, "many"),
|
||||
is: op("is", "is", false, "many"),
|
||||
is_not: op("isn't", "isn't", true, "many"),
|
||||
empty: op("is empty", "is empty", false, "none"),
|
||||
is_true: op("is true", "is true", false, "none"),
|
||||
is_false: op("is false", "is false", false, "none"),
|
||||
between: op("between", "between", false, "two"),
|
||||
at_least: op("at least", "at least", false, "one"),
|
||||
at_most: op("at most", "at most", false, "one"),
|
||||
within_days: op("within the last", "within the last", false, "one"),
|
||||
has: op("has", "has", false, "none"),
|
||||
missing: op("missing", "missing", false, "none"),
|
||||
unrated: op("unrated", "unrated", false, "none"),
|
||||
in: op("in", "in", false, "many"),
|
||||
not_in: op("isn't in", "isn't in", true, "many"),
|
||||
in_any: op("in any", "in any", false, "none"),
|
||||
in_none: op("in none", "in none", false, "none"),
|
||||
};
|
||||
|
||||
export interface FieldMeta {
|
||||
key: string;
|
||||
label: string;
|
||||
group: string;
|
||||
// value kind for the cell: text (with server suggestions when
|
||||
// suggest), number, days, id (server suggestions, id values), fixed
|
||||
// (static option list), none.
|
||||
valueKind: "text" | "number" | "days" | "id" | "fixed" | "none";
|
||||
suggest: boolean;
|
||||
options?: string[];
|
||||
ops: string[];
|
||||
defaultOp: string;
|
||||
}
|
||||
|
||||
const F = (
|
||||
key: string,
|
||||
label: string,
|
||||
group: string,
|
||||
valueKind: FieldMeta["valueKind"],
|
||||
ops: string[],
|
||||
defaultOp: string,
|
||||
suggest = false,
|
||||
options?: string[],
|
||||
): FieldMeta => ({ key, label, group, valueKind, ops, defaultOp, suggest, options });
|
||||
|
||||
const TEXT_OPS = ["contains", "not_contains", "is", "is_not", "empty"];
|
||||
const NUM_OPS = ["between", "at_least", "at_most", "empty"];
|
||||
const DATE_OPS = ["within_days", "between", "empty"];
|
||||
|
||||
export const FIELD_GROUPS: { group: string; fields: FieldMeta[] }[] = [
|
||||
{
|
||||
group: "People",
|
||||
fields: [
|
||||
F("author", "Author", "People", "text", TEXT_OPS, "contains", true),
|
||||
F("narrator", "Narrator", "People", "text", TEXT_OPS, "contains", true),
|
||||
F("contributor", "Contributor", "People", "text", TEXT_OPS, "contains", true),
|
||||
],
|
||||
},
|
||||
{
|
||||
group: "Publishing",
|
||||
fields: [
|
||||
F("publisher", "Publisher", "Publishing", "text", TEXT_OPS, "contains", true),
|
||||
F("imprint", "Imprint", "Publishing", "text", TEXT_OPS, "contains", true),
|
||||
F("edition", "Edition", "Publishing", "text", TEXT_OPS, "contains", true),
|
||||
F("published_year", "Published year", "Publishing", "number", NUM_OPS, "at_least"),
|
||||
],
|
||||
},
|
||||
{
|
||||
group: "Topics",
|
||||
fields: [
|
||||
F("genre", "Genre", "Topics", "text", TEXT_OPS, "contains", true),
|
||||
F("tags", "Tags", "Topics", "text", TEXT_OPS, "contains", true),
|
||||
F("story_arc", "Story arc", "Topics", "text", TEXT_OPS, "contains", true),
|
||||
F("age_rating", "Age rating", "Topics", "text", ["is", "is_not", "empty"], "is", true),
|
||||
],
|
||||
},
|
||||
{
|
||||
group: "Book facts",
|
||||
fields: [
|
||||
F("title", "Title", "Book facts", "text", TEXT_OPS, "contains", true),
|
||||
F("subtitle", "Subtitle", "Book facts", "text", TEXT_OPS, "contains", true),
|
||||
F("description", "Description", "Book facts", "text", TEXT_OPS, "contains"),
|
||||
F("summary", "Summary", "Book facts", "none", ["has", "missing"], "has"),
|
||||
F("language", "Language", "Book facts", "text", TEXT_OPS, "contains", true),
|
||||
F("page_count", "Page count", "Book facts", "number", NUM_OPS, "at_least"),
|
||||
F("chapter_count", "Chapter count", "Book facts", "number", NUM_OPS, "at_least"),
|
||||
F("total_characters", "Total characters", "Book facts", "number", NUM_OPS, "at_least"),
|
||||
F("community_rating", "Community rating", "Book facts", "number", NUM_OPS, "at_least"),
|
||||
F("added_at", "Date added", "Book facts", "days", DATE_OPS, "within_days"),
|
||||
F("imported_at", "Date imported", "Book facts", "days", DATE_OPS, "within_days"),
|
||||
F("web_url", "Web URL", "Book facts", "none", ["has", "missing"], "has"),
|
||||
F("metadata_notes", "Metadata notes", "Book facts", "none", ["has", "missing"], "has"),
|
||||
F("isbn", "ISBN", "Book facts", "text", ["is", "is_not", "has", "missing"], "has"),
|
||||
F("asin", "ASIN", "Book facts", "text", ["is", "is_not", "has", "missing"], "has"),
|
||||
F("file_size", "File size", "Book facts", "number", NUM_OPS, "at_least"),
|
||||
F("file_availability", "File availability", "Book facts", "fixed",
|
||||
["is", "is_not"], "is", false, ["available", "missing"]),
|
||||
F("cover", "Cover", "Book facts", "none", ["has", "missing"], "has"),
|
||||
F("added_by", "Added by", "Book facts", "id", ["is", "has", "missing"], "has", true),
|
||||
],
|
||||
},
|
||||
{
|
||||
group: "Comics & manga",
|
||||
fields: [
|
||||
F("series", "Series", "Comics & manga", "text", TEXT_OPS, "contains", true),
|
||||
F("series_number", "Series number", "Comics & manga", "number", NUM_OPS, "at_least"),
|
||||
F("series_count", "Series count", "Comics & manga", "number", NUM_OPS, "at_least"),
|
||||
F("volume", "Volume", "Comics & manga", "number", NUM_OPS, "at_least"),
|
||||
F("format_group", "Format group", "Comics & manga", "text", ["is", "is_not"], "is", true),
|
||||
F("mime_type", "File type (mime)", "Comics & manga", "text", ["is", "is_not"], "is", true),
|
||||
F("is_reflowable", "Reflowable", "Comics & manga", "none", ["is_true", "is_false"], "is_true"),
|
||||
F("has_fixed_layout", "Fixed layout", "Comics & manga", "none", ["is_true", "is_false"], "is_true"),
|
||||
F("is_black_and_white", "Black & white", "Comics & manga", "none", ["is_true", "is_false"], "is_true"),
|
||||
F("manga_type", "Manga type", "Comics & manga", "text", ["is", "is_not", "empty"], "is", true),
|
||||
F("reading_direction", "Reading direction", "Comics & manga", "text", ["is", "is_not", "empty"], "is", true),
|
||||
],
|
||||
},
|
||||
{
|
||||
group: "My reading",
|
||||
fields: [
|
||||
F("status", "Reading status", "My reading", "fixed", ["is"], "is", false,
|
||||
["unread", "reading", "finished"]),
|
||||
F("progress_percent", "Progress percent", "My reading", "number", NUM_OPS, "at_least"),
|
||||
F("last_read", "Last read", "My reading", "days", DATE_OPS, "within_days"),
|
||||
F("my_rating", "My rating", "My reading", "fixed",
|
||||
["is", "at_least", "at_most", "unrated"], "at_least", false,
|
||||
["1", "2", "3", "4", "5", "6", "7", "8", "9", "10"]),
|
||||
F("review", "Has review", "My reading", "none", ["has", "missing"], "has"),
|
||||
F("notes", "Notes", "My reading", "none", ["has", "missing", "within_days"], "has"),
|
||||
F("highlights", "Highlights", "My reading", "none", ["has", "missing", "within_days"], "has"),
|
||||
F("note_text", "Note text", "My reading", "text", ["contains", "not_contains", "empty"], "contains", true),
|
||||
F("highlight_text", "Highlight text", "My reading", "text", ["contains", "not_contains", "empty"], "contains", true),
|
||||
F("time_spent", "Time spent (minutes)", "My reading", "number", ["between", "at_least", "at_most"], "at_least"),
|
||||
F("pages_read", "Pages read", "My reading", "number", ["between", "at_least", "at_most"], "at_least"),
|
||||
F("collection", "In collection", "My reading", "id",
|
||||
["in", "not_in", "in_any", "in_none"], "in", true),
|
||||
],
|
||||
},
|
||||
{
|
||||
group: "Library",
|
||||
fields: [
|
||||
F("library", "Library", "Library", "id", ["is", "is_not"], "is", true),
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
// fieldByKey / fieldGroupsById — lookup helpers.
|
||||
const FIELD_BY_KEY = new Map<string, FieldMeta>();
|
||||
for (const g of FIELD_GROUPS) {
|
||||
for (const f of g.fields) {
|
||||
FIELD_BY_KEY.set(f.key, f);
|
||||
}
|
||||
}
|
||||
// Grouped ops per field, precomputed in the field's op order.
|
||||
const OPS_BY_FIELD = new Map<string, OpMeta[]>();
|
||||
for (const [, f] of FIELD_BY_KEY) {
|
||||
OPS_BY_FIELD.set(
|
||||
f.key,
|
||||
f.ops.map((k) => OPS[k]).filter(Boolean),
|
||||
);
|
||||
}
|
||||
|
||||
// Flat-expressible fields: key → the flat wire param a `contains`/`is`
|
||||
// rule serializes to (the PINNED deep-link contract — semantics
|
||||
// byte-identical to today's params).
|
||||
const FLAT_PARAMS: Record<string, string> = {
|
||||
author: "author_filter",
|
||||
series: "series_filter",
|
||||
genre: "genre_filter",
|
||||
publisher: "publisher_filter",
|
||||
tags: "tags_filter",
|
||||
narrator: "narrator_filter",
|
||||
language: "language_filter",
|
||||
story_arc: "story_arc_filter",
|
||||
imprint: "imprint_filter",
|
||||
edition: "edition_filter",
|
||||
age_rating: "age_rating_filter",
|
||||
format_group: "format_group_filter",
|
||||
manga_type: "manga_type_filter",
|
||||
reading_direction: "reading_direction_filter",
|
||||
};
|
||||
|
||||
// ─── tree state types ────────────────────────────────────────────────
|
||||
|
||||
export interface FilterRuleState {
|
||||
field: string;
|
||||
op: string;
|
||||
values: string[];
|
||||
}
|
||||
|
||||
export interface FilterGroupState {
|
||||
mode: "all" | "any";
|
||||
children: (FilterRuleState | FilterGroupState)[];
|
||||
}
|
||||
|
||||
export const isRule = (n: FilterRuleState | FilterGroupState): n is FilterRuleState =>
|
||||
"field" in n;
|
||||
|
||||
// flatParamNames are the wire params the flat deep-link contract pins
|
||||
// (plus the two flat extras the builder owns).
|
||||
export const FLAT_WIRE_NAMES = [
|
||||
...Object.values(FLAT_PARAMS),
|
||||
"explicit_filter",
|
||||
"abridged_filter",
|
||||
"year_min",
|
||||
"year_max",
|
||||
"has_cover",
|
||||
];
|
||||
|
||||
// opIsFlatExhaustible — every op that maps 1:1 onto a flat param.
|
||||
function ruleToFlatParam(rule: FilterRuleState): Record<string, string> | null {
|
||||
const joined = rule.values.join(",");
|
||||
if (!joined) return null;
|
||||
const flat = FLAT_PARAMS[rule.field];
|
||||
if (flat && rule.op === "contains") return { [flat]: joined };
|
||||
if (flat && rule.op === "is" &&
|
||||
["age_rating", "format_group", "manga_type", "reading_direction"].includes(rule.field)) {
|
||||
return { [flat]: joined };
|
||||
}
|
||||
if (flat && rule.op === "is" &&
|
||||
["story_arc", "imprint", "edition"].includes(rule.field)) {
|
||||
return { [flat]: joined };
|
||||
}
|
||||
if (rule.field === "cover") {
|
||||
if (rule.op === "has") return { has_cover: "true" };
|
||||
if (rule.op === "missing") return { has_cover: "false" };
|
||||
}
|
||||
if (rule.field === "explicit" && rule.op === "is_true") return { explicit_filter: "true" };
|
||||
if (rule.field === "explicit" && rule.op === "is_false") return { explicit_filter: "false" };
|
||||
if (rule.field === "abridged" && rule.op === "is_true") return { abridged_filter: "true" };
|
||||
if (rule.field === "abridged" && rule.op === "is_false") return { abridged_filter: "false" };
|
||||
if (rule.field === "published_year" && rule.op === "at_least") return { year_min: joined };
|
||||
if (rule.field === "published_year" && rule.op === "at_most") return { year_max: joined };
|
||||
if (rule.field === "published_year" && rule.op === "between") {
|
||||
return { year_min: rule.values[0], year_max: rule.values[1] };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// isFlatable — the smart-serialization rule: a single ALL group of
|
||||
// rules, one rule per field, every rule flat-expressible.
|
||||
function isFlatable(tree: FilterGroupState): boolean {
|
||||
if (tree.mode !== "all") return false;
|
||||
const seen = new Set<string>();
|
||||
for (const child of tree.children) {
|
||||
if (!isRule(child)) return false;
|
||||
if (seen.has(child.field)) return false;
|
||||
seen.add(child.field);
|
||||
if (!ruleToFlatParam(child)) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// ─── row projection (flat rendering of the nested tree) ─────────────
|
||||
|
||||
export interface BuilderRow {
|
||||
key: string;
|
||||
path: number[];
|
||||
type: "rule" | "groupOpen" | "groupClose";
|
||||
depth: number;
|
||||
connective: "AND" | "OR";
|
||||
isFirst: boolean;
|
||||
rule?: FilterRuleState;
|
||||
group?: FilterGroupState;
|
||||
}
|
||||
|
||||
function projectRows(
|
||||
group: FilterGroupState,
|
||||
path: number[],
|
||||
depth: number,
|
||||
out: BuilderRow[],
|
||||
): void {
|
||||
group.children.forEach((child, idx) => {
|
||||
const childPath = [...path, idx];
|
||||
const key = childPath.join("-");
|
||||
if (!isRule(child)) {
|
||||
out.push({
|
||||
key,
|
||||
path: childPath,
|
||||
type: "groupOpen",
|
||||
depth,
|
||||
connective: group.mode === "all" ? "AND" : "OR",
|
||||
isFirst: idx === 0,
|
||||
group: child,
|
||||
});
|
||||
projectRows(child, childPath, depth + 1, out);
|
||||
out.push({
|
||||
key: key + "-close",
|
||||
path: childPath,
|
||||
type: "groupClose",
|
||||
depth,
|
||||
connective: group.mode === "all" ? "AND" : "OR",
|
||||
isFirst: idx === 0,
|
||||
group: child,
|
||||
});
|
||||
return;
|
||||
}
|
||||
out.push({
|
||||
key,
|
||||
path: childPath,
|
||||
type: "rule",
|
||||
depth,
|
||||
connective: group.mode === "all" ? "AND" : "OR",
|
||||
isFirst: idx === 0,
|
||||
rule: child,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// ─── the Alpine component slice ──────────────────────────────────────
|
||||
|
||||
interface Suggestion {
|
||||
value: string;
|
||||
count: number;
|
||||
display?: string;
|
||||
}
|
||||
|
||||
export function filterBuilderData() {
|
||||
return {
|
||||
// Rule tree (source of truth; hidden wire inputs are synced after
|
||||
// every mutation).
|
||||
filterTree: { mode: "all", children: [] } as FilterGroupState,
|
||||
matchCount: null as number | null,
|
||||
matchCountLoading: false,
|
||||
openMenu: "" as string, // "<rowKey>:field" | "<rowKey>:op" | "<rowKey>:value" | ""
|
||||
suggestions: {} as Record<string, Suggestion[]>,
|
||||
suggestionsOpen: "" as string, // rowKey whose value menu is open
|
||||
idLabels: {} as Record<string, string>, // id → display, from suggestion fetches
|
||||
_suggestTimer: null as number | null,
|
||||
_countTimer: null as number | null,
|
||||
|
||||
get fieldGroups() {
|
||||
return FIELD_GROUPS;
|
||||
},
|
||||
|
||||
get builderRows(): BuilderRow[] {
|
||||
const rows: BuilderRow[] = [];
|
||||
projectRows(this.filterTree as FilterGroupState, [], 0, rows);
|
||||
return rows;
|
||||
},
|
||||
|
||||
// ── lookups ─────────────────────────────────────────────────────
|
||||
fieldMeta(key: string): FieldMeta | undefined {
|
||||
return FIELD_BY_KEY.get(key);
|
||||
},
|
||||
fieldLabelOf(key: string): string {
|
||||
return FIELD_BY_KEY.get(key)?.label ?? "Field";
|
||||
},
|
||||
opsFor(fieldKey: string): OpMeta[] {
|
||||
return OPS_BY_FIELD.get(fieldKey) ?? [];
|
||||
},
|
||||
opMeta(opKey: string): OpMeta | undefined {
|
||||
return OPS[opKey];
|
||||
},
|
||||
opLabelOf(rule: FilterRuleState): string {
|
||||
const m = OPS[rule.op];
|
||||
return m ? m.label : rule.op;
|
||||
},
|
||||
isNegated(rule: FilterRuleState): boolean {
|
||||
return OPS[rule.op]?.negated ?? false;
|
||||
},
|
||||
valueArity(rule: FilterRuleState): OpMeta["arity"] {
|
||||
return OPS[rule.op]?.arity ?? "many";
|
||||
},
|
||||
hasValueCell(rule: FilterRuleState): boolean {
|
||||
const field = FIELD_BY_KEY.get(rule.field);
|
||||
if (!field) return false;
|
||||
return field.valueKind !== "none" && this.valueArity(rule) !== "none";
|
||||
},
|
||||
|
||||
// ── mutations (each ends in form sync + live count refresh) ────
|
||||
_touch(): void {
|
||||
this.syncFormInputs();
|
||||
this.scheduleMatchCount();
|
||||
},
|
||||
ensureReadyRule(): void {
|
||||
if (this.filterTree.children.length === 0) {
|
||||
this.filterTree.children.push({ field: "author", op: "contains", values: [] });
|
||||
this._touch();
|
||||
}
|
||||
},
|
||||
nodeAt(path: number[]): FilterRuleState | FilterGroupState | null {
|
||||
let node: FilterRuleState | FilterGroupState = this.filterTree;
|
||||
for (const idx of path) {
|
||||
if (!("children" in node)) return null;
|
||||
const next = (node as FilterGroupState).children[idx];
|
||||
if (!next) return null;
|
||||
node = next;
|
||||
}
|
||||
return node;
|
||||
},
|
||||
addRule(path: number[]): void {
|
||||
const parent = this.nodeAt(path);
|
||||
if (!parent || !("children" in parent)) return;
|
||||
(parent as FilterGroupState).children.push({ field: "author", op: "contains", values: [] });
|
||||
this._touch();
|
||||
},
|
||||
addGroup(path: number[]): void {
|
||||
const parent = this.nodeAt(path);
|
||||
if (!parent || !("children" in parent)) return;
|
||||
(parent as FilterGroupState).children.push({
|
||||
mode: "any",
|
||||
children: [{ field: "author", op: "contains", values: [] }],
|
||||
});
|
||||
this._touch();
|
||||
},
|
||||
removeNode(path: number[]): void {
|
||||
if (path.length === 0) return;
|
||||
const parentPath = path.slice(0, -1);
|
||||
const idx = path[path.length - 1];
|
||||
const parent = this.nodeAt(parentPath);
|
||||
if (!parent || !("children" in parent)) return;
|
||||
(parent as FilterGroupState).children.splice(idx, 1);
|
||||
if (this.openMenu.startsWith(path.join("-") + ":")) this.openMenu = "";
|
||||
this._touch();
|
||||
},
|
||||
setGroupMode(path: number[], mode: "all" | "any"): void {
|
||||
const node = this.nodeAt(path);
|
||||
if (!node || !("children" in node)) return;
|
||||
(node as FilterGroupState).mode = mode;
|
||||
this._touch();
|
||||
},
|
||||
setRuleField(path: number[], key: string): void {
|
||||
const node = this.nodeAt(path);
|
||||
if (!node || !isRule(node)) return;
|
||||
if (node.field === key) {
|
||||
this.openMenu = "";
|
||||
return;
|
||||
}
|
||||
// Field switch clears the value + resets the operator to the
|
||||
// kind's default (approved behavior).
|
||||
node.field = key;
|
||||
const meta = FIELD_BY_KEY.get(key);
|
||||
node.op = meta?.defaultOp ?? "contains";
|
||||
node.values = [];
|
||||
this.openMenu = "";
|
||||
delete this.suggestions[path.join("-")];
|
||||
this._touch();
|
||||
},
|
||||
setRuleOp(path: number[], opKey: string): void {
|
||||
const node = this.nodeAt(path);
|
||||
if (!node || !isRule(node)) return;
|
||||
node.op = opKey;
|
||||
if (this.valueArity(node) === "none") {
|
||||
node.values = [];
|
||||
} else if (this.valueArity(node) === "two") {
|
||||
while (node.values.length < 2) node.values.push("");
|
||||
} else if (node.values.length > 1) {
|
||||
// keep the typed values; multi stays valid for many-arity
|
||||
} else if (node.values.length === 0) {
|
||||
node.values = [""];
|
||||
}
|
||||
this.openMenu = "";
|
||||
this._touch();
|
||||
},
|
||||
setRuleValue(path: number[], idx: number, value: string): void {
|
||||
const node = this.nodeAt(path);
|
||||
if (!node || !isRule(node)) return;
|
||||
node.values[idx] = value;
|
||||
this._touch();
|
||||
if (idx === 0 && FIELD_BY_KEY.get(node.field)?.suggest) {
|
||||
this.scheduleSuggestions(path.join("-"), path, node);
|
||||
}
|
||||
},
|
||||
pickSuggestion(_rowKey: string, path: number[], s: Suggestion): void {
|
||||
const node = this.nodeAt(path);
|
||||
if (!node || !isRule(node)) return;
|
||||
const meta = FIELD_BY_KEY.get(node.field);
|
||||
this.idLabels[s.value] = s.display ?? s.value;
|
||||
if (meta?.valueKind === "id") {
|
||||
// id-valued: toggle the id in the comma list.
|
||||
const ids = node.values.join(",").split(",").filter(Boolean);
|
||||
const at = ids.indexOf(s.value);
|
||||
if (at >= 0) {
|
||||
ids.splice(at, 1);
|
||||
} else {
|
||||
ids.push(s.value);
|
||||
}
|
||||
node.values = [ids.join(",")];
|
||||
} else if (this.valueArity(node) === "two") {
|
||||
node.values[0] = s.value;
|
||||
} else {
|
||||
node.values = [s.value];
|
||||
}
|
||||
this.suggestionsOpen = "";
|
||||
this._touch();
|
||||
},
|
||||
clearBuilder(): void {
|
||||
this.filterTree = { mode: "all", children: [] };
|
||||
this.matchCount = null;
|
||||
this.suggestions = {};
|
||||
this.suggestionsOpen = "";
|
||||
this.openMenu = "";
|
||||
this._touch();
|
||||
},
|
||||
|
||||
// ── menus ───────────────────────────────────────────────────────
|
||||
toggleMenu(menuKey: string): void {
|
||||
this.openMenu = this.openMenu === menuKey ? "" : menuKey;
|
||||
if (this.openMenu.endsWith(":value")) {
|
||||
this.suggestionsOpen = this.openMenu.split(":")[0];
|
||||
}
|
||||
},
|
||||
|
||||
// ── suggestions (field-aware, under the OTHER rules) ────────────
|
||||
scheduleSuggestions(rowKey: string, path: number[], rule: FilterRuleState): void {
|
||||
if (this._suggestTimer !== null) window.clearTimeout(this._suggestTimer);
|
||||
this._suggestTimer = window.setTimeout(() => {
|
||||
void this.fetchSuggestions(rowKey, path, rule);
|
||||
}, 250);
|
||||
},
|
||||
async fetchSuggestions(rowKey: string, path: number[], rule: FilterRuleState): Promise<void> {
|
||||
const meta = FIELD_BY_KEY.get(rule.field);
|
||||
if (!meta || !meta.suggest) return;
|
||||
// Fixed-option fields never hit the server.
|
||||
if (meta.valueKind === "fixed") return;
|
||||
const typed = rule.values[0] ?? "";
|
||||
if (typed.trim().length < 2 && meta.valueKind !== "id") return;
|
||||
const token = localStorage.getItem("token");
|
||||
if (!token) return;
|
||||
// Symmetry: the tree sent EXCLUDES the rule being edited — the
|
||||
// menu is computed under every OTHER completed rule.
|
||||
const others = this.treeWithout(path);
|
||||
const params = new URLSearchParams();
|
||||
params.set(meta.valueKind === "id" ? rule.field : rule.field, typed.trim() || " ");
|
||||
if (others) params.set("qf", JSON.stringify(others));
|
||||
const currentLibraryId = (
|
||||
document.getElementById("library-select") as HTMLSelectElement
|
||||
)?.value;
|
||||
if (currentLibraryId) params.set("library_id", currentLibraryId);
|
||||
params.set("limit", "8");
|
||||
try {
|
||||
const response = await fetch(`/api/media-items/search?${params.toString()}`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
if (!response.ok) {
|
||||
console.warn(`suggestion fetch failed (${response.status})`);
|
||||
return;
|
||||
}
|
||||
const data = (await response.json()) as { results?: Suggestion[] };
|
||||
for (const s of data.results ?? []) {
|
||||
if (s.display) this.idLabels[s.value] = s.display;
|
||||
}
|
||||
this.suggestions[rowKey] = data.results ?? [];
|
||||
this.suggestionsOpen = rowKey;
|
||||
} catch (error) {
|
||||
console.warn("suggestion fetch failed", error);
|
||||
}
|
||||
},
|
||||
openValueMenu(rowKey: string, path: number[], rule: FilterRuleState): void {
|
||||
const meta = FIELD_BY_KEY.get(rule.field);
|
||||
if (meta?.valueKind === "fixed") {
|
||||
// Static options: no fetch.
|
||||
this.suggestions[rowKey] = (meta.options ?? []).map((v) => ({
|
||||
value: v,
|
||||
count: 0,
|
||||
}));
|
||||
this.suggestionsOpen = rowKey;
|
||||
return;
|
||||
}
|
||||
if (meta?.suggest) {
|
||||
void this.fetchSuggestions(rowKey, path, rule);
|
||||
return;
|
||||
}
|
||||
this.suggestionsOpen = rowKey;
|
||||
},
|
||||
suggestionShown(rowKey: string): boolean {
|
||||
return this.suggestionsOpen === rowKey && (this.suggestions[rowKey]?.length ?? 0) > 0;
|
||||
},
|
||||
|
||||
// ── live count ──────────────────────────────────────────────────
|
||||
scheduleMatchCount(): void {
|
||||
if (this._countTimer !== null) window.clearTimeout(this._countTimer);
|
||||
this._countTimer = window.setTimeout(() => {
|
||||
void this.fetchMatchCount();
|
||||
}, 350);
|
||||
},
|
||||
async fetchMatchCount(): Promise<void> {
|
||||
const token = localStorage.getItem("token");
|
||||
if (!token) return;
|
||||
if (this.filterTree.children.length === 0) {
|
||||
this.matchCount = null;
|
||||
return;
|
||||
}
|
||||
const params = new URLSearchParams();
|
||||
params.set("qf", JSON.stringify(this.filterTree));
|
||||
const currentLibraryId = (
|
||||
document.getElementById("library-select") as HTMLSelectElement
|
||||
)?.value;
|
||||
if (currentLibraryId) params.set("library_id", currentLibraryId);
|
||||
this.matchCountLoading = true;
|
||||
try {
|
||||
const response = await fetch(`/api/media-items/filter-count?${params.toString()}`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
if (!response.ok) {
|
||||
this.matchCount = null;
|
||||
return;
|
||||
}
|
||||
const data = (await response.json()) as { count?: number };
|
||||
this.matchCount = data.count ?? null;
|
||||
} catch {
|
||||
this.matchCount = null;
|
||||
} finally {
|
||||
this.matchCountLoading = false;
|
||||
}
|
||||
},
|
||||
|
||||
// ── serialization + form sync ───────────────────────────────────
|
||||
// treeWithout returns the tree with the node at `path` removed (the
|
||||
// "other rules" for a rule's own value menu).
|
||||
treeWithout(path: number[]): FilterGroupState | null {
|
||||
const clone: FilterGroupState = JSON.parse(JSON.stringify(this.filterTree));
|
||||
if (path.length === 0) return clone;
|
||||
const parentPath = path.slice(0, -1);
|
||||
let node: FilterGroupState = clone;
|
||||
for (const idx of parentPath) {
|
||||
const next = node.children[idx];
|
||||
if (!next || isRule(next)) return clone;
|
||||
node = next;
|
||||
}
|
||||
node.children.splice(path[path.length - 1], 1);
|
||||
return clone;
|
||||
},
|
||||
serializeFilters():
|
||||
| { mode: "flat"; params: Record<string, string> }
|
||||
| { mode: "qf"; qf: string } {
|
||||
if (this.filterTree.children.length === 0) {
|
||||
return { mode: "flat", params: {} };
|
||||
}
|
||||
if (isFlatable(this.filterTree as FilterGroupState)) {
|
||||
const params: Record<string, string> = {};
|
||||
for (const child of this.filterTree.children) {
|
||||
if (!isRule(child)) continue;
|
||||
Object.assign(params, ruleToFlatParam(child));
|
||||
}
|
||||
return { mode: "flat", params };
|
||||
}
|
||||
return { mode: "qf", qf: JSON.stringify(this.filterTree) };
|
||||
},
|
||||
syncFormInputs(): void {
|
||||
const form = document.getElementById("filter-form") as HTMLFormElement | null;
|
||||
if (!form) return;
|
||||
// Drop every previously managed wire input, then write the
|
||||
// current serialization.
|
||||
for (const name of [...FLAT_WIRE_NAMES, "qf"]) {
|
||||
form.querySelectorAll(`input[name="${name}"]`).forEach((el) => el.remove());
|
||||
}
|
||||
const s = this.serializeFilters();
|
||||
const add = (name: string, value: string) => {
|
||||
const input = document.createElement("input");
|
||||
input.type = "hidden";
|
||||
input.name = name;
|
||||
input.value = value;
|
||||
form.appendChild(input);
|
||||
};
|
||||
if (s.mode === "qf") {
|
||||
add("qf", s.qf);
|
||||
return;
|
||||
}
|
||||
const flatParams = s.params as Record<string, string>;
|
||||
for (const [name, value] of Object.entries(flatParams)) {
|
||||
add(name, value);
|
||||
}
|
||||
},
|
||||
|
||||
// ── hydration (URL deep links + saved filters) ──────────────────
|
||||
// hydrateFromForm reads the form's wire inputs into the tree: a qf
|
||||
// input wins (pinned precedence), else the flat params become one
|
||||
// rule each. Returns true when anything was applied.
|
||||
hydrateFromForm(): boolean {
|
||||
const form = document.getElementById("filter-form") as HTMLFormElement | null;
|
||||
if (!form) return false;
|
||||
const qfInput = form.querySelector('input[name="qf"]') as HTMLInputElement | null;
|
||||
if (qfInput && qfInput.value) {
|
||||
try {
|
||||
const tree = JSON.parse(qfInput.value) as FilterGroupState;
|
||||
if (tree && Array.isArray(tree.children)) {
|
||||
this.filterTree = tree;
|
||||
return true;
|
||||
}
|
||||
} catch {
|
||||
// malformed qf: fall through to flat mapping
|
||||
}
|
||||
}
|
||||
const rules: FilterRuleState[] = [];
|
||||
for (const [fieldKey, wire] of Object.entries(FLAT_PARAMS)) {
|
||||
const input = form.querySelector(`input[name="${wire}"]`) as HTMLInputElement | null;
|
||||
if (!input || !input.value) continue;
|
||||
const meta = FIELD_BY_KEY.get(fieldKey);
|
||||
const op = meta && meta.ops.includes("contains") ? "contains" : "is";
|
||||
rules.push({ field: fieldKey, op, values: [input.value] });
|
||||
}
|
||||
const cover = form.querySelector('input[name="has_cover"]') as HTMLInputElement | null;
|
||||
if (cover && cover.value) {
|
||||
rules.push({ field: "cover", op: cover.value === "true" ? "has" : "missing", values: [] });
|
||||
}
|
||||
const explicitIn = form.querySelector('input[name="explicit_filter"]') as HTMLInputElement | null;
|
||||
if (explicitIn && explicitIn.value) {
|
||||
rules.push({ field: "explicit", op: explicitIn.value === "true" ? "is_true" : "is_false", values: [] });
|
||||
}
|
||||
const abridgedIn = form.querySelector('input[name="abridged_filter"]') as HTMLInputElement | null;
|
||||
if (abridgedIn && abridgedIn.value) {
|
||||
rules.push({ field: "abridged", op: abridgedIn.value === "true" ? "is_true" : "is_false", values: [] });
|
||||
}
|
||||
const yearMin = form.querySelector('input[name="year_min"]') as HTMLInputElement | null;
|
||||
if (yearMin && yearMin.value) {
|
||||
rules.push({ field: "published_year", op: "at_least", values: [yearMin.value] });
|
||||
}
|
||||
const yearMax = form.querySelector('input[name="year_max"]') as HTMLInputElement | null;
|
||||
if (yearMax && yearMax.value) {
|
||||
rules.push({ field: "published_year", op: "at_most", values: [yearMax.value] });
|
||||
}
|
||||
if (rules.length === 0) return false;
|
||||
this.filterTree = { mode: "all", children: rules };
|
||||
return true;
|
||||
},
|
||||
|
||||
// chipLabelFor — one rule's chip text.
|
||||
chipLabelFor(rule: FilterRuleState): string {
|
||||
const fieldLabel = this.fieldLabelOf(rule.field);
|
||||
const opLabel = this.opLabelOf(rule);
|
||||
const meta = FIELD_BY_KEY.get(rule.field);
|
||||
let value = "";
|
||||
if (this.valueArity(rule) !== "none" && meta?.valueKind !== "none") {
|
||||
const names = rule.values
|
||||
.join(",")
|
||||
.split(",")
|
||||
.filter(Boolean)
|
||||
.map((v) => this.idLabels[v] ?? v);
|
||||
value = names.length > 0 ? ` ${names.join(", ")}` : "";
|
||||
if (meta?.valueKind === "days") value = ` ${rule.values[0]} days`;
|
||||
}
|
||||
return `${fieldLabel} ${opLabel}${value}`.trim();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// Component registration (main.ts imports this module for the side
|
||||
// effect, same pattern as bookshelf.ts).
|
||||
Alpine.data("filterBuilderExtra", filterBuilderData);
|
||||
@@ -8,6 +8,7 @@ import "./api-explorer";
|
||||
import "./api-explorer-docs";
|
||||
import "./bookPicker";
|
||||
import "./bookshelf";
|
||||
import "./filter_builder";
|
||||
import "./book-detail";
|
||||
import "./collection-rules";
|
||||
import "./collections";
|
||||
|
||||
+8
-6
@@ -176,12 +176,14 @@ const setupHTMXListeners = (): void => {
|
||||
});
|
||||
};
|
||||
|
||||
// Suggestion fetches — field-value autocomplete (author=, genre=, …)
|
||||
// on the search endpoint — are background hints for typed inputs. A
|
||||
// failed hint must never interrupt the user with a toast: it logs to
|
||||
// the console only. The grid's real search (q=/sort= params) is NOT a
|
||||
// suggestion fetch and keeps its toasts.
|
||||
const suggestionFieldRe = /[?&](author|genre|tags|narrator|series|language)=/;
|
||||
// Suggestion fetches — field-value autocomplete on the search endpoint
|
||||
// (author=, genre=, note_text=, collection=, … any filter-registry
|
||||
// field key) — are background hints for typed inputs. A failed hint
|
||||
// must never interrupt the user with a toast: it logs to the console
|
||||
// only. The grid's real search (q=/sort= params) is NOT a suggestion
|
||||
// fetch and keeps its toasts.
|
||||
const suggestionFieldRe =
|
||||
/[?&](author|narrator|contributor|publisher|imprint|edition|genre|tags|story_arc|age_rating|title|subtitle|language|format_group|mime_type|manga_type|reading_direction|series|note_text|highlight_text|collection|library|added_by)=/;
|
||||
function isSuggestionFetch(input: Parameters<typeof fetch>[0]): boolean {
|
||||
const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url;
|
||||
if (!url.includes("/api/media-items/search?")) return false;
|
||||
|
||||
@@ -1909,3 +1909,126 @@
|
||||
background: inherit;
|
||||
transform: translateX(-50%);
|
||||
}
|
||||
|
||||
/* ---------- Filter rule builder (filter drawer, Phase 9) ----------
|
||||
Templ owns the markup; Alpine owns the rule-tree state. Look only:
|
||||
rows indent by group depth, negated operators/chips run red. */
|
||||
.fb-connective {
|
||||
font-size: 0.65rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.05em;
|
||||
color: var(--text-secondary);
|
||||
min-width: 1.6rem;
|
||||
}
|
||||
.fb-picker {
|
||||
font-size: 0.8rem;
|
||||
padding: 0.3rem 0.6rem;
|
||||
border-radius: 0.5rem;
|
||||
border: 1px solid var(--border);
|
||||
background-color: var(--bg-primary);
|
||||
color: var(--text-primary);
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.fb-picker:hover {
|
||||
background-color: var(--surface-hover);
|
||||
}
|
||||
.fb-picker-active {
|
||||
border-color: color-mix(in srgb, var(--accent) 45%, var(--border));
|
||||
}
|
||||
.fb-picker-op {
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
.fb-negated-text {
|
||||
color: var(--status-danger);
|
||||
}
|
||||
.fb-value {
|
||||
width: 9rem;
|
||||
font-size: 0.8rem;
|
||||
padding: 0.3rem 0.5rem;
|
||||
}
|
||||
.fb-menu {
|
||||
position: absolute;
|
||||
z-index: 60;
|
||||
margin-top: 0.25rem;
|
||||
width: 15rem;
|
||||
max-height: 16rem;
|
||||
overflow-y: auto;
|
||||
border-radius: 0.5rem;
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.25);
|
||||
background-color: var(--bg-primary);
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
.fb-menu-value {
|
||||
width: 16rem;
|
||||
left: 0;
|
||||
}
|
||||
.fb-menu-group {
|
||||
font-size: 0.65rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: var(--text-secondary);
|
||||
padding: 0.5rem 0.75rem 0.15rem;
|
||||
}
|
||||
.fb-menu-item {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
text-align: left;
|
||||
font-size: 0.8rem;
|
||||
padding: 0.35rem 0.75rem;
|
||||
color: var(--text-primary);
|
||||
cursor: pointer;
|
||||
}
|
||||
.fb-menu-item:hover {
|
||||
background-color: var(--surface-hover);
|
||||
}
|
||||
.fb-menu-item-active {
|
||||
color: var(--accent);
|
||||
}
|
||||
.fb-mode-btn {
|
||||
font-size: 0.65rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.05em;
|
||||
padding: 0.15rem 0.5rem;
|
||||
border-radius: 9999px;
|
||||
border: 1px solid var(--border);
|
||||
background-color: var(--bg-primary);
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
}
|
||||
.fb-mode-btn.fb-mode-active {
|
||||
background-color: var(--accent-muted);
|
||||
border-color: color-mix(in srgb, var(--accent) 45%, var(--border));
|
||||
color: var(--accent);
|
||||
}
|
||||
.fb-mini-btn {
|
||||
font-size: 0.7rem;
|
||||
padding: 0.15rem 0.5rem;
|
||||
border-radius: 0.4rem;
|
||||
border: 1px dashed var(--border);
|
||||
background-color: transparent;
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.fb-mini-btn:hover {
|
||||
background-color: var(--surface-hover);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
.fb-mini-btn.fb-danger {
|
||||
border-style: solid;
|
||||
color: var(--status-danger);
|
||||
}
|
||||
.fb-group-end {
|
||||
border-left: 2px solid var(--border);
|
||||
height: 0.5rem;
|
||||
margin-top: -0.25rem;
|
||||
}
|
||||
.fb-nested {
|
||||
border-left: 2px solid var(--border);
|
||||
padding-left: 0.6rem;
|
||||
}
|
||||
@@ -1,2 +1,2 @@
|
||||
*,:after,:before{--tw-border-spacing-x:0;--tw-border-spacing-y:0;--tw-translate-x:0;--tw-translate-y:0;--tw-rotate:0;--tw-skew-x:0;--tw-skew-y:0;--tw-scale-x:1;--tw-scale-y:1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness:proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:rgba(59,130,246,.5);--tw-ring-offset-shadow:0 0 #0000;--tw-ring-shadow:0 0 #0000;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }::backdrop{--tw-border-spacing-x:0;--tw-border-spacing-y:0;--tw-translate-x:0;--tw-translate-y:0;--tw-rotate:0;--tw-skew-x:0;--tw-skew-y:0;--tw-scale-x:1;--tw-scale-y:1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness:proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:rgba(59,130,246,.5);--tw-ring-offset-shadow:0 0 #0000;--tw-ring-shadow:0 0 #0000;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }
|
||||
/*! tailwindcss v3.4.19 | MIT License | https://tailwindcss.com*/*,:after,:before{box-sizing:border-box;border:0 solid #e5e7eb}:after,:before{--tw-content:""}:host,html{line-height:1.5;-webkit-text-size-adjust:100%;-moz-tab-size:4;-o-tab-size:4;tab-size:4;font-family:ui-sans-serif,system-ui,sans-serif,Noto Sans SC,Noto Sans TC,Noto Sans JP,Noto Sans KR,PingFang SC,Microsoft YaHei,Hiragino Sans,Apple SD Gothic Neo,Apple Color Emoji,Segoe UI Emoji,Segoe UI Symbol,Noto Color Emoji;font-feature-settings:normal;font-variation-settings:normal;-webkit-tap-highlight-color:transparent}body{margin:0;line-height:inherit}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,pre,samp{font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,monospace;font-feature-settings:normal;font-variation-settings:normal;font-size:1em}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}button,input,optgroup,select,textarea{font-family:inherit;font-feature-settings:inherit;font-variation-settings:inherit;font-size:100%;font-weight:inherit;line-height:inherit;letter-spacing:inherit;color:inherit;margin:0;padding:0}button,select{text-transform:none}button,input:where([type=button]),input:where([type=reset]),input:where([type=submit]){-webkit-appearance:button;background-color:transparent;background-image:none}:-moz-focusring{outline:auto}:-moz-ui-invalid{box-shadow:none}progress{vertical-align:baseline}::-webkit-inner-spin-button,::-webkit-outer-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}summary{display:list-item}blockquote,dd,dl,figure,h1,h2,h3,h4,h5,h6,hr,p,pre{margin:0}fieldset{margin:0}fieldset,legend{padding:0}menu,ol,ul{list-style:none;margin:0;padding:0}dialog{padding:0}textarea{resize:vertical}input::-moz-placeholder,textarea::-moz-placeholder{opacity:1;color:#9ca3af}input::placeholder,textarea::placeholder{opacity:1;color:#9ca3af}[role=button],button{cursor:pointer}:disabled{cursor:default}audio,canvas,embed,iframe,img,object,svg,video{display:block;vertical-align:middle}img,video{max-width:100%;height:auto}[hidden]:where(:not([hidden=until-found])){display:none}input:where(:not([type])),input:where([type=date]),input:where([type=datetime-local]),input:where([type=email]),input:where([type=month]),input:where([type=number]),input:where([type=password]),input:where([type=search]),input:where([type=tel]),input:where([type=text]),input:where([type=time]),input:where([type=url]),input:where([type=week]),select,select:where([multiple]),textarea{-webkit-appearance:none;-moz-appearance:none;appearance:none;background-color:#fff;border-color:#6b7280;border-width:1px;border-radius:0;padding:.5rem .75rem;font-size:1rem;line-height:1.5rem;--tw-shadow:0 0 #0000}input:where(:not([type])):focus,input:where([type=date]):focus,input:where([type=datetime-local]):focus,input:where([type=email]):focus,input:where([type=month]):focus,input:where([type=number]):focus,input:where([type=password]):focus,input:where([type=search]):focus,input:where([type=tel]):focus,input:where([type=text]):focus,input:where([type=time]):focus,input:where([type=url]):focus,input:where([type=week]):focus,select:focus,select:where([multiple]):focus,textarea:focus{outline:2px solid transparent;outline-offset:2px;--tw-ring-inset:var(--tw-empty,/*!*/ /*!*/);--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:#2563eb;--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow);border-color:#2563eb}input::-moz-placeholder,textarea::-moz-placeholder{color:#6b7280;opacity:1}input::placeholder,textarea::placeholder{color:#6b7280;opacity:1}::-webkit-datetime-edit-fields-wrapper{padding:0}::-webkit-date-and-time-value{min-height:1.5em;text-align:inherit}::-webkit-datetime-edit{display:inline-flex}::-webkit-datetime-edit,::-webkit-datetime-edit-day-field,::-webkit-datetime-edit-hour-field,::-webkit-datetime-edit-meridiem-field,::-webkit-datetime-edit-millisecond-field,::-webkit-datetime-edit-minute-field,::-webkit-datetime-edit-month-field,::-webkit-datetime-edit-second-field,::-webkit-datetime-edit-year-field{padding-top:0;padding-bottom:0}select{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 20 20'%3E%3Cpath Line truncated
|
||||
/*! tailwindcss v3.4.19 | MIT License | https://tailwindcss.com*/*,:after,:before{box-sizing:border-box;border:0 solid #e5e7eb}:after,:before{--tw-content:""}:host,html{line-height:1.5;-webkit-text-size-adjust:100%;-moz-tab-size:4;-o-tab-size:4;tab-size:4;font-family:ui-sans-serif,system-ui,sans-serif,Noto Sans SC,Noto Sans TC,Noto Sans JP,Noto Sans KR,PingFang SC,Microsoft YaHei,Hiragino Sans,Apple SD Gothic Neo,Apple Color Emoji,Segoe UI Emoji,Segoe UI Symbol,Noto Color Emoji;font-feature-settings:normal;font-variation-settings:normal;-webkit-tap-highlight-color:transparent}body{margin:0;line-height:inherit}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,pre,samp{font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,monospace;font-feature-settings:normal;font-variation-settings:normal;font-size:1em}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}button,input,optgroup,select,textarea{font-family:inherit;font-feature-settings:inherit;font-variation-settings:inherit;font-size:100%;font-weight:inherit;line-height:inherit;letter-spacing:inherit;color:inherit;margin:0;padding:0}button,select{text-transform:none}button,input:where([type=button]),input:where([type=reset]),input:where([type=submit]){-webkit-appearance:button;background-color:transparent;background-image:none}:-moz-focusring{outline:auto}:-moz-ui-invalid{box-shadow:none}progress{vertical-align:baseline}::-webkit-inner-spin-button,::-webkit-outer-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}summary{display:list-item}blockquote,dd,dl,figure,h1,h2,h3,h4,h5,h6,hr,p,pre{margin:0}fieldset{margin:0}fieldset,legend{padding:0}menu,ol,ul{list-style:none;margin:0;padding:0}dialog{padding:0}textarea{resize:vertical}input::-moz-placeholder,textarea::-moz-placeholder{opacity:1;color:#9ca3af}input::placeholder,textarea::placeholder{opacity:1;color:#9ca3af}[role=button],button{cursor:pointer}:disabled{cursor:default}audio,canvas,embed,iframe,img,object,svg,video{display:block;vertical-align:middle}img,video{max-width:100%;height:auto}[hidden]:where(:not([hidden=until-found])){display:none}input:where(:not([type])),input:where([type=date]),input:where([type=datetime-local]),input:where([type=email]),input:where([type=month]),input:where([type=number]),input:where([type=password]),input:where([type=search]),input:where([type=tel]),input:where([type=text]),input:where([type=time]),input:where([type=url]),input:where([type=week]),select,select:where([multiple]),textarea{-webkit-appearance:none;-moz-appearance:none;appearance:none;background-color:#fff;border-color:#6b7280;border-width:1px;border-radius:0;padding:.5rem .75rem;font-size:1rem;line-height:1.5rem;--tw-shadow:0 0 #0000}input:where(:not([type])):focus,input:where([type=date]):focus,input:where([type=datetime-local]):focus,input:where([type=email]):focus,input:where([type=month]):focus,input:where([type=number]):focus,input:where([type=password]):focus,input:where([type=search]):focus,input:where([type=tel]):focus,input:where([type=text]):focus,input:where([type=time]):focus,input:where([type=url]):focus,input:where([type=week]):focus,select:focus,select:where([multiple]):focus,textarea:focus{outline:2px solid transparent;outline-offset:2px;--tw-ring-inset:var(--tw-empty,/*!*/ /*!*/);--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:#2563eb;--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow);border-color:#2563eb}input::-moz-placeholder,textarea::-moz-placeholder{color:#6b7280;opacity:1}input::placeholder,textarea::placeholder{color:#6b7280;opacity:1}::-webkit-datetime-edit-fields-wrapper{padding:0}::-webkit-date-and-time-value{min-height:1.5em;text-align:inherit}::-webkit-datetime-edit{display:inline-flex}::-webkit-datetime-edit,::-webkit-datetime-edit-day-field,::-webkit-datetime-edit-hour-field,::-webkit-datetime-edit-meridiem-field,::-webkit-datetime-edit-millisecond-field,::-webkit-datetime-edit-minute-field,::-webkit-datetime-edit-month-field,::-webkit-datetime-edit-second-field,::-webkit-datetime-edit-year-field{padding-top:0;padding-bottom:0}select{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 20 20'%3E%3Cpath Line truncated
|
||||
Reference in new issue
Block a user