File size: 4,443 Bytes
830f2f5
45fb44a
830f2f5
 
 
45fb44a
 
 
 
 
 
 
 
 
 
 
 
 
830f2f5
 
 
 
45fb44a
830f2f5
45fb44a
 
 
 
 
 
830f2f5
 
 
 
45fb44a
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
830f2f5
 
45fb44a
 
830f2f5
 
 
 
45fb44a
830f2f5
45fb44a
 
 
830f2f5
 
45fb44a
 
 
 
 
 
 
830f2f5
 
45fb44a
 
 
 
 
 
 
 
830f2f5
 
45fb44a
 
 
 
830f2f5
 
 
 
 
45fb44a
830f2f5
 
45fb44a
830f2f5
 
 
 
 
 
 
45fb44a
 
 
 
 
 
 
 
 
 
 
 
830f2f5
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
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) };
}