open source is social v-it.org
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat(explore): add cap and skill detail pages with copy-to-clipboard

+379 -12
+379 -12
explore/public/index.html
··· 399 399 text-align: left; 400 400 } 401 401 } 402 + 403 + .detail-header { 404 + margin-bottom: 20px; 405 + } 406 + 407 + .detail-title { 408 + font-size: 1.3rem; 409 + font-weight: 700; 410 + margin: 0 0 8px; 411 + } 412 + 413 + .detail-meta { 414 + font-size: 0.85rem; 415 + color: #6b7280; 416 + } 417 + 418 + .detail-meta a { 419 + color: var(--vit-green-deep); 420 + text-decoration: none; 421 + } 422 + 423 + .detail-meta a:hover { 424 + text-decoration: underline; 425 + } 426 + 427 + .detail-body { 428 + white-space: pre-wrap; 429 + background: #f3f4f6; 430 + padding: 16px; 431 + border-radius: 6px; 432 + margin: 16px 0; 433 + font-size: 0.9rem; 434 + line-height: 1.5; 435 + overflow-wrap: break-word; 436 + } 437 + 438 + .detail-section { 439 + margin-top: 24px; 440 + } 441 + 442 + .detail-section h3 { 443 + font-size: 0.95rem; 444 + font-weight: 600; 445 + margin: 0 0 8px; 446 + color: #374151; 447 + } 448 + 449 + .detail-link { 450 + display: inline-block; 451 + margin-top: 16px; 452 + font-size: 0.85rem; 453 + } 454 + 455 + .kind-badge { 456 + display: inline-block; 457 + font-size: 0.7rem; 458 + font-weight: 600; 459 + padding: 2px 8px; 460 + border-radius: 3px; 461 + margin-left: 8px; 462 + vertical-align: middle; 463 + text-transform: uppercase; 464 + letter-spacing: 0.03em; 465 + background-color: #f3f4f6; 466 + color: #374151; 467 + } 468 + 469 + .kind-badge.kind-feat { 470 + background-color: #d1fae5; 471 + color: #065f46; 472 + } 473 + 474 + .kind-badge.kind-fix { 475 + background-color: #fef3c7; 476 + color: #92400e; 477 + } 478 + 479 + .kind-badge.kind-docs { 480 + background-color: #dbeafe; 481 + color: #1e40af; 482 + } 483 + 484 + .kind-badge.kind-test { 485 + background-color: #ede9fe; 486 + color: #5b21b6; 487 + } 488 + 489 + .kind-badge.kind-refactor { 490 + background-color: #f3f4f6; 491 + color: #374151; 492 + } 493 + 494 + .kind-badge.kind-chore { 495 + background-color: #f3f4f6; 496 + color: #374151; 497 + } 498 + 499 + .kind-badge.kind-perf { 500 + background-color: #fce7f3; 501 + color: #9d174d; 502 + } 503 + 504 + .kind-badge.kind-style { 505 + background-color: #e0e7ff; 506 + color: #3730a3; 507 + } 508 + 509 + .kind-badge.kind-security { 510 + background-color: #fee2e2; 511 + color: #991b1b; 512 + } 513 + 514 + .command-row { 515 + display: flex; 516 + align-items: center; 517 + gap: 8px; 518 + margin: 6px 0; 519 + } 520 + 521 + .command-row code { 522 + flex: 1; 523 + background: #f3f4f6; 524 + padding: 6px 10px; 525 + border-radius: 4px; 526 + font-size: 0.85rem; 527 + } 528 + 529 + .copy-btn { 530 + background: none; 531 + border: 1px solid #d1d5db; 532 + border-radius: 4px; 533 + padding: 4px 10px; 534 + font-size: 0.75rem; 535 + color: #374151; 536 + cursor: pointer; 537 + font-family: inherit; 538 + white-space: nowrap; 539 + } 540 + 541 + .copy-btn:hover { 542 + background: #f3f4f6; 543 + } 544 + 545 + .tag-pill { 546 + display: inline-block; 547 + background: #e5e7eb; 548 + color: #374151; 549 + font-size: 0.75rem; 550 + padding: 2px 8px; 551 + border-radius: 3px; 552 + margin: 0 4px 4px 0; 553 + text-decoration: none; 554 + cursor: pointer; 555 + } 556 + 557 + .tag-pill:hover { 558 + background: #d1d5db; 559 + } 560 + 561 + .vouch-item { 562 + padding: 6px 0; 563 + font-size: 0.85rem; 564 + color: #6b7280; 565 + } 566 + 567 + .not-found { 568 + color: #6b7280; 569 + padding: 32px 0; 570 + } 571 + 572 + .back-link { 573 + display: inline-block; 574 + margin-bottom: 16px; 575 + font-size: 0.85rem; 576 + } 402 577 </style> 403 578 </head> 404 579 <body> ··· 458 633 let currentBeaconFilter = null; 459 634 let skillsCursor = null; 460 635 let skillsContainer = null; 636 + let currentSkillTag = null; 461 637 462 638 function timeAgo(dateStr) { 463 639 const now = Date.now(); ··· 489 665 async function api(path) { 490 666 const res = await fetch('/api/' + path); 491 667 return res.json(); 668 + } 669 + 670 + function copyToClipboard(text, btn) { 671 + navigator.clipboard.writeText(text).then(function() { 672 + var orig = btn.textContent; 673 + btn.textContent = 'copied!'; 674 + setTimeout(function() { btn.textContent = orig; }, 1500); 675 + }); 492 676 } 493 677 494 678 function getRoute() { 495 679 const hash = location.hash.slice(1); 680 + if (hash.startsWith('cap/')) return { view: 'cap', value: decodeURIComponent(hash.slice(4)) }; 681 + if (hash.startsWith('skill/')) return { view: 'skill', value: decodeURIComponent(hash.slice(6)) }; 496 682 if (hash.startsWith('beacon/')) return { view: 'beacon', value: decodeURIComponent(hash.slice(7)) }; 497 683 if (hash === 'beacons') return { view: 'beacons' }; 498 - if (hash === 'skills') return { view: 'skills' }; 684 + if (hash.startsWith('skills')) { 685 + var qmark = hash.indexOf('?'); 686 + if (qmark !== -1) { 687 + var params = new URLSearchParams(hash.slice(qmark + 1)); 688 + return { view: 'skills', tag: params.get('tag') || null }; 689 + } 690 + return { view: 'skills' }; 691 + } 499 692 if (hash === 'stats') return { view: 'stats' }; 500 693 return { view: 'caps' }; 501 694 } ··· 504 697 const route = getRoute(); 505 698 document.querySelectorAll('.sub-nav a').forEach(function(a) { 506 699 const href = a.getAttribute('href'); 507 - if (route.view === 'caps') a.classList.toggle('active', href === '#'); 508 - else if (route.view === 'skills') a.classList.toggle('active', href === '#skills'); 700 + if (route.view === 'caps' || route.view === 'cap') a.classList.toggle('active', href === '#'); 701 + else if (route.view === 'skills' || route.view === 'skill') a.classList.toggle('active', href === '#skills'); 509 702 else if (route.view === 'beacons' || route.view === 'beacon') a.classList.toggle('active', href === '#beacons'); 510 703 else if (route.view === 'stats') a.classList.toggle('active', href === '#stats'); 511 704 else a.classList.remove('active'); ··· 519 712 const beacon = cap.beacon ? '<a href="#beacon/' + encodeURIComponent(cap.beacon) + '">' + esc(cap.beacon) + '</a>' : ''; 520 713 const time = timeAgo(cap.created_at); 521 714 const metaParts = [name, beacon, ref, time].filter(Boolean); 522 - const titleHtml = cap.uri 523 - ? '<a href="https://pdsls.dev/at/' + esc(cap.uri.replace('at://', '')) + '" target="_blank" rel="noopener">' + title + '</a>' 715 + const titleHtml = cap.ref 716 + ? '<a href="#cap/' + encodeURIComponent(cap.ref) + '">' + title + '</a>' 524 717 : title; 525 718 return '<div class="cap-item"><div class="cap-title">' + titleHtml + '</div><div class="cap-meta">' + metaParts.join(' · ') + '</div></div>'; 526 719 } ··· 533 726 const tags = skill.tags ? skill.tags.split(',').map(function(t) { return '<span class="skill-tag">' + esc(t.trim()) + '</span>'; }).join('') : ''; 534 727 const time = timeAgo(skill.created_at); 535 728 const metaParts = [author, version, time].filter(Boolean); 536 - return '<div class="skill-item"><div class="skill-title">' + name + '</div>' + desc + '<div class="skill-meta">' + metaParts.join(' · ') + (tags ? ' · ' + tags : '') + '</div></div>'; 729 + return '<div class="skill-item"><div class="skill-title"><a href="#skill/' + encodeURIComponent(skill.name) + '">' + name + '</a></div>' + desc + '<div class="skill-meta">' + metaParts.join(' · ') + (tags ? ' · ' + tags : '') + '</div></div>'; 537 730 } 538 731 539 732 function appendLoadMore(el) { ··· 570 763 btn.onclick = async function() { 571 764 btn.disabled = true; 572 765 btn.textContent = 'loading...'; 573 - const data = await api('skills?cursor=' + encodeURIComponent(skillsCursor)); 766 + const tagParam = currentSkillTag ? '&tag=' + encodeURIComponent(currentSkillTag) : ''; 767 + const data = await api('skills?cursor=' + encodeURIComponent(skillsCursor) + tagParam); 574 768 skillsCursor = data.cursor; 575 769 skillsContainer.innerHTML += data.skills.map(renderSkillItem).join(''); 576 770 btn.remove(); ··· 596 790 appendLoadMore(el); 597 791 } 598 792 599 - async function renderSkills(el) { 793 + async function renderCapDetail(el, ref) { 794 + var data = await api('cap?ref=' + encodeURIComponent(ref)); 795 + if (!data.cap) { 796 + el.innerHTML = '<div class="not-found"><a href="#" class="back-link">&larr; back to caps</a><p><strong>Cap not found.</strong></p><p>No cap with ref <code>' + esc(ref) + '</code> was found.</p></div>'; 797 + return; 798 + } 799 + var cap = data.cap; 800 + var record = null; 801 + if (cap.record_json) { 802 + try { record = JSON.parse(cap.record_json); } catch(e) {} 803 + } 804 + 805 + var kind = record && record.kind ? record.kind : null; 806 + var bodyText = record && record.text ? record.text : null; 807 + 808 + var kindBadge = ''; 809 + if (kind) { 810 + kindBadge = '<span class="kind-badge kind-' + esc(kind) + '">' + esc(kind) + '</span>'; 811 + } 812 + 813 + var title = esc(cap.title) || 'untitled'; 814 + var author = esc(displayName(cap)); 815 + var time = timeAgo(cap.created_at); 816 + var beacon = cap.beacon ? '<a href="#beacon/' + encodeURIComponent(cap.beacon) + '">' + esc(cap.beacon) + '</a>' : ''; 817 + var metaParts = [author, beacon, time].filter(Boolean); 818 + 819 + var html = '<a href="#" class="back-link">&larr; caps</a>'; 820 + html += '<div class="detail-header">'; 821 + html += '<div class="detail-title">' + title + kindBadge + '</div>'; 822 + html += '<div class="detail-meta">' + metaParts.join(' &middot; ') + '</div>'; 823 + html += '</div>'; 824 + 825 + if (bodyText) { 826 + html += '<div class="detail-body">' + esc(bodyText) + '</div>'; 827 + } 828 + 829 + var vouchHtml = ''; 830 + if (cap.uri) { 831 + try { 832 + var vouchData = await api('vouches?cap_uri=' + encodeURIComponent(cap.uri)); 833 + var vouches = vouchData.vouches || []; 834 + if (vouches.length > 0) { 835 + vouchHtml = '<div class="detail-section"><h3>vouches (' + vouches.length + ')</h3>'; 836 + vouches.forEach(function(v) { 837 + var vName = v.handle ? esc(v.handle) : esc(v.did); 838 + vouchHtml += '<div class="vouch-item">@' + vName + ' &middot; ' + timeAgo(v.created_at) + '</div>'; 839 + }); 840 + vouchHtml += '</div>'; 841 + } 842 + } catch(e) {} 843 + } 844 + html += vouchHtml; 845 + 846 + html += '<div class="detail-section"><h3>commands</h3>'; 847 + html += '<div class="command-row"><code>vit vet ' + esc(ref) + '</code><button class="copy-btn" data-copy="vit vet ' + esc(ref) + '">copy</button></div>'; 848 + html += '<div class="command-row"><code>vit remix ' + esc(ref) + '</code><button class="copy-btn" data-copy="vit remix ' + esc(ref) + '">copy</button></div>'; 849 + html += '</div>'; 850 + 851 + if (cap.uri) { 852 + html += '<a href="https://pdsls.dev/at/' + esc(cap.uri.replace('at://', '')) + '" target="_blank" rel="noopener" class="detail-link">view on ATProto &rarr;</a>'; 853 + } 854 + 855 + el.innerHTML = html; 856 + } 857 + 858 + async function renderSkillDetail(el, name) { 859 + var data = await api('skill?name=' + encodeURIComponent(name)); 860 + if (!data.skill) { 861 + el.innerHTML = '<div class="not-found"><a href="#skills" class="back-link">&larr; back to skills</a><p><strong>Skill not found.</strong></p><p>No skill named <code>' + esc(name) + '</code> was found.</p></div>'; 862 + return; 863 + } 864 + var skill = data.skill; 865 + var record = null; 866 + if (skill.record_json) { 867 + try { record = JSON.parse(skill.record_json); } catch(e) {} 868 + } 869 + 870 + var skillName = esc(skill.name) || 'unnamed'; 871 + var author = esc(displayName(skill)); 872 + var time = timeAgo(skill.created_at); 873 + var version = skill.version ? 'v' + esc(skill.version) : ''; 874 + var desc = skill.description ? esc(skill.description) : ''; 875 + 876 + var metaParts = [author, time].filter(Boolean); 877 + 878 + var html = '<a href="#skills" class="back-link">&larr; skills</a>'; 879 + html += '<div class="detail-header">'; 880 + html += '<div class="detail-title">/' + skillName; 881 + if (version) html += ' <span style="font-size:0.85rem;font-weight:normal;color:#6b7280">' + version + '</span>'; 882 + html += '</div>'; 883 + html += '<div class="detail-meta">' + metaParts.join(' &middot; ') + '</div>'; 884 + html += '</div>'; 885 + 886 + if (desc) { 887 + html += '<p>' + desc + '</p>'; 888 + } 889 + 890 + var tags = skill.tags ? skill.tags.split(',') : []; 891 + if (record && record.tags && Array.isArray(record.tags)) { 892 + tags = record.tags; 893 + } 894 + if (tags.length > 0) { 895 + html += '<div style="margin: 12px 0">'; 896 + tags.forEach(function(t) { 897 + var tag = t.trim ? t.trim() : t; 898 + if (tag) { 899 + html += '<a href="#skills?tag=' + encodeURIComponent(tag) + '" class="tag-pill">' + esc(tag) + '</a>'; 900 + } 901 + }); 902 + html += '</div>'; 903 + } 904 + 905 + var license = record && record.license ? record.license : null; 906 + var compat = record && record.compatibility ? record.compatibility : null; 907 + if (license || compat) { 908 + html += '<div class="detail-meta" style="margin: 8px 0">'; 909 + var infoParts = []; 910 + if (license) infoParts.push('license: ' + esc(license)); 911 + if (compat) infoParts.push(esc(compat)); 912 + html += infoParts.join(' &middot; '); 913 + html += '</div>'; 914 + } 915 + 916 + var bodyText = record && record.text ? record.text : (skill.text || null); 917 + if (bodyText) { 918 + html += '<div class="detail-body">' + esc(bodyText) + '</div>'; 919 + } 920 + 921 + var vouchHtml = ''; 922 + if (skill.uri) { 923 + try { 924 + var vouchData = await api('vouches?cap_uri=' + encodeURIComponent(skill.uri)); 925 + var vouches = vouchData.vouches || []; 926 + if (vouches.length > 0) { 927 + vouchHtml = '<div class="detail-section"><h3>vouches (' + vouches.length + ')</h3>'; 928 + vouches.forEach(function(v) { 929 + var vName = v.handle ? esc(v.handle) : esc(v.did); 930 + vouchHtml += '<div class="vouch-item">@' + vName + ' &middot; ' + timeAgo(v.created_at) + '</div>'; 931 + }); 932 + vouchHtml += '</div>'; 933 + } 934 + } catch(e) {} 935 + } 936 + html += vouchHtml; 937 + 938 + html += '<div class="detail-section"><h3>commands</h3>'; 939 + html += '<div class="command-row"><code>vit learn skill-' + esc(name) + '</code><button class="copy-btn" data-copy="vit learn skill-' + esc(name) + '">copy</button></div>'; 940 + html += '<div class="command-row"><code>vit learn skill-' + esc(name) + ' --user</code><button class="copy-btn" data-copy="vit learn skill-' + esc(name) + ' --user">copy</button></div>'; 941 + html += '</div>'; 942 + 943 + if (skill.uri) { 944 + html += '<a href="https://pdsls.dev/at/' + esc(skill.uri.replace('at://', '')) + '" target="_blank" rel="noopener" class="detail-link">view on ATProto &rarr;</a>'; 945 + } 946 + 947 + el.innerHTML = html; 948 + } 949 + 950 + async function renderSkills(el, tag) { 951 + currentSkillTag = tag || null; 600 952 skillsCursor = null; 601 - const data = await api('skills'); 953 + const data = await api(tag ? 'skills?tag=' + encodeURIComponent(tag) : 'skills'); 602 954 skillsCursor = data.cursor; 955 + const heading = tag 956 + ? '<h2 class="view-title">skills tagged "' + esc(tag) + '" <a href="#skills" style="font-size:0.85rem;font-weight:normal">clear filter</a></h2>' 957 + : ''; 603 958 604 959 if (data.skills.length === 0) { 605 - el.innerHTML = '<div class="empty-state"><p><strong>No skills yet.</strong></p><p>Skills are reusable agent instructions published to the network. Publish one:</p><pre><code>vit ship --skill</code></pre><p><a href="https://v-it.org/start/">Get started with vit →</a></p></div>'; 960 + if (tag) { 961 + el.innerHTML = heading + '<div class="empty-state"><p>No skills found matching tag "' + esc(tag) + '".</p><p><a href="#skills">View all skills</a></p></div>'; 962 + } else { 963 + el.innerHTML = heading + '<div class="empty-state"><p><strong>No skills yet.</strong></p><p>Skills are reusable agent instructions published to the network. Publish one:</p><pre><code>vit ship --skill</code></pre><p><a href="https://v-it.org/start/">Get started with vit →</a></p></div>'; 964 + } 606 965 return; 607 966 } 608 967 609 - el.innerHTML = '<div id="skills-list"></div>'; 968 + el.innerHTML = heading + '<div id="skills-list"></div>'; 610 969 skillsContainer = document.getElementById('skills-list'); 611 970 skillsContainer.innerHTML = data.skills.map(renderSkillItem).join(''); 612 971 appendSkillsLoadMore(el); ··· 696 1055 } 697 1056 }).catch(function() {}); 698 1057 1058 + document.addEventListener('click', function(e) { 1059 + var btn = e.target.closest('.copy-btn[data-copy]'); 1060 + if (!btn) return; 1061 + copyToClipboard(btn.getAttribute('data-copy'), btn); 1062 + }); 1063 + 699 1064 async function navigate() { 700 1065 updateNav(); 701 1066 const route = getRoute(); 702 1067 const view = document.getElementById('view'); 703 1068 704 1069 if (route.view === 'caps') await renderCaps(view); 705 - else if (route.view === 'skills') await renderSkills(view); 1070 + else if (route.view === 'skills') await renderSkills(view, route.tag); 706 1071 else if (route.view === 'beacons') await renderBeacons(view); 707 1072 else if (route.view === 'beacon') await renderBeaconCaps(view, route.value); 708 1073 else if (route.view === 'stats') await renderStats(view); 1074 + else if (route.view === 'cap') await renderCapDetail(view, route.value); 1075 + else if (route.view === 'skill') await renderSkillDetail(view, route.value); 709 1076 } 710 1077 711 1078 window.addEventListener('hashchange', navigate);