[READ-ONLY] Mirror of https://github.com/flo-bit/atproto-image-sharing. share images using your atproto account atmo.pics
0

Configure Feed

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

add code upload

Florian (Feb 7, 2026, 1:57 AM +0100) 8a7f119b f2b67b36

+738 -45
+1
package.json
··· 51 51 "@sveltejs/adapter-cloudflare": "^7.2.6", 52 52 "@types/dompurify": "^3.2.0", 53 53 "dompurify": "^3.3.1", 54 + "highlight.js": "^11.11.1", 54 55 "marked": "^17.0.1", 55 56 "wrangler": "^4.61.1" 56 57 }
+9
pnpm-lock.yaml
··· 20 20 dompurify: 21 21 specifier: ^3.3.1 22 22 version: 3.3.1 23 + highlight.js: 24 + specifier: ^11.11.1 25 + version: 11.11.1 23 26 marked: 24 27 specifier: ^17.0.1 25 28 version: 17.0.1 ··· 1533 1536 hex-rgb@4.3.0: 1534 1537 resolution: {integrity: sha512-Ox1pJVrDCyGHMG9CFg1tmrRUMRPRsAWYc/PinY0XzJU4K7y7vjNoLKIQ7BR5UJMCxNN8EM1MNDmHWA/B3aZUuw==, tarball: https://registry.npmjs.org/hex-rgb/-/hex-rgb-4.3.0.tgz} 1535 1538 engines: {node: '>=6'} 1539 + 1540 + highlight.js@11.11.1: 1541 + resolution: {integrity: sha512-Xwwo44whKBVCYoliBQwaPvtd/2tYFkRQtXDWj1nackaV2JPXx3L0+Jvd8/qCJ2p+ML0/XVkJ2q+Mr+UVdpJK5w==, tarball: https://registry.npmjs.org/highlight.js/-/highlight.js-11.11.1.tgz} 1542 + engines: {node: '>=12.0.0'} 1536 1543 1537 1544 ignore@5.3.2: 1538 1545 resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==, tarball: https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz} ··· 3422 3429 has-flag@4.0.0: {} 3423 3430 3424 3431 hex-rgb@4.3.0: {} 3432 + 3433 + highlight.js@11.11.1: {} 3425 3434 3426 3435 ignore@5.3.2: {} 3427 3436
+1 -1
src/lib/atproto/settings.ts
··· 13 13 14 14 // example: only allow create and delete 15 15 // collections: ['xyz.statusphere.status?action=create&action=update'], 16 - collections: ['pics.atmo.image', 'pics.atmo.video', 'pics.atmo.markdown'], 16 + collections: ['pics.atmo.image', 'pics.atmo.video', 'pics.atmo.markdown', 'pics.atmo.code'], 17 17 18 18 // what types of authenticated proxied requests you can make to services 19 19
+11
src/lib/index.ts
··· 17 17 return getMarkdownShareLink(repo, parts.rkey); 18 18 } 19 19 20 + if (parts.collection === 'pics.atmo.code') { 21 + return getCodeShareLink(repo, parts.rkey); 22 + } 23 + 20 24 return getShareLink(repo, parts.rkey); 21 25 } 22 26 ··· 40 44 rkey: rkey 41 45 })}`; 42 46 } 47 + 48 + export function getCodeShareLink(repo: string, rkey: string): string { 49 + return `${window.location.origin}${resolve('/c/[repo]/[rkey]', { 50 + repo: repo, 51 + rkey: rkey 52 + })}`; 53 + }
+471 -44
src/routes/+page.svelte
··· 16 16 import Button from '$lib/atproto/UI/Button.svelte'; 17 17 import { loginModalState } from '$lib/atproto/UI/LoginModal.svelte'; 18 18 import { resolve } from '$app/paths'; 19 + import { tick } from 'svelte'; 19 20 import { getShareLinkFromUri } from '$lib'; 20 21 21 22 type MediaRecord = { 22 23 uri: string; 23 24 value: Record<string, unknown>; 24 - type: 'image' | 'video' | 'markdown'; 25 + type: 'image' | 'video' | 'markdown' | 'code'; 25 26 }; 26 27 27 28 let fileInput: HTMLInputElement | undefined = $state(); ··· 32 33 let loadingImages = $state(false); 33 34 let isDragging = $state(false); 34 35 let dragCounter = 0; 35 - let showMarkdownEditor = $state(false); 36 + let showCreateModal = $state(false); 37 + let createMode = $state<'pick' | 'pick-text' | 'markdown' | 'code'>('pick'); 36 38 let markdownTitle = $state(''); 37 39 let markdownContent = $state(''); 38 40 let isPublishingMarkdown = $state(false); 41 + let codeTitle = $state(''); 42 + let codeContent = $state(''); 43 + let isPublishingCode = $state(false); 44 + 45 + let pastedText = $state(''); 46 + let modalEl = $state<HTMLDivElement | undefined>(); 47 + 48 + $effect(() => { 49 + if ((createMode === 'markdown' || createMode === 'code') && modalEl) { 50 + tick().then(() => { 51 + const input = modalEl?.querySelector<HTMLElement>('input, textarea'); 52 + input?.focus(); 53 + }); 54 + } 55 + }); 56 + 57 + function detectTextType(text: string): 'code' | 'markdown' | null { 58 + const lines = text.split('\n'); 59 + let codeScore = 0; 60 + let markdownScore = 0; 61 + 62 + for (const line of lines) { 63 + const trimmed = line.trimEnd(); 64 + // Code signals 65 + if (/[;{}]\s*$/.test(trimmed)) codeScore += 2; 66 + if (/^\s*(import|export|const|let|var|function|class|def|return|if|for|while|switch|try|catch)\b/.test(trimmed)) codeScore += 2; 67 + if (/[=!<>]=|=>|->|\|\||&&/.test(trimmed)) codeScore++; 68 + if (/^\s{2,}\S/.test(line) || /^\t+\S/.test(line)) codeScore++; 69 + 70 + // Markdown signals 71 + if (/^#{1,6}\s/.test(trimmed)) markdownScore += 3; 72 + if (/\[.+\]\(.+\)/.test(trimmed)) markdownScore += 2; 73 + if (/^(\*\*|__).+(\*\*|__)/.test(trimmed) || /(\*|_)\S.*\1/.test(trimmed)) markdownScore++; 74 + if (/^[-*+]\s/.test(trimmed) || /^\d+\.\s/.test(trimmed)) markdownScore += 2; 75 + if (/^>\s/.test(trimmed)) markdownScore += 2; 76 + } 77 + 78 + // Code fences count as markdown (it's markdown containing code) 79 + if (/^```/m.test(text)) markdownScore += 5; 80 + 81 + const threshold = 3; 82 + if (codeScore >= threshold && codeScore > markdownScore * 1.5) return 'code'; 83 + if (markdownScore >= threshold && markdownScore > codeScore * 1.5) return 'markdown'; 84 + return null; 85 + } 86 + 87 + function handleTextPaste(text: string) { 88 + const detected = detectTextType(text); 89 + if (detected === 'code') { 90 + publishCodeDirect(text); 91 + } else if (detected === 'markdown') { 92 + publishMarkdownDirect(text); 93 + } else { 94 + pastedText = text; 95 + createMode = 'pick-text'; 96 + showCreateModal = true; 97 + } 98 + } 99 + 100 + function openCreateModal() { 101 + createMode = 'pick'; 102 + showCreateModal = true; 103 + } 104 + 105 + function closeCreateModal() { 106 + showCreateModal = false; 107 + } 108 + 109 + function isInputFocused(): boolean { 110 + const el = document.activeElement; 111 + return !!el && (el.tagName === 'INPUT' || el.tagName === 'TEXTAREA'); 112 + } 113 + 114 + function handleKeydown(event: KeyboardEvent) { 115 + if (!user.isLoggedIn) return; 116 + 117 + // When modal is open in pick mode, handle type shortcuts 118 + if (showCreateModal && (createMode === 'pick' || createMode === 'pick-text')) { 119 + switch (event.key.toLowerCase()) { 120 + case 'i': 121 + event.preventDefault(); 122 + closeCreateModal(); 123 + fileInput?.click(); 124 + return; 125 + case 'v': 126 + event.preventDefault(); 127 + closeCreateModal(); 128 + fileInput?.click(); 129 + return; 130 + case 'm': 131 + event.preventDefault(); 132 + if (createMode === 'pick-text') { 133 + markdownContent = pastedText; 134 + pastedText = ''; 135 + } 136 + createMode = 'markdown'; 137 + return; 138 + case 'c': 139 + event.preventDefault(); 140 + if (createMode === 'pick-text') { 141 + codeContent = pastedText; 142 + pastedText = ''; 143 + } 144 + createMode = 'code'; 145 + return; 146 + case 'escape': 147 + closeCreateModal(); 148 + return; 149 + } 150 + return; 151 + } 152 + 153 + if (isInputFocused()) return; 154 + if (showCreateModal) return; 155 + 156 + if (event.key === '+' || event.key === '=') { 157 + openCreateModal(); 158 + } 159 + } 39 160 40 161 async function loadItems() { 41 162 if (!user.did) return; 42 163 loadingImages = true; 43 164 try { 44 - const [imageRecords, videoRecords, markdownRecords] = await Promise.all([ 165 + const [imageRecords, videoRecords, markdownRecords, codeRecords] = await Promise.all([ 45 166 listRecords({ collection: 'pics.atmo.image', limit: 0 }), 46 167 listRecords({ collection: 'pics.atmo.video', limit: 0 }), 47 - listRecords({ collection: 'pics.atmo.markdown', limit: 0 }) 168 + listRecords({ collection: 'pics.atmo.markdown', limit: 0 }), 169 + listRecords({ collection: 'pics.atmo.code', limit: 0 }) 48 170 ]); 49 171 const imageItems: MediaRecord[] = (imageRecords as any[]).map((r) => ({ 50 172 ...r, ··· 58 180 ...r, 59 181 type: 'markdown' as const 60 182 })); 61 - const all = [...imageItems, ...videoItems, ...markdownItems]; 183 + const codeItems: MediaRecord[] = (codeRecords as any[]).map((r) => ({ 184 + ...r, 185 + type: 'code' as const 186 + })); 187 + const all = [...imageItems, ...videoItems, ...markdownItems, ...codeItems]; 62 188 all.sort((a, b) => { 63 189 const dateA = (a.value as any).createdAt || ''; 64 190 const dateB = (b.value as any).createdAt || ''; ··· 142 268 return uploadVideo(file); 143 269 } 144 270 if (!file.type.startsWith('image/')) { 145 - feedbackMessage = 'Please drop an image or video file'; 271 + const handled = await uploadFile(file); 272 + if (!handled) feedbackMessage = 'Unsupported file type'; 146 273 return; 147 274 } 148 275 ··· 235 362 } 236 363 237 364 function handlePaste(event: ClipboardEvent) { 365 + if (!user.isLoggedIn) return; 238 366 const pasteItems = event.clipboardData?.items; 239 367 if (!pasteItems) return; 240 368 369 + // Check for files first (images/videos) 241 370 for (const item of pasteItems) { 242 371 if (item.type.startsWith('image/') || item.type.startsWith('video/')) { 243 372 const file = item.getAsFile(); 244 373 if (!file) return; 245 - 246 - if (!user.isLoggedIn) { 247 - feedbackMessage = 'Please log in to upload'; 248 - return; 249 - } 250 - 251 374 uploadImage(file); 252 375 return; 253 376 } 377 + } 378 + 379 + // Check for text — only if no input/textarea is focused 380 + const active = document.activeElement; 381 + if (active && (active.tagName === 'INPUT' || active.tagName === 'TEXTAREA')) return; 382 + 383 + const text = event.clipboardData?.getData('text/plain'); 384 + if (text && text.trim().length > 0) { 385 + handleTextPaste(text); 254 386 } 255 387 } 256 388 ··· 289 421 } 290 422 markdownTitle = ''; 291 423 markdownContent = ''; 292 - showMarkdownEditor = false; 424 + showCreateModal = false; 293 425 } catch (error) { 294 426 console.error('Publish failed:', error); 295 427 feedbackMessage = 'Publish failed. Please try again.'; ··· 298 430 } 299 431 } 300 432 433 + async function publishCode() { 434 + if (!codeContent.trim()) { 435 + feedbackMessage = 'Please write some code'; 436 + return; 437 + } 438 + isPublishingCode = true; 439 + feedbackMessage = ''; 440 + try { 441 + const rkey = createTID(); 442 + const record = { 443 + $type: 'pics.atmo.code', 444 + title: codeTitle.trim() || undefined, 445 + content: codeContent, 446 + createdAt: new Date().toISOString() 447 + }; 448 + await putRecord({ 449 + collection: 'pics.atmo.code', 450 + rkey, 451 + record 452 + }); 453 + const newItem: MediaRecord = { 454 + uri: `at://${user.did}/pics.atmo.code/${rkey}`, 455 + value: record, 456 + type: 'code' 457 + }; 458 + items = [newItem, ...items]; 459 + const shareLink = getShareLinkFromUri(newItem.uri, user.profile?.handle); 460 + try { 461 + await navigator.clipboard.writeText(shareLink); 462 + feedbackMessage = 'Link copied to clipboard!'; 463 + } catch { 464 + feedbackMessage = 'Code published!'; 465 + } 466 + codeTitle = ''; 467 + codeContent = ''; 468 + showCreateModal = false; 469 + } catch (error) { 470 + console.error('Publish failed:', error); 471 + feedbackMessage = 'Publish failed. Please try again.'; 472 + } finally { 473 + isPublishingCode = false; 474 + } 475 + } 476 + 477 + async function publishMarkdownDirect(content: string, title?: string) { 478 + feedbackMessage = ''; 479 + try { 480 + const rkey = createTID(); 481 + const record = { 482 + $type: 'pics.atmo.markdown', 483 + title: title || undefined, 484 + content, 485 + createdAt: new Date().toISOString() 486 + }; 487 + await putRecord({ collection: 'pics.atmo.markdown', rkey, record }); 488 + const newItem: MediaRecord = { 489 + uri: `at://${user.did}/pics.atmo.markdown/${rkey}`, 490 + value: record, 491 + type: 'markdown' 492 + }; 493 + items = [newItem, ...items]; 494 + const shareLink = getShareLinkFromUri(newItem.uri, user.profile?.handle); 495 + try { 496 + await navigator.clipboard.writeText(shareLink); 497 + feedbackMessage = 'Link copied to clipboard!'; 498 + } catch { 499 + feedbackMessage = 'Markdown published!'; 500 + } 501 + } catch (error) { 502 + console.error('Publish failed:', error); 503 + feedbackMessage = 'Publish failed. Please try again.'; 504 + } 505 + } 506 + 507 + async function publishCodeDirect(content: string, title?: string) { 508 + feedbackMessage = ''; 509 + try { 510 + const rkey = createTID(); 511 + const record = { 512 + $type: 'pics.atmo.code', 513 + title: title || undefined, 514 + content, 515 + createdAt: new Date().toISOString() 516 + }; 517 + await putRecord({ collection: 'pics.atmo.code', rkey, record }); 518 + const newItem: MediaRecord = { 519 + uri: `at://${user.did}/pics.atmo.code/${rkey}`, 520 + value: record, 521 + type: 'code' 522 + }; 523 + items = [newItem, ...items]; 524 + const shareLink = getShareLinkFromUri(newItem.uri, user.profile?.handle); 525 + try { 526 + await navigator.clipboard.writeText(shareLink); 527 + feedbackMessage = 'Link copied to clipboard!'; 528 + } catch { 529 + feedbackMessage = 'Code published!'; 530 + } 531 + } catch (error) { 532 + console.error('Publish failed:', error); 533 + feedbackMessage = 'Publish failed. Please try again.'; 534 + } 535 + } 536 + 537 + const CODE_EXTENSIONS = new Set([ 538 + 'js', 'ts', 'jsx', 'tsx', 'mjs', 'mts', 'cjs', 'cts', 539 + 'py', 'rb', 'go', 'rs', 'c', 'cpp', 'h', 'hpp', 'cc', 540 + 'java', 'kt', 'kts', 'swift', 'cs', 'fs', 541 + 'sh', 'bash', 'zsh', 'fish', 542 + 'css', 'scss', 'sass', 'less', 543 + 'html', 'xml', 'svg', 544 + 'json', 'yaml', 'yml', 'toml', 'ini', 'cfg', 545 + 'sql', 'lua', 'php', 'pl', 'r', 'scala', 546 + 'zig', 'hs', 'elm', 'ex', 'exs', 'clj', 'cljs', 547 + 'dart', 'vue', 'svelte', 'astro', 548 + 'dockerfile', 'makefile', 'cmake', 549 + 'tf', 'hcl', 'nix', 'dhall', 550 + 'graphql', 'gql', 'proto', 'wasm', 'wat' 551 + ]); 552 + 553 + const MARKDOWN_EXTENSIONS = new Set(['md', 'mdx', 'markdown', 'mdown', 'mkd']); 554 + 555 + function getFileExtension(name: string): string { 556 + const lower = name.toLowerCase(); 557 + // Handle extensionless known filenames 558 + if (['dockerfile', 'makefile', 'rakefile', 'gemfile', 'cmakelists.txt'].includes(lower)) { 559 + return 'dockerfile'; 560 + } 561 + const dot = lower.lastIndexOf('.'); 562 + return dot >= 0 ? lower.slice(dot + 1) : ''; 563 + } 564 + 565 + async function uploadFile(file: File) { 566 + const ext = getFileExtension(file.name); 567 + 568 + if (MARKDOWN_EXTENSIONS.has(ext)) { 569 + const text = await file.text(); 570 + await publishMarkdownDirect(text, file.name); 571 + return true; 572 + } 573 + 574 + if (CODE_EXTENSIONS.has(ext)) { 575 + const text = await file.text(); 576 + await publishCodeDirect(text, file.name); 577 + return true; 578 + } 579 + 580 + return false; 581 + } 582 + 583 + function getBadgeColor(type: MediaRecord['type']): string { 584 + switch (type) { 585 + case 'image': return 'bg-cyan-600'; 586 + case 'video': return 'bg-rose-600'; 587 + case 'markdown': return 'bg-amber-600'; 588 + case 'code': return 'bg-purple-600'; 589 + } 590 + } 591 + 301 592 function getThumbnailUrl(record: MediaRecord): string | undefined { 302 593 if (!user.did) return; 303 594 if (record.type === 'video') { ··· 315 606 if (item.type === 'markdown') { 316 607 return resolve('/m/[repo]/[rkey]', { repo: handle, rkey }); 317 608 } 609 + if (item.type === 'code') { 610 + return resolve('/c/[repo]/[rkey]', { repo: handle, rkey }); 611 + } 318 612 return resolve('/i/[repo]/[rkey]', { repo: handle, rkey }); 319 613 } 320 614 ··· 335 629 function getItemLabel(item: MediaRecord): string { 336 630 if (item.type === 'video') return 'video'; 337 631 if (item.type === 'markdown') return 'post'; 632 + if (item.type === 'code') return 'snippet'; 338 633 return 'image'; 339 634 } 340 635 341 636 function getItemCollection(item: MediaRecord): AllowedCollection { 342 637 if (item.type === 'video') return 'pics.atmo.video'; 343 638 if (item.type === 'markdown') return 'pics.atmo.markdown'; 639 + if (item.type === 'code') return 'pics.atmo.code'; 344 640 return 'pics.atmo.image'; 345 641 } 346 642 ··· 374 670 } 375 671 </script> 376 672 377 - <svelte:window onpaste={handlePaste} /> 673 + <svelte:window onpaste={handlePaste} onkeydown={handleKeydown} /> 378 674 379 675 <!-- svelte-ignore a11y_no_static_element_interactions --> 380 676 <div ··· 430 726 </div> 431 727 {/if} 432 728 729 + <!-- Create modal --> 730 + {#if showCreateModal} 731 + <!-- svelte-ignore a11y_no_static_element_interactions --> 732 + <div 733 + class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm" 734 + onclick={(e) => { if (e.target === e.currentTarget) closeCreateModal(); }} 735 + onkeydown={(e) => { if (e.key === 'Escape') closeCreateModal(); }} 736 + > 737 + <div bind:this={modalEl} class="bg-base-50 dark:bg-base-900 border-base-200 dark:border-base-800 mx-4 w-full max-w-lg rounded-2xl border p-6 shadow-xl"> 738 + {#if createMode === 'pick'} 739 + <h2 class="mb-4 text-lg font-semibold">What do you want to share?</h2> 740 + <div class="grid grid-cols-2 gap-3"> 741 + <button 742 + type="button" 743 + class="bg-base-100 dark:bg-base-800 hover:bg-base-200 dark:hover:bg-base-700 relative flex flex-col items-center gap-2 rounded-xl p-4 transition-colors" 744 + onclick={() => { closeCreateModal(); fileInput?.click(); }} 745 + > 746 + <svg xmlns="http://www.w3.org/2000/svg" class="h-8 w-8" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5"> 747 + <path stroke-linecap="round" stroke-linejoin="round" d="M2.25 15.75l5.159-5.159a2.25 2.25 0 013.182 0l5.159 5.159m-1.5-1.5l1.409-1.41a2.25 2.25 0 013.182 0l2.909 2.91m-18 3.75h16.5a1.5 1.5 0 001.5-1.5V6a1.5 1.5 0 00-1.5-1.5H3.75A1.5 1.5 0 002.25 6v12a1.5 1.5 0 001.5 1.5zm10.5-11.25h.008v.008h-.008V8.25zm.375 0a.375.375 0 11-.75 0 .375.375 0 01.75 0z" /> 748 + </svg> 749 + <span class="text-sm font-medium">Image</span> 750 + <kbd class="text-base-400 absolute top-2 right-2 text-[10px] font-mono">I</kbd> 751 + </button> 752 + <button 753 + type="button" 754 + class="bg-base-100 dark:bg-base-800 hover:bg-base-200 dark:hover:bg-base-700 relative flex flex-col items-center gap-2 rounded-xl p-4 transition-colors" 755 + onclick={() => { closeCreateModal(); fileInput?.click(); }} 756 + > 757 + <svg xmlns="http://www.w3.org/2000/svg" class="h-8 w-8" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5"> 758 + <path stroke-linecap="round" stroke-linejoin="round" d="m15.75 10.5 4.72-4.72a.75.75 0 0 1 1.28.53v11.38a.75.75 0 0 1-1.28.53l-4.72-4.72M4.5 18.75h9a2.25 2.25 0 0 0 2.25-2.25v-9a2.25 2.25 0 0 0-2.25-2.25h-9A2.25 2.25 0 0 0 2.25 7.5v9a2.25 2.25 0 0 0 2.25 2.25Z" /> 759 + </svg> 760 + <span class="text-sm font-medium">Video</span> 761 + <kbd class="text-base-400 absolute top-2 right-2 text-[10px] font-mono">V</kbd> 762 + </button> 763 + <button 764 + type="button" 765 + class="bg-base-100 dark:bg-base-800 hover:bg-base-200 dark:hover:bg-base-700 relative flex flex-col items-center gap-2 rounded-xl p-4 transition-colors" 766 + onclick={() => (createMode = 'markdown')} 767 + > 768 + <svg xmlns="http://www.w3.org/2000/svg" class="h-8 w-8" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5"> 769 + <path stroke-linecap="round" stroke-linejoin="round" d="M19.5 14.25v-2.625a3.375 3.375 0 00-3.375-3.375h-1.5A1.125 1.125 0 0113.5 7.125v-1.5a3.375 3.375 0 00-3.375-3.375H8.25m0 12.75h7.5m-7.5 3H12M10.5 2.25H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 00-9-9z" /> 770 + </svg> 771 + <span class="text-sm font-medium">Markdown</span> 772 + <kbd class="text-base-400 absolute top-2 right-2 text-[10px] font-mono">M</kbd> 773 + </button> 774 + <button 775 + type="button" 776 + class="bg-base-100 dark:bg-base-800 hover:bg-base-200 dark:hover:bg-base-700 relative flex flex-col items-center gap-2 rounded-xl p-4 transition-colors" 777 + onclick={() => (createMode = 'code')} 778 + > 779 + <svg xmlns="http://www.w3.org/2000/svg" class="h-8 w-8" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5"> 780 + <path stroke-linecap="round" stroke-linejoin="round" d="M17.25 6.75L22.5 12l-5.25 5.25m-10.5 0L1.5 12l5.25-5.25m7.5-3l-4.5 16.5" /> 781 + </svg> 782 + <span class="text-sm font-medium">Code</span> 783 + <kbd class="text-base-400 absolute top-2 right-2 text-[10px] font-mono">C</kbd> 784 + </button> 785 + </div> 786 + {:else if createMode === 'pick-text'} 787 + <h2 class="mb-2 text-lg font-semibold">What type of text is this?</h2> 788 + <p class="text-base-500 mb-4 text-sm">We couldn't auto-detect the format. Please choose:</p> 789 + <pre class="bg-base-100 dark:bg-base-800 text-base-500 mb-4 max-h-32 overflow-hidden rounded-lg p-3 text-xs font-mono">{pastedText.slice(0, 300)}{pastedText.length > 300 ? '...' : ''}</pre> 790 + <div class="grid grid-cols-2 gap-3"> 791 + <button 792 + type="button" 793 + class="bg-base-100 dark:bg-base-800 hover:bg-base-200 dark:hover:bg-base-700 relative flex flex-col items-center gap-2 rounded-xl p-4 transition-colors" 794 + onclick={() => { markdownContent = pastedText; pastedText = ''; createMode = 'markdown'; }} 795 + > 796 + <svg xmlns="http://www.w3.org/2000/svg" class="h-8 w-8" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5"> 797 + <path stroke-linecap="round" stroke-linejoin="round" d="M19.5 14.25v-2.625a3.375 3.375 0 00-3.375-3.375h-1.5A1.125 1.125 0 0113.5 7.125v-1.5a3.375 3.375 0 00-3.375-3.375H8.25m0 12.75h7.5m-7.5 3H12M10.5 2.25H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 00-9-9z" /> 798 + </svg> 799 + <span class="text-sm font-medium">Markdown</span> 800 + <kbd class="text-base-400 absolute top-2 right-2 text-[10px] font-mono">M</kbd> 801 + </button> 802 + <button 803 + type="button" 804 + class="bg-base-100 dark:bg-base-800 hover:bg-base-200 dark:hover:bg-base-700 relative flex flex-col items-center gap-2 rounded-xl p-4 transition-colors" 805 + onclick={() => { codeContent = pastedText; pastedText = ''; createMode = 'code'; }} 806 + > 807 + <svg xmlns="http://www.w3.org/2000/svg" class="h-8 w-8" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5"> 808 + <path stroke-linecap="round" stroke-linejoin="round" d="M17.25 6.75L22.5 12l-5.25 5.25m-10.5 0L1.5 12l5.25-5.25m7.5-3l-4.5 16.5" /> 809 + </svg> 810 + <span class="text-sm font-medium">Code</span> 811 + <kbd class="text-base-400 absolute top-2 right-2 text-[10px] font-mono">C</kbd> 812 + </button> 813 + </div> 814 + {:else if createMode === 'markdown'} 815 + <div class="mb-4 flex items-center gap-2"> 816 + <button type="button" aria-label="Back" class="text-base-500 hover:text-base-700 dark:hover:text-base-300" onclick={() => (createMode = 'pick')}> 817 + <svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"> 818 + <path stroke-linecap="round" stroke-linejoin="round" d="M15.75 19.5L8.25 12l7.5-7.5" /> 819 + </svg> 820 + </button> 821 + <h2 class="text-lg font-semibold">Write markdown</h2> 822 + </div> 823 + <div class="flex flex-col gap-3"> 824 + <input 825 + type="text" 826 + placeholder="Title (optional)" 827 + bind:value={markdownTitle} 828 + class="dark:bg-base-800 bg-base-100 border-base-300 dark:border-base-700 rounded-lg border px-3 py-2 text-sm" 829 + /> 830 + <textarea 831 + placeholder="Write your markdown here..." 832 + bind:value={markdownContent} 833 + rows="8" 834 + class="dark:bg-base-800 bg-base-100 border-base-300 dark:border-base-700 rounded-lg border px-3 py-2 text-sm font-mono" 835 + ></textarea> 836 + <Button disabled={isPublishingMarkdown} onclick={publishMarkdown}> 837 + {isPublishingMarkdown ? 'Publishing...' : 'Publish'} 838 + </Button> 839 + </div> 840 + {:else if createMode === 'code'} 841 + <div class="mb-4 flex items-center gap-2"> 842 + <button type="button" aria-label="Back" class="text-base-500 hover:text-base-700 dark:hover:text-base-300" onclick={() => (createMode = 'pick')}> 843 + <svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"> 844 + <path stroke-linecap="round" stroke-linejoin="round" d="M15.75 19.5L8.25 12l7.5-7.5" /> 845 + </svg> 846 + </button> 847 + <h2 class="text-lg font-semibold">Share code</h2> 848 + </div> 849 + <div class="flex flex-col gap-3"> 850 + <input 851 + type="text" 852 + placeholder="Title / filename (optional)" 853 + bind:value={codeTitle} 854 + class="dark:bg-base-800 bg-base-100 border-base-300 dark:border-base-700 rounded-lg border px-3 py-2 text-sm font-mono" 855 + /> 856 + <textarea 857 + placeholder="Paste your code here..." 858 + bind:value={codeContent} 859 + rows="10" 860 + class="dark:bg-base-800 bg-base-100 border-base-300 dark:border-base-700 rounded-lg border px-3 py-2 text-sm font-mono" 861 + ></textarea> 862 + <Button disabled={isPublishingCode} onclick={publishCode}> 863 + {isPublishingCode ? 'Publishing...' : 'Publish'} 864 + </Button> 865 + </div> 866 + {/if} 867 + </div> 868 + </div> 869 + {/if} 870 + 433 871 <div class="mx-auto my-4 max-w-3xl px-4 md:my-32"> 434 872 <h1 class="text-3xl font-bold">atmo.pics</h1> 435 873 <h1 class="my-2">quick atproto image, video and markdown sharing</h1> ··· 467 905 onchange={handleFileInput} 468 906 /> 469 907 470 - <Button class="mt-8" disabled={isUploading} onclick={() => fileInput?.click()}> 471 - {isUploading ? 'Uploading...' : 'Upload image or video'} 472 - </Button> 473 - 474 - <Button class="mt-2" onclick={() => (showMarkdownEditor = !showMarkdownEditor)}> 475 - {showMarkdownEditor ? 'Cancel writing' : 'Write markdown'} 476 - </Button> 477 - 478 - {#if showMarkdownEditor} 479 - <div class="mt-4 flex flex-col gap-3"> 480 - <input 481 - type="text" 482 - placeholder="Title (optional)" 483 - bind:value={markdownTitle} 484 - class="dark:bg-base-800 bg-base-100 border-base-300 dark:border-base-700 rounded-lg border px-3 py-2 text-sm" 485 - /> 486 - <textarea 487 - placeholder="Write your markdown here..." 488 - bind:value={markdownContent} 489 - rows="8" 490 - class="dark:bg-base-800 bg-base-100 border-base-300 dark:border-base-700 rounded-lg border px-3 py-2 text-sm font-mono" 491 - ></textarea> 492 - <Button disabled={isPublishingMarkdown} onclick={publishMarkdown}> 493 - {isPublishingMarkdown ? 'Publishing...' : 'Publish'} 494 - </Button> 495 - </div> 496 - {/if} 908 + <Button class="mt-8" onclick={openCreateModal}>Create new</Button> 497 909 498 910 <div 499 911 class="mt-4 h-5 text-sm" ··· 511 923 {:else if items.length > 0} 512 924 <div class="mt-8 grid grid-cols-2 gap-4 sm:grid-cols-3"> 513 925 {#each items as item (item.uri)} 514 - {#if item.type === 'markdown'} 926 + {#if item.type === 'markdown' || item.type === 'code'} 515 927 <a 516 928 href={getItemLink(item)} 517 929 class="group bg-base-200 dark:bg-base-800 relative aspect-square overflow-hidden rounded-xl p-4 flex flex-col" 518 930 > 519 931 {#if (item.value as any).title} 520 - <div class="font-semibold text-sm mb-1 truncate">{(item.value as any).title}</div> 932 + <div class="font-semibold text-sm mb-1 truncate {item.type === 'code' ? 'font-mono' : ''}">{(item.value as any).title}</div> 521 933 {/if} 522 - <div class="text-xs text-base-500 line-clamp-[8] flex-1 overflow-hidden whitespace-pre-wrap break-words"> 934 + <div class="text-xs text-base-500 line-clamp-[8] flex-1 overflow-hidden whitespace-pre-wrap break-words {item.type === 'code' ? 'font-mono' : ''}"> 523 935 {((item.value as any).content ?? '').slice(0, 200)} 524 936 </div> 937 + <span class="absolute bottom-2 left-2 rounded-full {getBadgeColor(item.type)} px-2 py-0.5 text-[10px] font-medium text-white">{item.type}</span> 525 938 <div 526 939 class="absolute top-2 right-2 flex gap-1 opacity-0 transition-opacity group-hover:opacity-100 pointer-coarse:opacity-100" 527 940 > ··· 597 1010 </div> 598 1011 </div> 599 1012 {/if} 1013 + <span class="absolute bottom-2 left-2 rounded-full {getBadgeColor(item.type)} px-2 py-0.5 text-[10px] font-medium text-white">{item.type}</span> 600 1014 <div 601 1015 class="absolute top-2 right-2 flex gap-1 opacity-0 transition-opacity group-hover:opacity-100 pointer-coarse:opacity-100" 602 1016 > ··· 653 1067 {/if} 654 1068 {/if} 655 1069 </div> 1070 + 1071 + {#if user.isLoggedIn} 1072 + <button 1073 + type="button" 1074 + class="bg-accent-600 hover:bg-accent-700 fixed right-6 bottom-6 z-40 flex h-14 w-14 items-center justify-center rounded-full text-white shadow-lg transition-colors" 1075 + onclick={openCreateModal} 1076 + title="Create new (+)" 1077 + > 1078 + <svg xmlns="http://www.w3.org/2000/svg" class="h-7 w-7" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2.5"> 1079 + <path stroke-linecap="round" stroke-linejoin="round" d="M12 4.5v15m7.5-7.5h-15" /> 1080 + </svg> 1081 + </button> 1082 + {/if} 656 1083 </div>
+23
src/routes/c/[repo]/[rkey]/+page.server.ts
··· 1 + import { getRecord, resolveHandle } from '$lib/atproto'; 2 + import { isDid, isHandle } from '@atcute/lexicons/syntax'; 3 + import { error } from '@sveltejs/kit'; 4 + 5 + export async function load({ params }) { 6 + const repo = params.repo; 7 + 8 + const did = isDid(repo) 9 + ? repo 10 + : isHandle(repo) 11 + ? await resolveHandle({ handle: repo }) 12 + : undefined; 13 + 14 + if (!did) throw error(404, 'User not found'); 15 + 16 + const record = await getRecord({ did, collection: 'pics.atmo.code', rkey: params.rkey }); 17 + 18 + if (!record.value.content) { 19 + throw error(404, 'Code not found'); 20 + } 21 + 22 + return { record, did }; 23 + }
+181
src/routes/c/[repo]/[rkey]/+page.svelte
··· 1 + <script lang="ts"> 2 + import { page } from '$app/state'; 3 + import { getDetailedProfile } from '$lib/atproto'; 4 + import { getCodeShareLink } from '$lib'; 5 + import { resolve } from '$app/paths'; 6 + import hljs from 'highlight.js'; 7 + import 'highlight.js/styles/github-dark.min.css'; 8 + import { onMount } from 'svelte'; 9 + 10 + let createdAt = $state<string | undefined>(undefined); 11 + let uploader = $state<{ displayName?: string; handle?: string; avatar?: string } | undefined>( 12 + undefined 13 + ); 14 + let copied = $state(false); 15 + 16 + function formatDate(isoString: string): string { 17 + const date = new Date(isoString); 18 + return date.toLocaleDateString(undefined, { 19 + year: 'numeric', 20 + month: 'short', 21 + day: 'numeric', 22 + hour: '2-digit', 23 + minute: '2-digit' 24 + }); 25 + } 26 + 27 + async function copyLink() { 28 + try { 29 + if (!page.params.repo || !page.params.rkey) return; 30 + await navigator.clipboard.writeText(getCodeShareLink(page.params.repo, page.params.rkey)); 31 + copied = true; 32 + setTimeout(() => (copied = false), 2000); 33 + } catch { 34 + // Ignore errors 35 + } 36 + } 37 + 38 + async function copyCode() { 39 + try { 40 + await navigator.clipboard.writeText(code); 41 + copied = true; 42 + setTimeout(() => (copied = false), 2000); 43 + } catch { 44 + // Ignore errors 45 + } 46 + } 47 + 48 + $effect(() => { 49 + getDetailedProfile({ did: data.did }).then((profile) => { 50 + if (profile) { 51 + uploader = { 52 + displayName: profile.displayName, 53 + handle: profile.handle, 54 + avatar: profile.avatar 55 + }; 56 + } 57 + }); 58 + if (data.record?.value?.createdAt) { 59 + createdAt = data.record.value.createdAt as string; 60 + } 61 + }); 62 + 63 + let { data } = $props(); 64 + 65 + const title = $derived((data.record?.value?.title as string) || ''); 66 + const code = $derived((data.record?.value?.content as string) || ''); 67 + 68 + let mounted = $state(false); 69 + onMount(() => (mounted = true)); 70 + 71 + const highlighted = $derived.by(() => { 72 + if (!mounted || !code) return { html: '', language: '' }; 73 + const result = hljs.highlightAuto(code); 74 + return { html: result.value, language: result.language || '' }; 75 + }); 76 + const highlightedHtml = $derived(highlighted.html); 77 + const detectedLanguage = $derived(highlighted.language); 78 + </script> 79 + 80 + <svelte:head> 81 + <meta 82 + property="og:image" 83 + content={resolve('/c/[repo]/[rkey]/og.png', { 84 + repo: page.params.repo ?? '', 85 + rkey: page.params.rkey ?? '' 86 + })} 87 + /> 88 + <meta 89 + name="twitter:image" 90 + content={resolve('/c/[repo]/[rkey]/og.png', { 91 + repo: page.params.repo ?? '', 92 + rkey: page.params.rkey ?? '' 93 + })} 94 + /> 95 + <meta name="twitter:card" content="summary_large_image" /> 96 + 97 + <title>{title || 'Code snippet'}</title> 98 + <meta property="og:title" content={title || 'Code snippet'} /> 99 + <meta name="twitter:title" content={title || 'Code snippet'} /> 100 + </svelte:head> 101 + 102 + <div class="dark:bg-base-900 min-h-screen bg-white"> 103 + <div class="mx-auto max-w-4xl px-4 py-8 md:py-16"> 104 + {#if title} 105 + <h1 class="mb-4 text-2xl font-bold font-mono">{title}</h1> 106 + {/if} 107 + 108 + <!-- Uploader info + actions --> 109 + <div class="mb-6 flex items-center gap-3"> 110 + {#if uploader?.avatar} 111 + <img src={uploader.avatar} alt="" class="h-10 w-10 rounded-full" /> 112 + {/if} 113 + <div> 114 + {#if uploader?.displayName || uploader?.handle} 115 + <div class="font-medium">{uploader.displayName || uploader.handle}</div> 116 + {/if} 117 + {#if createdAt} 118 + <div class="text-base-500 text-sm">{formatDate(createdAt)}</div> 119 + {/if} 120 + </div> 121 + <div class="ml-auto flex items-center gap-2"> 122 + {#if detectedLanguage} 123 + <span class="text-base-500 text-xs font-mono">{detectedLanguage}</span> 124 + {/if} 125 + <button 126 + type="button" 127 + class="bg-base-200 dark:bg-base-800 hover:bg-base-300 dark:hover:bg-base-700 flex cursor-pointer items-center gap-2 rounded-full px-4 py-2 text-sm transition-colors" 128 + onclick={copyCode} 129 + > 130 + <svg 131 + xmlns="http://www.w3.org/2000/svg" 132 + class="h-4 w-4" 133 + fill="none" 134 + viewBox="0 0 24 24" 135 + stroke="currentColor" 136 + stroke-width="2" 137 + > 138 + <path 139 + stroke-linecap="round" 140 + stroke-linejoin="round" 141 + d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z" 142 + /> 143 + </svg> 144 + {copied ? 'Copied!' : 'Copy code'} 145 + </button> 146 + <button 147 + type="button" 148 + class="bg-base-200 dark:bg-base-800 hover:bg-base-300 dark:hover:bg-base-700 flex cursor-pointer items-center gap-2 rounded-full px-4 py-2 text-sm transition-colors" 149 + onclick={copyLink} 150 + > 151 + <svg 152 + xmlns="http://www.w3.org/2000/svg" 153 + class="h-4 w-4" 154 + fill="none" 155 + viewBox="0 0 24 24" 156 + stroke="currentColor" 157 + stroke-width="2" 158 + > 159 + <path 160 + stroke-linecap="round" 161 + stroke-linejoin="round" 162 + d="M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101" 163 + /> 164 + <path 165 + stroke-linecap="round" 166 + stroke-linejoin="round" 167 + d="M10.172 13.828a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.102 1.101" 168 + /> 169 + </svg> 170 + Copy link 171 + </button> 172 + </div> 173 + </div> 174 + 175 + <!-- Code block --> 176 + <div class="overflow-hidden rounded-xl"> 177 + <pre 178 + class="hljs overflow-x-auto p-6 text-sm leading-relaxed"><code>{#if mounted}{@html highlightedHtml}{:else}{code}{/if}</code></pre> 179 + </div> 180 + </div> 181 + </div>
+41
src/routes/c/[repo]/[rkey]/og.png/+server.ts
··· 1 + import { getRecord, resolveHandle } from '$lib/atproto'; 2 + import { isDid, isHandle } from '@atcute/lexicons/syntax'; 3 + import { error } from '@sveltejs/kit'; 4 + import { ImageResponse } from '@ethercorps/sveltekit-og'; 5 + 6 + export async function GET({ params }) { 7 + const repo = params.repo; 8 + 9 + const did = isDid(repo) 10 + ? repo 11 + : isHandle(repo) 12 + ? await resolveHandle({ handle: repo }) 13 + : undefined; 14 + 15 + if (!did) throw error(404, 'User not found'); 16 + 17 + const record = await getRecord({ did, collection: 'pics.atmo.code', rkey: params.rkey }); 18 + 19 + const title = (record.value.title as string) || 'Code snippet'; 20 + const content = (record.value.content as string) || ''; 21 + const preview = content.length > 400 ? content.slice(0, 400) + '...' : content; 22 + 23 + const ogWidth = 1200; 24 + const ogHeight = 630; 25 + 26 + const escapedTitle = title.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;'); 27 + const escapedPreview = preview 28 + .replace(/&/g, '&amp;') 29 + .replace(/</g, '&lt;') 30 + .replace(/>/g, '&gt;'); 31 + 32 + const htmlString = `<div style="display:flex;flex-direction:column;justify-content:center;padding:48px;width:${ogWidth}px;height:${ogHeight}px;background:#1e1e2e;color:#cdd6f4;font-family:monospace;"> 33 + <div style="font-size:36px;font-weight:bold;margin-bottom:24px;color:#89b4fa;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">${escapedTitle}</div> 34 + <div style="font-size:20px;color:#a6adc8;overflow:hidden;display:-webkit-box;-webkit-line-clamp:10;-webkit-box-orient:vertical;white-space:pre-wrap;">${escapedPreview}</div> 35 + </div>`; 36 + 37 + return new ImageResponse(htmlString, { 38 + width: ogWidth, 39 + height: ogHeight 40 + }); 41 + }