hlassiege/panopliepublic Fork
⑂ 9e4bfeb
Commits
⬇ Clone ▾
git clone https://git.rickub.com/hlassiege/panoplie.git
git clone ssh://git@rickub.com/hlassiege/panoplie.git

Host key fingerprint (ed25519): SHA256:iycHnxEyq0Q7uyVpB7JlznP0G7JrTPXLYRcAU5CSLhc — verify it before your first connect.

Download ZIP

feat(ui): compact preset cards with per-type counts and a visible New preset tile

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
hlassiege committed 2026-10-23T17:29:08+02:00 Browse files
9e4bfeb parent: 1d169d5
modified packages/ui/app/pages/catalog/presets/index.vue +41 -18
@@ -6,13 +6,24 @@ const { data, loading, error, refresh, exists, presets, presetUsedBy } = useCata
66 onMounted(refresh)
77
88 const sorted = computed(() => [...presets.value].sort((a, b) => a.name.localeCompare(b.name)))
9-const MAX_CHIPS = 12
9+const MAX_CHIPS = 6
1010
1111 function chips(p: Preset) {
1212 return CAT_ORDER.flatMap(t => (p.items[t] ?? []).map(name => ({ type: t as CatalogItemType, name })))
1313 }
14+/** Item count per type, in display order, e.g. "35 skills · 2 subagents". */
15+function counts(p: Preset) {
16+ return CAT_ORDER
17+ .map(t => ({ type: t, n: p.items[t]?.length ?? 0 }))
18+ .filter(c => c.n)
19+ .map((c) => {
20+ const word = c.n > 1 ? CAT_META[c.type].label : CAT_META[c.type].singular
21+ return { ...c, label: `${c.n} ${word.startsWith('MCP') ? word : word.toLowerCase()}` }
22+ })
23+}
24+const usedCount = (name: string) => presetUsedBy(name).length
1425 const used = (name: string) => {
15- const n = presetUsedBy(name).length
26+ const n = usedCount(name)
1627 return n ? `Enabled in ${n} project${n > 1 ? 's' : ''}` : 'Not enabled anywhere yet'
1728 }
1829 </script>
@@ -22,7 +33,7 @@ const used = (name: string) => {
2233 <CatalogHeader tab="presets" />
2334
2435 <div v-if="loading && !data" class="grid gap-3 [grid-template-columns:repeat(auto-fill,minmax(300px,1fr))]">
25- <USkeleton v-for="i in 4" :key="i" class="h-40 rounded-[10px]" />
36+ <USkeleton v-for="i in 4" :key="i" class="h-32 rounded-[10px]" />
2637 </div>
2738 <SkNote v-else-if="error && !data" tone="accent">
2839 <p class="m-0 font-medium">Could not load the catalog</p>
@@ -34,9 +45,11 @@ const used = (name: string) => {
3445 <div v-else-if="data" class="grid gap-3 [grid-template-columns:repeat(auto-fill,minmax(300px,1fr))]">
3546 <NuxtLink
3647 to="/catalog/presets/new"
37- class="flex min-h-[150px] flex-col items-center justify-center gap-1 rounded-[10px] border border-dashed border-sk-line p-[18px] text-sk-muted transition-colors hover:border-sk-faint hover:text-sk-text"
48+ class="group flex min-h-[120px] flex-col items-center justify-center gap-2 rounded-[10px] border border-dashed border-sk-faint/50 bg-sk-panel/60 p-4 text-sk-muted transition-colors hover:border-sk-faint hover:bg-sk-panel hover:text-sk-text"
3849 >
39- <span class="text-xl leading-none" aria-hidden="true">+</span>
50+ <span class="grid size-8 place-items-center rounded-full bg-sk-sel text-sk-text transition-colors group-hover:bg-sk-ink group-hover:text-sk-onink">
51+ <UIcon name="i-lucide-plus" class="size-4" />
52+ </span>
4053 <span class="font-medium">New preset</span>
4154 </NuxtLink>
4255
@@ -44,22 +57,32 @@ const used = (name: string) => {
4457 v-for="p in sorted"
4558 :key="p.name"
4659 :to="`/catalog/presets/${encodeURIComponent(p.name)}`"
47- class="flex flex-col gap-3 rounded-[10px] bg-sk-panel p-[18px] text-sk-text shadow-sk-ring transition-shadow hover:shadow-sk-ring-hover"
60+ class="flex flex-col rounded-[10px] bg-sk-panel text-sk-text shadow-sk-ring transition-shadow hover:shadow-sk-ring-hover"
4861 >
49- <div class="flex flex-col gap-[3px]">
50- <span class="text-sm font-semibold">{{ p.name }}</span>
51- <span v-if="p.description" class="text-pretty text-sk-muted">{{ p.description }}</span>
62+ <div class="flex flex-col gap-2.5 p-4">
63+ <div class="flex flex-col gap-[3px]">
64+ <span class="text-sm font-semibold">{{ p.name }}</span>
65+ <span v-if="p.description" class="line-clamp-2 text-pretty text-sk-muted">{{ p.description }}</span>
66+ </div>
67+ <div v-if="counts(p).length || p.includes.length" class="flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-sk-muted">
68+ <span v-for="c in counts(p)" :key="c.type" class="inline-flex items-center gap-1">
69+ <UIcon :name="CAT_META[c.type].icon" class="size-3.5 text-sk-faint" />{{ c.label }}
70+ </span>
71+ <span v-if="p.includes.length" class="inline-flex items-center gap-1">
72+ <UIcon name="i-lucide-layers" class="size-3.5 text-sk-faint" />
73+ <span>Includes <template v-for="(inc, i) in p.includes" :key="inc"><span class="font-medium text-sk-text">{{ inc }}</span><template v-if="i < p.includes.length - 1">, </template></template></span>
74+ </span>
75+ </div>
76+ <div class="flex flex-wrap gap-1">
77+ <SkItemChip v-for="c in chips(p).slice(0, MAX_CHIPS)" :key="`${c.type}/${c.name}`" :name="c.name" />
78+ <span v-if="chips(p).length > MAX_CHIPS" class="px-1 py-[3px] text-xs text-sk-faint">+{{ chips(p).length - MAX_CHIPS }} more</span>
79+ <span v-if="!chips(p).length" class="text-xs text-sk-faint">{{ p.includes.length ? 'Only the included presets' : 'No items yet' }}</span>
80+ </div>
5281 </div>
53- <span v-if="p.includes.length" class="text-xs text-sk-muted">
54- Includes preset
55- <template v-for="(inc, i) in p.includes" :key="inc"><span class="font-medium text-sk-text">{{ inc }}</span><template v-if="i < p.includes.length - 1">, </template></template>
56- </span>
57- <div class="flex flex-wrap gap-1">
58- <SkItemChip v-for="c in chips(p).slice(0, MAX_CHIPS)" :key="`${c.type}/${c.name}`" :name="c.name" :type="c.type" />
59- <span v-if="chips(p).length > MAX_CHIPS" class="px-1 py-[3px] text-xs text-sk-faint">+{{ chips(p).length - MAX_CHIPS }} more</span>
60- <span v-if="!chips(p).length" class="text-xs text-sk-faint">{{ p.includes.length ? 'Only the included presets' : 'No items yet' }}</span>
82+ <div class="mt-auto flex items-center gap-1.5 border-t border-sk-line2 px-4 py-2.5 text-xs" :class="usedCount(p.name) ? 'text-sk-muted' : 'text-sk-faint'">
83+ <span class="size-1.5 rounded-full" :class="usedCount(p.name) ? 'bg-sk-ok' : 'bg-sk-line'" aria-hidden="true" />
84+ {{ used(p.name) }}
6185 </div>
62- <span class="mt-auto text-xs text-sk-faint">{{ used(p.name) }}</span>
6386 </NuxtLink>
6487 </div>
6588 </SkPage>
@@ -6,13 +6,24 @@ const { data, loading, error, refresh, exists, presets, presetUsedBy } = useCata
6 onMounted(refresh)6 onMounted(refresh)
7 7
8 const sorted = computed(() => [...presets.value].sort((a, b) => a.name.localeCompare(b.name)))8 const sorted = computed(() => [...presets.value].sort((a, b) => a.name.localeCompare(b.name)))
9-const MAX_CHIPS = 129+const MAX_CHIPS = 6
10 10
11 function chips(p: Preset) {11 function chips(p: Preset) {
12 return CAT_ORDER.flatMap(t => (p.items[t] ?? []).map(name => ({ type: t as CatalogItemType, name })))12 return CAT_ORDER.flatMap(t => (p.items[t] ?? []).map(name => ({ type: t as CatalogItemType, name })))
13 }13 }
14+/** Item count per type, in display order, e.g. "35 skills · 2 subagents". */
15+function counts(p: Preset) {
16+ return CAT_ORDER
17+ .map(t => ({ type: t, n: p.items[t]?.length ?? 0 }))
18+ .filter(c => c.n)
19+ .map((c) => {
20+ const word = c.n > 1 ? CAT_META[c.type].label : CAT_META[c.type].singular
21+ return { ...c, label: `${c.n} ${word.startsWith('MCP') ? word : word.toLowerCase()}` }
22+ })
23+}
24+const usedCount = (name: string) => presetUsedBy(name).length
14 const used = (name: string) => {25 const used = (name: string) => {
15- const n = presetUsedBy(name).length26+ const n = usedCount(name)
16 return n ? `Enabled in ${n} project${n > 1 ? 's' : ''}` : 'Not enabled anywhere yet'27 return n ? `Enabled in ${n} project${n > 1 ? 's' : ''}` : 'Not enabled anywhere yet'
17 }28 }
18 </script>29 </script>
@@ -22,7 +33,7 @@ const used = (name: string) => {
22 <CatalogHeader tab="presets" />33 <CatalogHeader tab="presets" />
23 34
24 <div v-if="loading && !data" class="grid gap-3 [grid-template-columns:repeat(auto-fill,minmax(300px,1fr))]">35 <div v-if="loading && !data" class="grid gap-3 [grid-template-columns:repeat(auto-fill,minmax(300px,1fr))]">
25- <USkeleton v-for="i in 4" :key="i" class="h-40 rounded-[10px]" />36+ <USkeleton v-for="i in 4" :key="i" class="h-32 rounded-[10px]" />
26 </div>37 </div>
27 <SkNote v-else-if="error && !data" tone="accent">38 <SkNote v-else-if="error && !data" tone="accent">
28 <p class="m-0 font-medium">Could not load the catalog</p>39 <p class="m-0 font-medium">Could not load the catalog</p>
@@ -34,9 +45,11 @@ const used = (name: string) => {
34 <div v-else-if="data" class="grid gap-3 [grid-template-columns:repeat(auto-fill,minmax(300px,1fr))]">45 <div v-else-if="data" class="grid gap-3 [grid-template-columns:repeat(auto-fill,minmax(300px,1fr))]">
35 <NuxtLink46 <NuxtLink
36 to="/catalog/presets/new"47 to="/catalog/presets/new"
37- class="flex min-h-[150px] flex-col items-center justify-center gap-1 rounded-[10px] border border-dashed border-sk-line p-[18px] text-sk-muted transition-colors hover:border-sk-faint hover:text-sk-text"48+ class="group flex min-h-[120px] flex-col items-center justify-center gap-2 rounded-[10px] border border-dashed border-sk-faint/50 bg-sk-panel/60 p-4 text-sk-muted transition-colors hover:border-sk-faint hover:bg-sk-panel hover:text-sk-text"
38 >49 >
39- <span class="text-xl leading-none" aria-hidden="true">+</span>50+ <span class="grid size-8 place-items-center rounded-full bg-sk-sel text-sk-text transition-colors group-hover:bg-sk-ink group-hover:text-sk-onink">
51+ <UIcon name="i-lucide-plus" class="size-4" />
52+ </span>
40 <span class="font-medium">New preset</span>53 <span class="font-medium">New preset</span>
41 </NuxtLink>54 </NuxtLink>
42 55
@@ -44,22 +57,32 @@ const used = (name: string) => {
44 v-for="p in sorted"57 v-for="p in sorted"
45 :key="p.name"58 :key="p.name"
46 :to="`/catalog/presets/${encodeURIComponent(p.name)}`"59 :to="`/catalog/presets/${encodeURIComponent(p.name)}`"
47- class="flex flex-col gap-3 rounded-[10px] bg-sk-panel p-[18px] text-sk-text shadow-sk-ring transition-shadow hover:shadow-sk-ring-hover"60+ class="flex flex-col rounded-[10px] bg-sk-panel text-sk-text shadow-sk-ring transition-shadow hover:shadow-sk-ring-hover"
48 >61 >
49- <div class="flex flex-col gap-[3px]">62+ <div class="flex flex-col gap-2.5 p-4">
50- <span class="text-sm font-semibold">{{ p.name }}</span>63+ <div class="flex flex-col gap-[3px]">
51- <span v-if="p.description" class="text-pretty text-sk-muted">{{ p.description }}</span>64+ <span class="text-sm font-semibold">{{ p.name }}</span>
65+ <span v-if="p.description" class="line-clamp-2 text-pretty text-sk-muted">{{ p.description }}</span>
66+ </div>
67+ <div v-if="counts(p).length || p.includes.length" class="flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-sk-muted">
68+ <span v-for="c in counts(p)" :key="c.type" class="inline-flex items-center gap-1">
69+ <UIcon :name="CAT_META[c.type].icon" class="size-3.5 text-sk-faint" />{{ c.label }}
70+ </span>
71+ <span v-if="p.includes.length" class="inline-flex items-center gap-1">
72+ <UIcon name="i-lucide-layers" class="size-3.5 text-sk-faint" />
73+ <span>Includes <template v-for="(inc, i) in p.includes" :key="inc"><span class="font-medium text-sk-text">{{ inc }}</span><template v-if="i < p.includes.length - 1">, </template></template></span>
74+ </span>
75+ </div>
76+ <div class="flex flex-wrap gap-1">
77+ <SkItemChip v-for="c in chips(p).slice(0, MAX_CHIPS)" :key="`${c.type}/${c.name}`" :name="c.name" />
78+ <span v-if="chips(p).length > MAX_CHIPS" class="px-1 py-[3px] text-xs text-sk-faint">+{{ chips(p).length - MAX_CHIPS }} more</span>
79+ <span v-if="!chips(p).length" class="text-xs text-sk-faint">{{ p.includes.length ? 'Only the included presets' : 'No items yet' }}</span>
80+ </div>
52 </div>81 </div>
53- <span v-if="p.includes.length" class="text-xs text-sk-muted">82+ <div class="mt-auto flex items-center gap-1.5 border-t border-sk-line2 px-4 py-2.5 text-xs" :class="usedCount(p.name) ? 'text-sk-muted' : 'text-sk-faint'">
54- Includes preset83+ <span class="size-1.5 rounded-full" :class="usedCount(p.name) ? 'bg-sk-ok' : 'bg-sk-line'" aria-hidden="true" />
55- <template v-for="(inc, i) in p.includes" :key="inc"><span class="font-medium text-sk-text">{{ inc }}</span><template v-if="i < p.includes.length - 1">, </template></template>84+ {{ used(p.name) }}
56- </span>
57- <div class="flex flex-wrap gap-1">
58- <SkItemChip v-for="c in chips(p).slice(0, MAX_CHIPS)" :key="`${c.type}/${c.name}`" :name="c.name" :type="c.type" />
59- <span v-if="chips(p).length > MAX_CHIPS" class="px-1 py-[3px] text-xs text-sk-faint">+{{ chips(p).length - MAX_CHIPS }} more</span>
60- <span v-if="!chips(p).length" class="text-xs text-sk-faint">{{ p.includes.length ? 'Only the included presets' : 'No items yet' }}</span>
61 </div>85 </div>
62- <span class="mt-auto text-xs text-sk-faint">{{ used(p.name) }}</span>
63 </NuxtLink>86 </NuxtLink>
64 </div>87 </div>
65 </SkPage>88 </SkPage>