browser / src /composables /useFilters.ts
lrigamai's picture
Group LoRAs by character, add table view and docked detail panel
45fb44a
Raw History Blame Contribute Delete
4.44 kB
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<SortKey, (a: Ranked, b: Ranked) => 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<BaseModel[]>, characters: Ref<Character[]>) {
const initial = readUrl();
const filters = reactive<Filters>(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) };
}