|
457 | 457 | importLoading = false; |
458 | 458 | } |
459 | 459 | } |
| 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 | + } |
460 | 710 | </script> |
461 | 711 |
|
462 | 712 | <svelte:head> |
|
528 | 778 | <Button variant="secondary" onclick={() => editConfig(config.slug)}>Edit</Button> |
529 | 779 | <Button variant="secondary" onclick={() => duplicateConfig(config.slug)}>Duplicate</Button> |
530 | 780 | <Button variant="secondary" onclick={() => shareConfig(config)}>Share</Button> |
| 781 | + <Button variant="secondary" onclick={() => generateStackImage(config)}>Share Stack</Button> |
531 | 782 | <Button variant="danger" onclick={() => deleteConfig(config.slug)}>Delete</Button> |
532 | 783 | </div> |
533 | 784 | </div> |
|
0 commit comments