|
| 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> |
0 commit comments