Skip to content

Commit a0573df

Browse files
committed
feat: add Share My Stack — generate shareable PNG image of config packages
1 parent 13cfb8f commit a0573df

File tree

1 file changed

+251
-0
lines changed

1 file changed

+251
-0
lines changed

src/routes/dashboard/+page.svelte

Lines changed: 251 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -457,6 +457,256 @@
457457
importLoading = false;
458458
}
459459
}
460+
461+
async function generateStackImage(config: Config) {
462+
const response = await fetch(`/api/configs/${config.slug}`);
463+
const data = await response.json();
464+
const packages: { name: string; type: string }[] = data.config.packages || [];
465+
466+
const cli = packages.filter((p) => p.type !== 'cask' && p.type !== 'npm');
467+
const apps = packages.filter((p) => p.type === 'cask');
468+
const npm = packages.filter((p) => p.type === 'npm');
469+
470+
const W = 1200;
471+
const H = 630;
472+
const canvas = document.createElement('canvas');
473+
canvas.width = W;
474+
canvas.height = H;
475+
const maybeCtx = canvas.getContext('2d');
476+
if (!maybeCtx) return;
477+
const ctx: CanvasRenderingContext2D = maybeCtx;
478+
479+
ctx.fillStyle = '#0a0a0a';
480+
ctx.fillRect(0, 0, W, H);
481+
482+
const glow = ctx.createRadialGradient(W * 0.5, H * 0.15, 0, W * 0.5, H * 0.15, W * 0.6);
483+
glow.addColorStop(0, 'rgba(34, 197, 94, 0.08)');
484+
glow.addColorStop(0.5, 'rgba(34, 197, 94, 0.03)');
485+
glow.addColorStop(1, 'rgba(34, 197, 94, 0)');
486+
ctx.fillStyle = glow;
487+
ctx.fillRect(0, 0, W, H);
488+
489+
const edgeGlow = ctx.createRadialGradient(W * 0.85, H * 0.8, 0, W * 0.85, H * 0.8, W * 0.35);
490+
edgeGlow.addColorStop(0, 'rgba(34, 197, 94, 0.04)');
491+
edgeGlow.addColorStop(1, 'rgba(34, 197, 94, 0)');
492+
ctx.fillStyle = edgeGlow;
493+
ctx.fillRect(0, 0, W, H);
494+
495+
for (let i = 0; i < W; i += 1) {
496+
for (let j = 0; j < H; j += 4) {
497+
if (Math.random() < 0.015) {
498+
const noise = Math.random() * 12;
499+
ctx.fillStyle = `rgba(255,255,255,${noise / 255})`;
500+
ctx.fillRect(i, j, 1, 1);
501+
}
502+
}
503+
}
504+
505+
ctx.strokeStyle = 'rgba(34, 197, 94, 0.15)';
506+
ctx.lineWidth = 1;
507+
ctx.strokeRect(24, 24, W - 48, H - 48);
508+
509+
const cornerLen = 16;
510+
ctx.strokeStyle = '#22c55e';
511+
ctx.lineWidth = 2;
512+
ctx.beginPath();
513+
ctx.moveTo(24, 24 + cornerLen);
514+
ctx.lineTo(24, 24);
515+
ctx.lineTo(24 + cornerLen, 24);
516+
ctx.stroke();
517+
ctx.beginPath();
518+
ctx.moveTo(W - 24 - cornerLen, 24);
519+
ctx.lineTo(W - 24, 24);
520+
ctx.lineTo(W - 24, 24 + cornerLen);
521+
ctx.stroke();
522+
ctx.beginPath();
523+
ctx.moveTo(24, H - 24 - cornerLen);
524+
ctx.lineTo(24, H - 24);
525+
ctx.lineTo(24 + cornerLen, H - 24);
526+
ctx.stroke();
527+
ctx.beginPath();
528+
ctx.moveTo(W - 24 - cornerLen, H - 24);
529+
ctx.lineTo(W - 24, H - 24);
530+
ctx.lineTo(W - 24, H - 24 - cornerLen);
531+
ctx.stroke();
532+
533+
ctx.font = 'bold 28px system-ui, -apple-system, sans-serif';
534+
ctx.fillStyle = '#22c55e';
535+
ctx.fillText('OpenBoot', 56, 72);
536+
537+
const username = $auth.user?.username || 'user';
538+
ctx.font = '16px system-ui, -apple-system, sans-serif';
539+
ctx.fillStyle = '#666666';
540+
const logoWidth = ctx.measureText('OpenBoot').width;
541+
ctx.fillText(`@${username}`, 56 + logoWidth + 16, 72);
542+
543+
ctx.font = 'bold 22px system-ui, -apple-system, sans-serif';
544+
ctx.fillStyle = '#e5e5e5';
545+
ctx.fillText(config.name, 56, 108);
546+
547+
ctx.strokeStyle = 'rgba(42, 42, 42, 0.6)';
548+
ctx.lineWidth = 1;
549+
ctx.beginPath();
550+
ctx.moveTo(56, 124);
551+
ctx.lineTo(W - 56, 124);
552+
ctx.stroke();
553+
554+
const colWidth = (W - 112 - 32) / 3;
555+
const colStartX = 56;
556+
const groupStartY = 148;
557+
558+
function measureTag(text: string, maxWidth: number): { displayText: string; w: number; h: number } {
559+
const tagH = 30;
560+
const padX = 12;
561+
ctx.font = '13px "SFMono-Regular", "Cascadia Mono", "Consolas", monospace';
562+
let displayText = text;
563+
let textW = ctx.measureText(displayText).width;
564+
if (textW + padX * 2 > maxWidth) {
565+
while (textW + padX * 2 + 10 > maxWidth && displayText.length > 3) {
566+
displayText = displayText.slice(0, -1);
567+
textW = ctx.measureText(displayText + '...').width;
568+
}
569+
displayText = displayText + '...';
570+
textW = ctx.measureText(displayText).width;
571+
}
572+
return { displayText, w: textW + padX * 2, h: tagH };
573+
}
574+
575+
function drawTag(x: number, y: number, text: string, maxWidth: number): { w: number; h: number } {
576+
const { displayText, w: tagW, h: tagH } = measureTag(text, maxWidth);
577+
const padX = 12;
578+
579+
ctx.fillStyle = '#1a1a1a';
580+
ctx.beginPath();
581+
ctx.roundRect(x, y, tagW, tagH, 6);
582+
ctx.fill();
583+
584+
ctx.strokeStyle = '#2a2a2a';
585+
ctx.lineWidth = 1;
586+
ctx.beginPath();
587+
ctx.roundRect(x, y, tagW, tagH, 6);
588+
ctx.stroke();
589+
590+
ctx.fillStyle = '#d4d4d4';
591+
ctx.font = '13px "SFMono-Regular", "Cascadia Mono", "Consolas", monospace';
592+
ctx.fillText(displayText, x + padX, y + 20);
593+
594+
return { w: tagW, h: tagH };
595+
}
596+
597+
function drawGroup(
598+
label: string,
599+
pkgs: { name: string }[],
600+
colX: number,
601+
startY: number,
602+
maxColW: number
603+
): number {
604+
let y = startY;
605+
606+
ctx.font = 'bold 11px system-ui, -apple-system, sans-serif';
607+
ctx.fillStyle = '#666666';
608+
ctx.fillText(label.toUpperCase(), colX, y + 10);
609+
610+
ctx.font = '11px system-ui, -apple-system, sans-serif';
611+
ctx.fillStyle = '#444444';
612+
const labelW = ctx.measureText(label.toUpperCase()).width;
613+
ctx.fillText(`${pkgs.length}`, colX + labelW + 8, y + 10);
614+
615+
y += 22;
616+
let rowX = colX;
617+
const gap = 6;
618+
const tagH = 30;
619+
const maxY = H - 80;
620+
621+
for (let i = 0; i < pkgs.length; i++) {
622+
if (y > maxY) {
623+
ctx.font = '12px system-ui, -apple-system, sans-serif';
624+
ctx.fillStyle = '#444444';
625+
ctx.fillText(`+${pkgs.length - i} more`, rowX, y + 14);
626+
y += 20;
627+
break;
628+
}
629+
630+
const avail = maxColW - (rowX - colX);
631+
const measured = measureTag(pkgs[i].name, avail);
632+
633+
if (rowX + measured.w + gap > colX + maxColW && rowX !== colX) {
634+
rowX = colX;
635+
y += tagH + gap;
636+
if (y > maxY) {
637+
ctx.font = '12px system-ui, -apple-system, sans-serif';
638+
ctx.fillStyle = '#444444';
639+
ctx.fillText(`+${pkgs.length - i} more`, rowX, y + 14);
640+
y += 20;
641+
break;
642+
}
643+
}
644+
645+
const tag = drawTag(rowX, y, pkgs[i].name, maxColW - (rowX - colX));
646+
rowX += tag.w + gap;
647+
648+
if (rowX > colX + maxColW - 40) {
649+
rowX = colX;
650+
y += tagH + gap;
651+
}
652+
}
653+
654+
return y;
655+
}
656+
657+
const groups: [string, { name: string }[]][] = [];
658+
if (cli.length > 0) groups.push(['CLI', cli]);
659+
if (apps.length > 0) groups.push(['Apps', apps]);
660+
if (npm.length > 0) groups.push(['NPM', npm]);
661+
662+
if (groups.length === 1) {
663+
drawGroup(groups[0][0], groups[0][1], colStartX, groupStartY, W - 112);
664+
} else if (groups.length === 2) {
665+
const halfW = (W - 112 - 16) / 2;
666+
drawGroup(groups[0][0], groups[0][1], colStartX, groupStartY, halfW);
667+
drawGroup(groups[1][0], groups[1][1], colStartX + halfW + 16, groupStartY, halfW);
668+
} else if (groups.length === 3) {
669+
drawGroup(groups[0][0], groups[0][1], colStartX, groupStartY, colWidth);
670+
drawGroup(groups[1][0], groups[1][1], colStartX + colWidth + 16, groupStartY, colWidth);
671+
drawGroup(groups[2][0], groups[2][1], colStartX + (colWidth + 16) * 2, groupStartY, colWidth);
672+
}
673+
674+
ctx.fillStyle = 'rgba(10, 10, 10, 0.9)';
675+
ctx.fillRect(0, H - 56, W, 56);
676+
ctx.strokeStyle = 'rgba(42, 42, 42, 0.4)';
677+
ctx.lineWidth = 1;
678+
ctx.beginPath();
679+
ctx.moveTo(56, H - 56);
680+
ctx.lineTo(W - 56, H - 56);
681+
ctx.stroke();
682+
683+
ctx.font = '14px system-ui, -apple-system, sans-serif';
684+
ctx.fillStyle = '#444444';
685+
ctx.fillText('openboot.dev', 56, H - 28);
686+
687+
const totalCount = `${packages.length} packages`;
688+
ctx.font = '14px system-ui, -apple-system, sans-serif';
689+
ctx.fillStyle = '#22c55e';
690+
const countW = ctx.measureText(totalCount).width;
691+
ctx.fillText(totalCount, W - 56 - countW, H - 28);
692+
693+
const blob = await new Promise<Blob | null>((resolve) => canvas.toBlob(resolve, 'image/png'));
694+
if (!blob) return;
695+
696+
const url = URL.createObjectURL(blob);
697+
const a = document.createElement('a');
698+
a.href = url;
699+
a.download = `${config.name.toLowerCase().replace(/\s+/g, '-')}-stack.png`;
700+
document.body.appendChild(a);
701+
a.click();
702+
document.body.removeChild(a);
703+
URL.revokeObjectURL(url);
704+
705+
const installUrl = `https://${getInstallUrl(config)}`;
706+
const text = `My dev stack: ${config.name} (${packages.length} packages) — set up in minutes with @openbootdotdev`;
707+
const tweetUrl = `https://twitter.com/intent/tweet?text=${encodeURIComponent(text)}&url=${encodeURIComponent(installUrl)}&hashtags=${encodeURIComponent('OpenBoot,DevTools')}`;
708+
window.open(tweetUrl, '_blank', 'width=550,height=420');
709+
}
460710
</script>
461711

462712
<svelte:head>
@@ -528,6 +778,7 @@
528778
<Button variant="secondary" onclick={() => editConfig(config.slug)}>Edit</Button>
529779
<Button variant="secondary" onclick={() => duplicateConfig(config.slug)}>Duplicate</Button>
530780
<Button variant="secondary" onclick={() => shareConfig(config)}>Share</Button>
781+
<Button variant="secondary" onclick={() => generateStackImage(config)}>Share Stack</Button>
531782
<Button variant="danger" onclick={() => deleteConfig(config.slug)}>Delete</Button>
532783
</div>
533784
</div>

0 commit comments

Comments
 (0)