browser / src /components /LoraTable.vue
lrigamai's picture
Group LoRAs by character, add table view and docked detail panel
45fb44a
Raw History Blame Contribute Delete
7.48 kB
<script setup lang="ts">
import { computed } from "vue";
import type { Character, SortKey } from "../types";
import { formatBytes, formatDate, modelLabel } from "../lib/format";
import { previewsFor, variantFor } from "../composables/useFilters";
import Icon from "./Icon.vue";
import LoraPreview from "./LoraPreview.vue";
/**
* Compact ledger view of the same results as the grid: one hairline row per character, with a
* column per base model. Column heads double as sort controls, bound to the same sort as the dock.
*/
const props = defineProps<{ items: Character[]; models: string[]; filter: string[]; selected?: string }>();
defineEmits<{ open: [key: string]; clear: [] }>();
const sort = defineModel<SortKey>("sort", { required: true });
// Only the selected models get columns (all of them when nothing is selected); with exactly one
// selected, its column is just "Size".
const columns = computed(() => (props.filter.length ? props.models.filter((m) => props.filter.includes(m)) : props.models));
const single = computed(() => props.filter.length === 1);
const on = (c: Character, m: string) => c.variants.find((v) => v.model === m);
const shown = (c: Character) => variantFor(c, props.filter);
const previews = (c: Character) => previewsFor(c, props.filter);
const ariaSort = (key: SortKey) => (sort.value === key ? (key === "name" ? "ascending" : "descending") : undefined);
</script>
<template>
<div v-if="items.length" class="table" role="table" aria-label="Characters" :style="{ '--models': columns.length }">
<div class="row head" role="row">
<span role="columnheader" class="c-thumb" aria-hidden="true" />
<button role="columnheader" class="c-name" type="button" :aria-sort="ariaSort('name')" :class="{ on: sort === 'name' }" @click="sort = 'name'">
Character
</button>
<span v-for="m in columns" :key="m" role="columnheader" class="c-model">
{{ single ? "Size" : modelLabel(m) }}
</span>
<button role="columnheader" class="c-date" type="button" :aria-sort="ariaSort('updated')" :class="{ on: sort === 'updated' }" @click="sort = 'updated'">
Updated
</button>
<span role="columnheader" class="c-go" aria-hidden="true" />
</div>
<button
v-for="(c, i) in items"
:key="c.key"
class="row item"
:class="{ selected: c.key === selected }"
:aria-current="c.key === selected || undefined"
role="row"
type="button"
:style="{ '--i': i }"
:aria-label="`Open ${c.name}`"
@click="$emit('open', c.key)"
>
<span role="cell" class="c-thumb">
<LoraPreview :preview="previews(c)[0]" :name="c.name" :seed-key="c.key" />
</span>
<span role="cell" class="c-name">
<span class="name">{{ c.name }}</span>
<span class="sub">{{ c.variants.length }} of {{ models.length }} models</span>
<!-- narrow screens fold models + date under the name -->
<span class="fold">{{ c.variants.map((v) => modelLabel(v.model)).join(" · ") }} — {{ formatDate(shown(c).updated) }}</span>
</span>
<span v-for="m in columns" :key="m" role="cell" class="c-model">
<template v-if="on(c, m)">
<Icon v-if="!single" name="check" class="tick" /> {{ formatBytes(on(c, m)!.size) }}
</template>
<span v-else class="none">—</span>
</span>
<span role="cell" class="c-date">{{ formatDate(shown(c).updated) }}</span>
<span role="cell" class="c-go"><Icon name="chevron" /></span>
</button>
</div>
<div v-else class="empty">
<span class="eyebrow">No match</span>
<p>Nobody by that name on these models.</p>
<button class="btn" type="button" @click="$emit('clear')">Clear search</button>
</div>
</template>
<style scoped>
.row {
display: grid;
grid-template-columns: 44px minmax(0, 1fr) repeat(var(--models), 100px) 116px 24px;
align-items: center;
gap: 20px;
width: 100%;
padding: 0 12px 0 10px;
font-family: var(--font-mono);
font-size: 12px;
letter-spacing: 0.04em;
text-transform: uppercase;
font-variant-numeric: tabular-nums;
}
/* Column heads: sticky under the page header, mono labels, sortable ones light up. */
.head {
position: sticky;
top: var(--header-h);
z-index: 2;
height: 36px;
border-bottom: 1px solid var(--line-strong);
background: var(--bg);
font-size: 10px;
letter-spacing: 0.1em;
color: var(--text-3);
}
.head button {
justify-self: start;
padding: 0;
border: 0;
background: none;
font: inherit;
letter-spacing: inherit;
text-transform: inherit;
color: inherit;
cursor: pointer;
transition: color 0.15s;
}
.head button:hover {
color: var(--text);
}
.head button.on {
color: var(--accent);
}
.head button.on::after {
content: " \2193";
}
.head .c-name.on::after {
content: " A\2192Z";
}
.item {
position: relative;
min-height: 72px;
border: 0;
border-bottom: 1px solid var(--line);
background: none;
color: var(--text-2);
text-align: left;
cursor: pointer;
transition: background 0.15s;
animation: rise 0.4s var(--ease) both;
animation-delay: calc(min(var(--i), 20) * 20ms);
}
/* accent tab on the leading edge */
.item::before {
content: "";
position: absolute;
left: 0;
top: -1px;
bottom: -1px;
width: 2px;
background: var(--accent);
transform: scaleY(0);
transition: transform 0.2s var(--ease);
}
.item:hover {
background: var(--surface);
}
.item:hover::before,
.item:focus-visible::before,
.item.selected::before {
transform: scaleY(1);
}
.item.selected {
background: var(--surface);
}
.item.selected .name {
color: var(--accent);
}
.item:focus-visible {
outline-offset: -1px;
}
@keyframes rise {
from {
opacity: 0;
transform: translateY(6px);
}
}
@media (prefers-reduced-motion: reduce) {
.item {
animation: none;
}
}
.c-thumb {
display: block;
width: 44px;
}
.c-thumb :deep(.portrait) {
border: 1px solid var(--line);
}
.c-thumb :deep(.mono-gram) {
font-size: 20px;
}
.item:hover .c-thumb :deep(.portrait) {
border-color: var(--line-strong);
}
.c-name {
display: grid;
gap: 3px;
min-width: 0;
}
.name {
overflow: hidden;
font-family: var(--font-display);
font-size: 24px;
font-weight: 800;
line-height: 1;
letter-spacing: 0.01em;
color: var(--text);
text-overflow: ellipsis;
white-space: nowrap;
transition: color 0.15s;
}
.item:hover .name {
color: var(--accent);
}
.sub,
.fold {
overflow: hidden;
font-size: 11px;
text-transform: none;
color: var(--text-3);
text-overflow: ellipsis;
white-space: nowrap;
}
.fold {
display: none;
text-transform: uppercase;
}
.c-model {
display: flex;
align-items: center;
gap: 6px;
white-space: nowrap;
}
.tick {
color: var(--accent);
}
.none {
color: var(--line-strong);
}
.c-date {
white-space: nowrap;
}
.c-go {
display: grid;
place-items: center;
color: var(--text-3);
transition: color 0.15s, transform 0.2s var(--ease);
}
.item:hover .c-go {
color: var(--accent);
transform: translateX(3px);
}
/* Narrower: tighter model columns, then fold models/date under the name. */
@media (max-width: 900px) {
.row {
grid-template-columns: 44px minmax(0, 1fr) repeat(var(--models), 88px) 100px 20px;
gap: 14px;
}
}
@media (max-width: 640px) {
.row {
grid-template-columns: 40px minmax(0, 1fr) 20px;
gap: 14px;
padding: 0 6px 0 8px;
}
.c-thumb {
width: 40px;
}
.c-model,
.c-date,
.sub {
display: none;
}
.fold {
display: block;
}
.name {
font-size: 21px;
}
}
.empty {
display: grid;
justify-items: center;
gap: 8px;
padding: 72px 16px;
border: 1px dashed var(--line-strong);
text-align: center;
}
.empty p {
margin: 0 0 8px;
color: var(--text-2);
}
</style>