import { computed, reactive, ref, watch, type Ref } from "vue"; import type { BaseModel, Character, Filters, Lora, Preview, SortKey, ViewKey } from "../types"; const SORTS: SortKey[] = ["updated", "name", "size"]; const DEFAULT_SORT: SortKey = "updated"; const VIEWS: ViewKey[] = ["grid", "table"]; const DEFAULT_VIEW: ViewKey = "grid"; // The chosen view is also remembered per browser, so a plain link opens in your last layout. const VIEW_KEY = "lora-browser.view"; function storedView(): ViewKey | null { try { const v = localStorage.getItem(VIEW_KEY) as ViewKey; return VIEWS.includes(v) ? v : null; } catch { return null; } } function readUrl() { const p = new URLSearchParams(location.search); const sort = p.get("sort") as SortKey; const view = p.get("view") as ViewKey; return { filters: { models: (p.get("model") ?? "").split(",").filter(Boolean), q: p.get("q") ?? "", sort: SORTS.includes(sort) ? sort : DEFAULT_SORT, view: VIEWS.includes(view) ? view : (storedView() ?? DEFAULT_VIEW), } as Filters, open: p.get("lora") ?? "", }; } /** * The variant that represents a character in the current view: the most recently updated one * among the filtered models (among all of them when nothing is filtered). */ export function variantFor(c: Character, filter: string[]): Lora { const pool = filter.length ? c.variants.filter((v) => filter.includes(v.model)) : []; return (pool.length ? pool : c.variants).reduce((a, b) => ((b.updated ?? "") > (a.updated ?? "") ? b : a)); } /** Images to show for a character: its shown variant's, else another model's rather than none. */ export function previewsFor(c: Character, filter: string[]): Preview[] { const v = variantFor(c, filter); return v.previews.length ? v.previews : (c.variants.find((x) => x.previews.length)?.previews ?? []); } type Ranked = { c: Character; v: Lora }; const compare: Record number> = { name: (a, b) => a.c.name.localeCompare(b.c.name), updated: (a, b) => (b.v.updated ?? "").localeCompare(a.v.updated ?? "") || a.c.name.localeCompare(b.c.name), size: (a, b) => b.v.size - a.v.size, }; /** Model / search / sort / view state, mirrored into the query string so any view is a shareable link. */ export function useFilters(models: Ref, characters: Ref) { const initial = readUrl(); const filters = reactive(initial.filters); const openId = ref(initial.open); // Drop models the URL names that don't exist (any more); keep the rest in base-model order. watch(models, (list) => { if (!list.length) return; const known = list.map((m) => m.id).filter((id) => filters.models.includes(id)); if (known.join() !== filters.models.join()) filters.models = known; }); /** Characters on any of the selected models (all of them when nothing is selected). */ const inModel = computed(() => filters.models.length ? characters.value.filter((c) => c.variants.some((v) => filters.models.includes(v.model))) : characters.value, ); const results = computed(() => { const terms = filters.q.toLowerCase().split(/\s+/).filter(Boolean); return inModel.value .filter((c) => { const text = `${c.name} ${c.variants.map((v) => v.file).join(" ")}`.toLowerCase(); return terms.every((t) => text.includes(t)); }) .map((c) => ({ c, v: variantFor(c, filters.models) })) .sort(compare[filters.sort]) .map((r) => r.c); }); // `lora` holds a character key; older links held a file path, which still resolves. const openCharacter = computed( () => characters.value.find((c) => c.key === openId.value || c.variants.some((v) => v.id === openId.value)) ?? null, ); watch( [filters, openId], () => { const p = new URLSearchParams(); if (filters.models.length) p.set("model", filters.models.join(",")); if (filters.q) p.set("q", filters.q); if (filters.sort !== DEFAULT_SORT) p.set("sort", filters.sort); if (filters.view !== DEFAULT_VIEW) p.set("view", filters.view); if (openId.value) p.set("lora", openId.value); const qs = p.toString(); history.replaceState(null, "", qs ? `?${qs}` : location.pathname); }, { deep: true }, ); watch( () => filters.view, (v) => { try { localStorage.setItem(VIEW_KEY, v); } catch { // storage blocked: the URL still carries it } }, ); return { filters, openId, openCharacter, results, total: computed(() => inModel.value.length) }; }