Skip to content

Commit d56bdc8

Browse files
fullstackjamclaude
andcommitted
style(ui): redesign dashboard as list-rows with package DNA bar
Aligns the dashboard with the updated OpenBoot Redesign mockup. Configs now render as a single-column list of rows instead of a card grid. Each row shows a package "DNA" bar (cli/cask/npm segments, brightening within each group), right-aligned stats, and a hero click-to-copy install command alongside edit and more (…) actions. - Add ConfigRow.svelte for the new dashboard row - Switch dashboard from ConfigCard grid to a ConfigRow column list - Add a backward-compatible `square` trigger variant to ContextMenu - Align the dashboard stats line tokens to the mockup Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Rp5Ri2TcfaCozPfpkrTDkw
1 parent ddc1b22 commit d56bdc8

3 files changed

Lines changed: 356 additions & 25 deletions

File tree

Lines changed: 332 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,332 @@
1+
<script lang="ts">
2+
import ContextMenu from './ContextMenu.svelte';
3+
4+
interface Config {
5+
id: string;
6+
slug: string;
7+
name: string;
8+
description: string;
9+
base_preset: string;
10+
visibility: string;
11+
alias: string | null;
12+
packages?: any[];
13+
updated_at?: string;
14+
install_count?: number;
15+
}
16+
17+
let { config, username, onaction }: {
18+
config: Config;
19+
username: string;
20+
onaction: (action: string, slug: string) => void;
21+
} = $props();
22+
23+
const packages = $derived(
24+
Array.isArray(config.packages)
25+
? config.packages.map((p: any) =>
26+
typeof p === 'string' ? { name: p, type: 'formula' } : p
27+
)
28+
: []
29+
);
30+
31+
const cli = $derived(packages.filter((p: any) => p.type !== 'cask' && p.type !== 'npm').length);
32+
const apps = $derived(packages.filter((p: any) => p.type === 'cask').length);
33+
const npm = $derived(packages.filter((p: any) => p.type === 'npm').length);
34+
35+
// DNA: one segment per package, grouped by type, brightening across each group
36+
const dna = $derived.by(() => {
37+
const groups: [number, string][] = [
38+
[cli, 'var(--accent)'],
39+
[apps, 'var(--amber)'],
40+
[npm, '#7aa2e3'],
41+
];
42+
const segs: { color: string; opacity: number }[] = [];
43+
for (const [n, color] of groups) {
44+
for (let i = 0; i < n; i++) {
45+
segs.push({ color, opacity: 0.55 + (i / Math.max(n - 1, 1)) * 0.45 });
46+
}
47+
}
48+
return segs;
49+
});
50+
51+
const installUrl = $derived(
52+
config.alias ? `openboot.dev/${config.alias}` : `openboot.dev/${username}/${config.slug}`
53+
);
54+
55+
let copied = $state(false);
56+
function copyInstall() {
57+
navigator.clipboard?.writeText(`curl -fsSL ${installUrl} | bash`).catch(() => {});
58+
copied = true;
59+
setTimeout(() => (copied = false), 1600);
60+
}
61+
62+
function formatDate(dateStr?: string): string {
63+
if (!dateStr) return '';
64+
const date = new Date(dateStr.endsWith('Z') ? dateStr : dateStr + 'Z');
65+
if (isNaN(date.getTime())) return '';
66+
return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' });
67+
}
68+
69+
const menuItems = $derived([
70+
{ label: 'Share', action: 'share' },
71+
{ label: 'Duplicate', action: 'duplicate' },
72+
{ label: 'Export JSON', action: 'export' },
73+
...(config.visibility !== 'public'
74+
? [{ label: 'Push to Community', action: 'push' }]
75+
: []),
76+
{ label: 'Delete', action: 'delete', danger: true },
77+
]);
78+
</script>
79+
80+
<div class="row">
81+
<div class="row-top">
82+
<div class="identity">
83+
<div class="id-head">
84+
<h3 class="row-name">{config.name}</h3>
85+
<span class="vis-tag" class:public={config.visibility === 'public'}>{config.visibility}</span>
86+
</div>
87+
{#if dna.length > 0}
88+
<div class="dna" aria-hidden="true">
89+
{#each dna as seg, i (i)}
90+
<span class="dna-seg" style="background:{seg.color}; opacity:{seg.opacity};"></span>
91+
{/each}
92+
</div>
93+
{/if}
94+
{#if config.description}
95+
<p class="row-desc">{config.description}</p>
96+
{/if}
97+
</div>
98+
99+
<div class="stats">
100+
<span class="stat-installs">
101+
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="var(--text-secondary)" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>
102+
{config.install_count ?? 0}
103+
</span>
104+
<span class="stat-meta">{cli} cli · {apps} apps</span>
105+
<span class="stat-meta">updated {formatDate(config.updated_at)}</span>
106+
</div>
107+
</div>
108+
109+
<div class="row-actions">
110+
<button class="copy-cmd" onclick={copyInstall}>
111+
<code><span class="prompt">$</span> curl -fsSL {installUrl} | bash</code>
112+
<span class="copy-label" class:copied>
113+
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>
114+
{copied ? 'copied ✓' : 'click to copy install'}
115+
</span>
116+
</button>
117+
<button class="icon-btn" aria-label="Edit" onclick={() => onaction('edit', config.slug)}>
118+
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.12 2.12 0 0 1 3 3L12 15l-4 1 1-4Z"/></svg>
119+
</button>
120+
<ContextMenu square items={menuItems} onselect={(action) => onaction(action, config.slug)} />
121+
</div>
122+
</div>
123+
124+
<style>
125+
.row {
126+
background: var(--bg-secondary);
127+
border: 1px solid var(--border);
128+
border-radius: 14px;
129+
padding: 20px 22px;
130+
transition: border-color 0.18s ease;
131+
}
132+
133+
.row:hover {
134+
border-color: var(--border-hover);
135+
}
136+
137+
.row-top {
138+
display: flex;
139+
align-items: flex-start;
140+
gap: 24px;
141+
}
142+
143+
/* ── identity + dna ── */
144+
.identity {
145+
flex: 1 1 0;
146+
min-width: 0;
147+
}
148+
149+
.id-head {
150+
display: flex;
151+
align-items: center;
152+
gap: 11px;
153+
margin-bottom: 12px;
154+
}
155+
156+
.row-name {
157+
font-size: 1.05rem;
158+
font-weight: 500;
159+
letter-spacing: -0.01em;
160+
color: var(--text-primary);
161+
margin: 0;
162+
white-space: nowrap;
163+
overflow: hidden;
164+
text-overflow: ellipsis;
165+
}
166+
167+
.vis-tag {
168+
font-size: 0.66rem;
169+
text-transform: uppercase;
170+
letter-spacing: 0.08em;
171+
border-radius: 5px;
172+
padding: 2px 7px;
173+
color: var(--text-muted);
174+
border: 1px solid var(--border-hover);
175+
white-space: nowrap;
176+
flex-shrink: 0;
177+
}
178+
179+
.vis-tag.public {
180+
color: var(--accent);
181+
border-color: color-mix(in srgb, var(--accent) 40%, transparent);
182+
}
183+
184+
.dna {
185+
display: flex;
186+
height: 7px;
187+
gap: 2px;
188+
margin-bottom: 11px;
189+
max-width: 280px;
190+
}
191+
192+
.dna-seg {
193+
flex: 1;
194+
height: 100%;
195+
}
196+
197+
.row-desc {
198+
font-size: 0.82rem;
199+
color: var(--text-secondary);
200+
line-height: 1.55;
201+
margin: 0;
202+
display: -webkit-box;
203+
-webkit-line-clamp: 2;
204+
line-clamp: 2;
205+
-webkit-box-orient: vertical;
206+
overflow: hidden;
207+
}
208+
209+
/* ── stats ── */
210+
.stats {
211+
flex-shrink: 0;
212+
display: flex;
213+
flex-direction: column;
214+
align-items: flex-end;
215+
gap: 7px;
216+
padding-left: 8px;
217+
}
218+
219+
.stat-installs {
220+
display: flex;
221+
align-items: center;
222+
gap: 6px;
223+
font-size: 0.85rem;
224+
color: var(--text-primary);
225+
font-variant-numeric: tabular-nums;
226+
}
227+
228+
.stat-meta {
229+
font-size: 0.72rem;
230+
color: var(--text-muted);
231+
white-space: nowrap;
232+
}
233+
234+
/* ── install command = hero action ── */
235+
.row-actions {
236+
display: flex;
237+
align-items: center;
238+
gap: 10px;
239+
margin-top: 16px;
240+
}
241+
242+
.copy-cmd {
243+
flex: 1;
244+
min-width: 0;
245+
display: flex;
246+
align-items: center;
247+
gap: 10px;
248+
background: var(--bg-tertiary);
249+
border: 1px solid var(--border);
250+
border-radius: 9px;
251+
padding: 11px 13px;
252+
cursor: pointer;
253+
font-family: inherit;
254+
text-align: left;
255+
transition: border-color 0.15s ease;
256+
}
257+
258+
.copy-cmd:hover {
259+
border-color: var(--border-hover);
260+
}
261+
262+
.copy-cmd code {
263+
flex: 1;
264+
min-width: 0;
265+
font-family: inherit;
266+
font-size: 0.76rem;
267+
color: var(--text-secondary);
268+
white-space: nowrap;
269+
overflow: hidden;
270+
text-overflow: ellipsis;
271+
}
272+
273+
.copy-cmd .prompt {
274+
color: var(--accent);
275+
}
276+
277+
.copy-label {
278+
flex-shrink: 0;
279+
display: flex;
280+
align-items: center;
281+
gap: 6px;
282+
font-size: 0.7rem;
283+
color: var(--text-muted);
284+
transition: color 0.15s ease;
285+
}
286+
287+
.copy-cmd:hover .copy-label {
288+
color: var(--text-secondary);
289+
}
290+
291+
.copy-label.copied,
292+
.copy-cmd:hover .copy-label.copied {
293+
color: var(--accent);
294+
}
295+
296+
.icon-btn {
297+
flex-shrink: 0;
298+
display: inline-flex;
299+
align-items: center;
300+
justify-content: center;
301+
width: 40px;
302+
height: 40px;
303+
background: var(--bg-tertiary);
304+
border: 1px solid var(--border);
305+
border-radius: 9px;
306+
color: var(--text-secondary);
307+
cursor: pointer;
308+
transition:
309+
border-color 0.15s ease,
310+
color 0.15s ease;
311+
}
312+
313+
.icon-btn:hover {
314+
border-color: var(--border-hover);
315+
color: var(--accent);
316+
}
317+
318+
@media (max-width: 640px) {
319+
.row-top {
320+
flex-direction: column;
321+
gap: 14px;
322+
}
323+
324+
.stats {
325+
flex-direction: row;
326+
align-items: center;
327+
gap: 14px;
328+
padding-left: 0;
329+
flex-wrap: wrap;
330+
}
331+
}
332+
</style>

src/lib/components/ContextMenu.svelte

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,10 @@
55
danger?: boolean;
66
}
77
8-
let { items, onselect }: {
8+
let { items, onselect, square = false }: {
99
items: MenuItem[];
1010
onselect: (action: string) => void;
11+
square?: boolean;
1112
} = $props();
1213
1314
let open = $state(false);
@@ -36,7 +37,7 @@
3637
<svelte:window onclick={handleClickOutside} />
3738

3839
<div class="ctx" bind:this={menuEl}>
39-
<button class="trigger" onclick={toggle} aria-label="More actions">⋯</button>
40+
<button class="trigger" class:square onclick={toggle} aria-label="More actions">⋯</button>
4041
{#if open}
4142
<div class="dropdown">
4243
{#each items as item, i}
@@ -78,6 +79,15 @@
7879
color 0.15s ease;
7980
}
8081
82+
.trigger.square {
83+
width: 40px;
84+
height: 40px;
85+
padding: 0;
86+
border-radius: 9px;
87+
font-size: 1rem;
88+
color: var(--text-muted);
89+
}
90+
8191
.trigger:hover {
8292
border-color: var(--border-hover);
8393
color: var(--accent);

0 commit comments

Comments
 (0)