diff --git a/package.json b/package.json index 8ce5473..30bc9c2 100644 --- a/package.json +++ b/package.json @@ -51,6 +51,7 @@ "@sveltejs/adapter-cloudflare": "^7.2.6", "@types/dompurify": "^3.2.0", "dompurify": "^3.3.1", + "highlight.js": "^11.11.1", "marked": "^17.0.1", "wrangler": "^4.61.1" } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 19c7684..747ba0d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -20,6 +20,9 @@ importers: dompurify: specifier: ^3.3.1 version: 3.3.1 + highlight.js: + specifier: ^11.11.1 + version: 11.11.1 marked: specifier: ^17.0.1 version: 17.0.1 @@ -1534,6 +1537,10 @@ packages: resolution: {integrity: sha512-Ox1pJVrDCyGHMG9CFg1tmrRUMRPRsAWYc/PinY0XzJU4K7y7vjNoLKIQ7BR5UJMCxNN8EM1MNDmHWA/B3aZUuw==, tarball: https://registry.npmjs.org/hex-rgb/-/hex-rgb-4.3.0.tgz} engines: {node: '>=6'} + highlight.js@11.11.1: + resolution: {integrity: sha512-Xwwo44whKBVCYoliBQwaPvtd/2tYFkRQtXDWj1nackaV2JPXx3L0+Jvd8/qCJ2p+ML0/XVkJ2q+Mr+UVdpJK5w==, tarball: https://registry.npmjs.org/highlight.js/-/highlight.js-11.11.1.tgz} + engines: {node: '>=12.0.0'} + ignore@5.3.2: resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==, tarball: https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz} engines: {node: '>= 4'} @@ -3423,6 +3430,8 @@ snapshots: hex-rgb@4.3.0: {} + highlight.js@11.11.1: {} + ignore@5.3.2: {} ignore@7.0.5: {} diff --git a/src/lib/atproto/settings.ts b/src/lib/atproto/settings.ts index 034fb34..bcf2220 100644 --- a/src/lib/atproto/settings.ts +++ b/src/lib/atproto/settings.ts @@ -13,7 +13,7 @@ export const permissions = { // example: only allow create and delete // collections: ['xyz.statusphere.status?action=create&action=update'], - collections: ['pics.atmo.image', 'pics.atmo.video', 'pics.atmo.markdown'], + collections: ['pics.atmo.image', 'pics.atmo.video', 'pics.atmo.markdown', 'pics.atmo.code'], // what types of authenticated proxied requests you can make to services diff --git a/src/lib/index.ts b/src/lib/index.ts index 9781983..6ca7373 100644 --- a/src/lib/index.ts +++ b/src/lib/index.ts @@ -17,6 +17,10 @@ export function getShareLinkFromUri(uri: string, handle?: string): string { return getMarkdownShareLink(repo, parts.rkey); } + if (parts.collection === 'pics.atmo.code') { + return getCodeShareLink(repo, parts.rkey); + } + return getShareLink(repo, parts.rkey); } @@ -40,3 +44,10 @@ export function getMarkdownShareLink(repo: string, rkey: string): string { rkey: rkey })}`; } + +export function getCodeShareLink(repo: string, rkey: string): string { + return `${window.location.origin}${resolve('/c/[repo]/[rkey]', { + repo: repo, + rkey: rkey + })}`; +} diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte index f71af9f..509ff5d 100644 --- a/src/routes/+page.svelte +++ b/src/routes/+page.svelte @@ -16,12 +16,13 @@ import Button from '$lib/atproto/UI/Button.svelte'; import { loginModalState } from '$lib/atproto/UI/LoginModal.svelte'; import { resolve } from '$app/paths'; + import { tick } from 'svelte'; import { getShareLinkFromUri } from '$lib'; type MediaRecord = { uri: string; value: Record; - type: 'image' | 'video' | 'markdown'; + type: 'image' | 'video' | 'markdown' | 'code'; }; let fileInput: HTMLInputElement | undefined = $state(); @@ -32,19 +33,140 @@ let loadingImages = $state(false); let isDragging = $state(false); let dragCounter = 0; - let showMarkdownEditor = $state(false); + let showCreateModal = $state(false); + let createMode = $state<'pick' | 'pick-text' | 'markdown' | 'code'>('pick'); let markdownTitle = $state(''); let markdownContent = $state(''); let isPublishingMarkdown = $state(false); + let codeTitle = $state(''); + let codeContent = $state(''); + let isPublishingCode = $state(false); + + let pastedText = $state(''); + let modalEl = $state(); + + $effect(() => { + if ((createMode === 'markdown' || createMode === 'code') && modalEl) { + tick().then(() => { + const input = modalEl?.querySelector('input, textarea'); + input?.focus(); + }); + } + }); + + function detectTextType(text: string): 'code' | 'markdown' | null { + const lines = text.split('\n'); + let codeScore = 0; + let markdownScore = 0; + + for (const line of lines) { + const trimmed = line.trimEnd(); + // Code signals + if (/[;{}]\s*$/.test(trimmed)) codeScore += 2; + if (/^\s*(import|export|const|let|var|function|class|def|return|if|for|while|switch|try|catch)\b/.test(trimmed)) codeScore += 2; + if (/[=!<>]=|=>|->|\|\||&&/.test(trimmed)) codeScore++; + if (/^\s{2,}\S/.test(line) || /^\t+\S/.test(line)) codeScore++; + + // Markdown signals + if (/^#{1,6}\s/.test(trimmed)) markdownScore += 3; + if (/\[.+\]\(.+\)/.test(trimmed)) markdownScore += 2; + if (/^(\*\*|__).+(\*\*|__)/.test(trimmed) || /(\*|_)\S.*\1/.test(trimmed)) markdownScore++; + if (/^[-*+]\s/.test(trimmed) || /^\d+\.\s/.test(trimmed)) markdownScore += 2; + if (/^>\s/.test(trimmed)) markdownScore += 2; + } + + // Code fences count as markdown (it's markdown containing code) + if (/^```/m.test(text)) markdownScore += 5; + + const threshold = 3; + if (codeScore >= threshold && codeScore > markdownScore * 1.5) return 'code'; + if (markdownScore >= threshold && markdownScore > codeScore * 1.5) return 'markdown'; + return null; + } + + function handleTextPaste(text: string) { + const detected = detectTextType(text); + if (detected === 'code') { + publishCodeDirect(text); + } else if (detected === 'markdown') { + publishMarkdownDirect(text); + } else { + pastedText = text; + createMode = 'pick-text'; + showCreateModal = true; + } + } + + function openCreateModal() { + createMode = 'pick'; + showCreateModal = true; + } + + function closeCreateModal() { + showCreateModal = false; + } + + function isInputFocused(): boolean { + const el = document.activeElement; + return !!el && (el.tagName === 'INPUT' || el.tagName === 'TEXTAREA'); + } + + function handleKeydown(event: KeyboardEvent) { + if (!user.isLoggedIn) return; + + // When modal is open in pick mode, handle type shortcuts + if (showCreateModal && (createMode === 'pick' || createMode === 'pick-text')) { + switch (event.key.toLowerCase()) { + case 'i': + event.preventDefault(); + closeCreateModal(); + fileInput?.click(); + return; + case 'v': + event.preventDefault(); + closeCreateModal(); + fileInput?.click(); + return; + case 'm': + event.preventDefault(); + if (createMode === 'pick-text') { + markdownContent = pastedText; + pastedText = ''; + } + createMode = 'markdown'; + return; + case 'c': + event.preventDefault(); + if (createMode === 'pick-text') { + codeContent = pastedText; + pastedText = ''; + } + createMode = 'code'; + return; + case 'escape': + closeCreateModal(); + return; + } + return; + } + + if (isInputFocused()) return; + if (showCreateModal) return; + + if (event.key === '+' || event.key === '=') { + openCreateModal(); + } + } async function loadItems() { if (!user.did) return; loadingImages = true; try { - const [imageRecords, videoRecords, markdownRecords] = await Promise.all([ + const [imageRecords, videoRecords, markdownRecords, codeRecords] = await Promise.all([ listRecords({ collection: 'pics.atmo.image', limit: 0 }), listRecords({ collection: 'pics.atmo.video', limit: 0 }), - listRecords({ collection: 'pics.atmo.markdown', limit: 0 }) + listRecords({ collection: 'pics.atmo.markdown', limit: 0 }), + listRecords({ collection: 'pics.atmo.code', limit: 0 }) ]); const imageItems: MediaRecord[] = (imageRecords as any[]).map((r) => ({ ...r, @@ -58,7 +180,11 @@ ...r, type: 'markdown' as const })); - const all = [...imageItems, ...videoItems, ...markdownItems]; + const codeItems: MediaRecord[] = (codeRecords as any[]).map((r) => ({ + ...r, + type: 'code' as const + })); + const all = [...imageItems, ...videoItems, ...markdownItems, ...codeItems]; all.sort((a, b) => { const dateA = (a.value as any).createdAt || ''; const dateB = (b.value as any).createdAt || ''; @@ -142,7 +268,8 @@ return uploadVideo(file); } if (!file.type.startsWith('image/')) { - feedbackMessage = 'Please drop an image or video file'; + const handled = await uploadFile(file); + if (!handled) feedbackMessage = 'Unsupported file type'; return; } @@ -235,23 +362,28 @@ } function handlePaste(event: ClipboardEvent) { + if (!user.isLoggedIn) return; const pasteItems = event.clipboardData?.items; if (!pasteItems) return; + // Check for files first (images/videos) for (const item of pasteItems) { if (item.type.startsWith('image/') || item.type.startsWith('video/')) { const file = item.getAsFile(); if (!file) return; - - if (!user.isLoggedIn) { - feedbackMessage = 'Please log in to upload'; - return; - } - uploadImage(file); return; } } + + // Check for text — only if no input/textarea is focused + const active = document.activeElement; + if (active && (active.tagName === 'INPUT' || active.tagName === 'TEXTAREA')) return; + + const text = event.clipboardData?.getData('text/plain'); + if (text && text.trim().length > 0) { + handleTextPaste(text); + } } async function publishMarkdown() { @@ -289,7 +421,7 @@ } markdownTitle = ''; markdownContent = ''; - showMarkdownEditor = false; + showCreateModal = false; } catch (error) { console.error('Publish failed:', error); feedbackMessage = 'Publish failed. Please try again.'; @@ -298,6 +430,165 @@ } } + async function publishCode() { + if (!codeContent.trim()) { + feedbackMessage = 'Please write some code'; + return; + } + isPublishingCode = true; + feedbackMessage = ''; + try { + const rkey = createTID(); + const record = { + $type: 'pics.atmo.code', + title: codeTitle.trim() || undefined, + content: codeContent, + createdAt: new Date().toISOString() + }; + await putRecord({ + collection: 'pics.atmo.code', + rkey, + record + }); + const newItem: MediaRecord = { + uri: `at://${user.did}/pics.atmo.code/${rkey}`, + value: record, + type: 'code' + }; + items = [newItem, ...items]; + const shareLink = getShareLinkFromUri(newItem.uri, user.profile?.handle); + try { + await navigator.clipboard.writeText(shareLink); + feedbackMessage = 'Link copied to clipboard!'; + } catch { + feedbackMessage = 'Code published!'; + } + codeTitle = ''; + codeContent = ''; + showCreateModal = false; + } catch (error) { + console.error('Publish failed:', error); + feedbackMessage = 'Publish failed. Please try again.'; + } finally { + isPublishingCode = false; + } + } + + async function publishMarkdownDirect(content: string, title?: string) { + feedbackMessage = ''; + try { + const rkey = createTID(); + const record = { + $type: 'pics.atmo.markdown', + title: title || undefined, + content, + createdAt: new Date().toISOString() + }; + await putRecord({ collection: 'pics.atmo.markdown', rkey, record }); + const newItem: MediaRecord = { + uri: `at://${user.did}/pics.atmo.markdown/${rkey}`, + value: record, + type: 'markdown' + }; + items = [newItem, ...items]; + const shareLink = getShareLinkFromUri(newItem.uri, user.profile?.handle); + try { + await navigator.clipboard.writeText(shareLink); + feedbackMessage = 'Link copied to clipboard!'; + } catch { + feedbackMessage = 'Markdown published!'; + } + } catch (error) { + console.error('Publish failed:', error); + feedbackMessage = 'Publish failed. Please try again.'; + } + } + + async function publishCodeDirect(content: string, title?: string) { + feedbackMessage = ''; + try { + const rkey = createTID(); + const record = { + $type: 'pics.atmo.code', + title: title || undefined, + content, + createdAt: new Date().toISOString() + }; + await putRecord({ collection: 'pics.atmo.code', rkey, record }); + const newItem: MediaRecord = { + uri: `at://${user.did}/pics.atmo.code/${rkey}`, + value: record, + type: 'code' + }; + items = [newItem, ...items]; + const shareLink = getShareLinkFromUri(newItem.uri, user.profile?.handle); + try { + await navigator.clipboard.writeText(shareLink); + feedbackMessage = 'Link copied to clipboard!'; + } catch { + feedbackMessage = 'Code published!'; + } + } catch (error) { + console.error('Publish failed:', error); + feedbackMessage = 'Publish failed. Please try again.'; + } + } + + const CODE_EXTENSIONS = new Set([ + 'js', 'ts', 'jsx', 'tsx', 'mjs', 'mts', 'cjs', 'cts', + 'py', 'rb', 'go', 'rs', 'c', 'cpp', 'h', 'hpp', 'cc', + 'java', 'kt', 'kts', 'swift', 'cs', 'fs', + 'sh', 'bash', 'zsh', 'fish', + 'css', 'scss', 'sass', 'less', + 'html', 'xml', 'svg', + 'json', 'yaml', 'yml', 'toml', 'ini', 'cfg', + 'sql', 'lua', 'php', 'pl', 'r', 'scala', + 'zig', 'hs', 'elm', 'ex', 'exs', 'clj', 'cljs', + 'dart', 'vue', 'svelte', 'astro', + 'dockerfile', 'makefile', 'cmake', + 'tf', 'hcl', 'nix', 'dhall', + 'graphql', 'gql', 'proto', 'wasm', 'wat' + ]); + + const MARKDOWN_EXTENSIONS = new Set(['md', 'mdx', 'markdown', 'mdown', 'mkd']); + + function getFileExtension(name: string): string { + const lower = name.toLowerCase(); + // Handle extensionless known filenames + if (['dockerfile', 'makefile', 'rakefile', 'gemfile', 'cmakelists.txt'].includes(lower)) { + return 'dockerfile'; + } + const dot = lower.lastIndexOf('.'); + return dot >= 0 ? lower.slice(dot + 1) : ''; + } + + async function uploadFile(file: File) { + const ext = getFileExtension(file.name); + + if (MARKDOWN_EXTENSIONS.has(ext)) { + const text = await file.text(); + await publishMarkdownDirect(text, file.name); + return true; + } + + if (CODE_EXTENSIONS.has(ext)) { + const text = await file.text(); + await publishCodeDirect(text, file.name); + return true; + } + + return false; + } + + function getBadgeColor(type: MediaRecord['type']): string { + switch (type) { + case 'image': return 'bg-cyan-600'; + case 'video': return 'bg-rose-600'; + case 'markdown': return 'bg-amber-600'; + case 'code': return 'bg-purple-600'; + } + } + function getThumbnailUrl(record: MediaRecord): string | undefined { if (!user.did) return; if (record.type === 'video') { @@ -315,6 +606,9 @@ if (item.type === 'markdown') { return resolve('/m/[repo]/[rkey]', { repo: handle, rkey }); } + if (item.type === 'code') { + return resolve('/c/[repo]/[rkey]', { repo: handle, rkey }); + } return resolve('/i/[repo]/[rkey]', { repo: handle, rkey }); } @@ -335,12 +629,14 @@ function getItemLabel(item: MediaRecord): string { if (item.type === 'video') return 'video'; if (item.type === 'markdown') return 'post'; + if (item.type === 'code') return 'snippet'; return 'image'; } function getItemCollection(item: MediaRecord): AllowedCollection { if (item.type === 'video') return 'pics.atmo.video'; if (item.type === 'markdown') return 'pics.atmo.markdown'; + if (item.type === 'code') return 'pics.atmo.code'; return 'pics.atmo.image'; } @@ -374,7 +670,7 @@ } - +
{/if} + + {#if showCreateModal} + +
{ if (e.target === e.currentTarget) closeCreateModal(); }} + onkeydown={(e) => { if (e.key === 'Escape') closeCreateModal(); }} + > +
+ {#if createMode === 'pick'} +

What do you want to share?

+
+ + + + +
+ {:else if createMode === 'pick-text'} +

What type of text is this?

+

We couldn't auto-detect the format. Please choose:

+
{pastedText.slice(0, 300)}{pastedText.length > 300 ? '...' : ''}
+
+ + +
+ {:else if createMode === 'markdown'} +
+ +

Write markdown

+
+
+ + + +
+ {:else if createMode === 'code'} +
+ +

Share code

+
+
+ + + +
+ {/if} +
+
+ {/if} +

atmo.pics

quick atproto image, video and markdown sharing

@@ -467,33 +905,7 @@ onchange={handleFileInput} /> - - - - - {#if showMarkdownEditor} -
- - - -
- {/if} +
0} diff --git a/src/routes/c/[repo]/[rkey]/+page.server.ts b/src/routes/c/[repo]/[rkey]/+page.server.ts new file mode 100644 index 0000000..5acea2b --- /dev/null +++ b/src/routes/c/[repo]/[rkey]/+page.server.ts @@ -0,0 +1,23 @@ +import { getRecord, resolveHandle } from '$lib/atproto'; +import { isDid, isHandle } from '@atcute/lexicons/syntax'; +import { error } from '@sveltejs/kit'; + +export async function load({ params }) { + const repo = params.repo; + + const did = isDid(repo) + ? repo + : isHandle(repo) + ? await resolveHandle({ handle: repo }) + : undefined; + + if (!did) throw error(404, 'User not found'); + + const record = await getRecord({ did, collection: 'pics.atmo.code', rkey: params.rkey }); + + if (!record.value.content) { + throw error(404, 'Code not found'); + } + + return { record, did }; +} diff --git a/src/routes/c/[repo]/[rkey]/+page.svelte b/src/routes/c/[repo]/[rkey]/+page.svelte new file mode 100644 index 0000000..06abd5d --- /dev/null +++ b/src/routes/c/[repo]/[rkey]/+page.svelte @@ -0,0 +1,181 @@ + + + + + + + + {title || 'Code snippet'} + + + + +
+
+ {#if title} +

{title}

+ {/if} + + +
+ {#if uploader?.avatar} + + {/if} +
+ {#if uploader?.displayName || uploader?.handle} +
{uploader.displayName || uploader.handle}
+ {/if} + {#if createdAt} +
{formatDate(createdAt)}
+ {/if} +
+
+ {#if detectedLanguage} + {detectedLanguage} + {/if} + + +
+
+ + +
+
{#if mounted}{@html highlightedHtml}{:else}{code}{/if}
+
+
+
diff --git a/src/routes/c/[repo]/[rkey]/og.png/+server.ts b/src/routes/c/[repo]/[rkey]/og.png/+server.ts new file mode 100644 index 0000000..4ad8279 --- /dev/null +++ b/src/routes/c/[repo]/[rkey]/og.png/+server.ts @@ -0,0 +1,41 @@ +import { getRecord, resolveHandle } from '$lib/atproto'; +import { isDid, isHandle } from '@atcute/lexicons/syntax'; +import { error } from '@sveltejs/kit'; +import { ImageResponse } from '@ethercorps/sveltekit-og'; + +export async function GET({ params }) { + const repo = params.repo; + + const did = isDid(repo) + ? repo + : isHandle(repo) + ? await resolveHandle({ handle: repo }) + : undefined; + + if (!did) throw error(404, 'User not found'); + + const record = await getRecord({ did, collection: 'pics.atmo.code', rkey: params.rkey }); + + const title = (record.value.title as string) || 'Code snippet'; + const content = (record.value.content as string) || ''; + const preview = content.length > 400 ? content.slice(0, 400) + '...' : content; + + const ogWidth = 1200; + const ogHeight = 630; + + const escapedTitle = title.replace(/&/g, '&').replace(//g, '>'); + const escapedPreview = preview + .replace(/&/g, '&') + .replace(//g, '>'); + + const htmlString = `
+
${escapedTitle}
+
${escapedPreview}
+
`; + + return new ImageResponse(htmlString, { + width: ogWidth, + height: ogHeight + }); +}