Spaces:
Running
Running
Download src/components/LoraTable.vue from lrigamai/browser: direct link, hf CLI and curl.
- Browser
- Download file 7.48 kB
-
https://huggingface.co/spaces/lrigamai/browser/resolve/main/src/components/LoraTable.vue
- Command line
-
hf download hf://spaces/lrigamai/browser/src/components/LoraTable.vue
-
curl -L -o LoraTable.vue https://huggingface.co/spaces/lrigamai/browser/resolve/main/src/components/LoraTable.vue
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> | |