Browse Source

implement full media support, including blossom

master
Silberengel 1 month ago
parent
commit
d8f33ad99f
  1. 11
      package-lock.json
  2. 3
      package.json
  3. 6
      public/healthz.json
  4. 312
      src/lib/components/content/MediaAttachments.svelte
  5. 327
      src/lib/components/content/RichTextEditor.svelte
  6. 326
      src/lib/components/write/AdvancedEditor.svelte
  7. 2
      src/lib/modules/discussions/DiscussionCard.svelte
  8. 38
      src/lib/modules/feed/FeedPost.svelte
  9. 11
      src/lib/services/nostr/file-upload.ts
  10. 7
      src/routes/about/+page.svelte

11
package-lock.json generated

@ -1,12 +1,12 @@
{ {
"name": "aitherboard", "name": "aitherboard",
"version": "0.3.0", "version": "0.3.1",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "aitherboard", "name": "aitherboard",
"version": "0.3.0", "version": "0.3.1",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@codemirror/autocomplete": "^6.20.0", "@codemirror/autocomplete": "^6.20.0",
@ -23,6 +23,7 @@
"@sveltejs/vite-plugin-svelte": "^4.0.0-next.6", "@sveltejs/vite-plugin-svelte": "^4.0.0-next.6",
"@tanstack/svelte-virtual": "^3.0.0", "@tanstack/svelte-virtual": "^3.0.0",
"asciidoctor": "3.0.x", "asciidoctor": "3.0.x",
"blurhash": "^2.0.5",
"codemirror-asciidoc": "^2.0.1", "codemirror-asciidoc": "^2.0.1",
"dompurify": "^3.0.6", "dompurify": "^3.0.6",
"emoji-picker-element": "^1.28.1", "emoji-picker-element": "^1.28.1",
@ -4078,6 +4079,12 @@
"url": "https://github.com/sponsors/sindresorhus" "url": "https://github.com/sponsors/sindresorhus"
} }
}, },
"node_modules/blurhash": {
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/blurhash/-/blurhash-2.0.5.tgz",
"integrity": "sha512-cRygWd7kGBQO3VEhPiTgq4Wc43ctsM+o46urrmPOiuAe+07fzlSB9OJVdpgDL0jPqXUVQ9ht7aq7kxOeJHRK+w==",
"license": "MIT"
},
"node_modules/brace-expansion": { "node_modules/brace-expansion": {
"version": "2.0.2", "version": "2.0.2",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz",

3
package.json

@ -1,6 +1,6 @@
{ {
"name": "aitherboard", "name": "aitherboard",
"version": "0.3.0", "version": "0.3.1",
"type": "module", "type": "module",
"author": "silberengel@gitcitadel.com", "author": "silberengel@gitcitadel.com",
"description": "A decentralized messageboard built on the Nostr protocol.", "description": "A decentralized messageboard built on the Nostr protocol.",
@ -37,6 +37,7 @@
"@sveltejs/vite-plugin-svelte": "^4.0.0-next.6", "@sveltejs/vite-plugin-svelte": "^4.0.0-next.6",
"@tanstack/svelte-virtual": "^3.0.0", "@tanstack/svelte-virtual": "^3.0.0",
"asciidoctor": "3.0.x", "asciidoctor": "3.0.x",
"blurhash": "^2.0.5",
"codemirror-asciidoc": "^2.0.1", "codemirror-asciidoc": "^2.0.1",
"dompurify": "^3.0.6", "dompurify": "^3.0.6",
"emoji-picker-element": "^1.28.1", "emoji-picker-element": "^1.28.1",

6
public/healthz.json

@ -1,8 +1,8 @@
{ {
"status": "ok", "status": "ok",
"service": "aitherboard", "service": "aitherboard",
"version": "0.3.0", "version": "0.3.1",
"buildTime": "2026-02-11T10:58:05.118Z", "buildTime": "2026-02-12T11:24:49.574Z",
"gitCommit": "unknown", "gitCommit": "unknown",
"timestamp": 1770807485118 "timestamp": 1770895489574
} }

312
src/lib/components/content/MediaAttachments.svelte

@ -1,13 +1,17 @@
<script lang="ts"> <script lang="ts">
import type { NostrEvent } from '../../types/nostr.js'; import type { NostrEvent } from '../../types/nostr.js';
import { decode } from 'blurhash';
import { onMount } from 'svelte';
interface Props { interface Props {
event: NostrEvent; event: NostrEvent;
forceRender?: boolean; // If true, always render media even if URL is in content (for media kinds) forceRender?: boolean; // If true, always render media even if URL is in content (for media kinds)
onMediaClick?: (url: string, event: MouseEvent) => void; // Optional callback when media is clicked onMediaClick?: (url: string, event: MouseEvent) => void; // Optional callback when media is clicked
isFeedView?: boolean; // Enable blur for feed view
thumbnailWidth?: number; // Thumbnail width in pixels (default 250)
} }
let { event, forceRender = false, onMediaClick }: Props = $props(); let { event, forceRender = false, onMediaClick, isFeedView = false, thumbnailWidth = 250 }: Props = $props();
function handleMediaClick(e: MouseEvent, url: string) { function handleMediaClick(e: MouseEvent, url: string) {
e.stopPropagation(); // Don't trigger parent click handlers e.stopPropagation(); // Don't trigger parent click handlers
@ -17,14 +21,16 @@
} }
interface MediaItem { interface MediaItem {
url: string; url: string; // Full-size URL
thumbnailUrl?: string; // From imeta "thumb" field or NIP-94 "thumb" tag
blurhash?: string; // From imeta "blurhash" or "bh" field, or NIP-94 "blurhash" tag
type: 'image' | 'video' | 'audio' | 'file'; type: 'image' | 'video' | 'audio' | 'file';
mimeType?: string; mimeType?: string;
width?: number; width?: number; // From imeta "x" field or "dim WIDTHxHEIGHT" or NIP-94 "dim" tag
height?: number; height?: number; // From imeta "y" field or "dim WIDTHxHEIGHT" or NIP-94 "dim" tag
size?: number; size?: number;
alt?: string; // Alt text for images alt?: string; // Alt text for images
source: 'image-tag' | 'imeta' | 'file-tag' | 'content'; source: 'image-tag' | 'imeta' | 'file-tag' | 'content' | 'nip94';
} }
function normalizeUrl(url: string): string { function normalizeUrl(url: string): string {
@ -101,6 +107,8 @@
let width: number | undefined; let width: number | undefined;
let height: number | undefined; let height: number | undefined;
let alt: string | undefined; let alt: string | undefined;
let thumbnailUrl: string | undefined;
let blurhash: string | undefined;
for (let i = 1; i < tag.length; i++) { for (let i = 1; i < tag.length; i++) {
const item = tag[i]; const item = tag[i];
@ -114,6 +122,20 @@
height = parseInt(item.substring(2).trim(), 10); height = parseInt(item.substring(2).trim(), 10);
} else if (item.startsWith('alt ')) { } else if (item.startsWith('alt ')) {
alt = item.substring(4).trim(); alt = item.substring(4).trim();
} else if (item.startsWith('thumb ')) {
thumbnailUrl = item.substring(6).trim();
} else if (item.startsWith('blurhash ')) {
blurhash = item.substring(9).trim();
} else if (item.startsWith('bh ')) {
blurhash = item.substring(3).trim();
} else if (item.startsWith('dim ')) {
// Parse "dim WIDTHxHEIGHT"
const dimStr = item.substring(4).trim();
const dimMatch = dimStr.match(/^(\d+(?:\.\d+)?)x(\d+(?:\.\d+)?)$/i);
if (dimMatch) {
width = parseInt(dimMatch[1], 10);
height = parseInt(dimMatch[2], 10);
}
} }
} }
@ -134,6 +156,8 @@
media.push({ media.push({
url, url,
thumbnailUrl,
blurhash,
type, type,
mimeType, mimeType,
width, width,
@ -154,7 +178,68 @@
media[0].alt = altTag[1]; media[0].alt = altTag[1];
} }
// 3. file tags (NIP-94) // 3. NIP-94 tags (kind 1063) - separate tag arrays
if (event.kind === 1063) {
let url: string | undefined;
let mimeType: string | undefined;
let width: number | undefined;
let height: number | undefined;
let alt: string | undefined;
let thumbnailUrl: string | undefined;
let blurhash: string | undefined;
let size: number | undefined;
for (const tag of event.tags) {
if (tag[0] === 'url' && tag[1]) {
url = tag[1];
} else if (tag[0] === 'm' && tag[1]) {
mimeType = tag[1];
} else if (tag[0] === 'thumb' && tag[1]) {
thumbnailUrl = tag[1];
} else if (tag[0] === 'blurhash' && tag[1]) {
blurhash = tag[1];
} else if (tag[0] === 'alt' && tag[1]) {
alt = tag[1];
} else if (tag[0] === 'dim' && tag[1]) {
// Parse "WIDTHxHEIGHT"
const dimMatch = tag[1].match(/^(\d+(?:\.\d+)?)x(\d+(?:\.\d+)?)$/i);
if (dimMatch) {
width = parseInt(dimMatch[1], 10);
height = parseInt(dimMatch[2], 10);
}
} else if (tag[0] === 'size' && tag[1]) {
size = parseInt(tag[1], 10);
}
}
if (url) {
const normalized = normalizeUrl(url);
if (!seen.has(normalized)) {
let type: 'image' | 'video' | 'audio' | 'file' = 'file';
if (mimeType) {
if (mimeType.startsWith('image/')) type = 'image';
else if (mimeType.startsWith('video/')) type = 'video';
else if (mimeType.startsWith('audio/')) type = 'audio';
}
media.push({
url,
thumbnailUrl,
blurhash,
type,
mimeType,
width,
height,
size,
alt,
source: 'nip94'
});
seen.add(normalized);
}
}
}
// 4. Legacy file tags (fallback)
for (const tag of event.tags) { for (const tag of event.tags) {
if (tag[0] === 'file' && tag[1]) { if (tag[0] === 'file' && tag[1]) {
const normalized = normalizeUrl(tag[1]); const normalized = normalizeUrl(tag[1]);
@ -183,7 +268,7 @@
} }
} }
// 4. Extract from markdown content (images in markdown syntax) // 5. Extract from markdown content (images in markdown syntax)
const imageRegex = /!\[.*?\]\((.*?)\)/g; const imageRegex = /!\[.*?\]\((.*?)\)/g;
let match; let match;
while ((match = imageRegex.exec(event.content)) !== null) { while ((match = imageRegex.exec(event.content)) !== null) {
@ -199,7 +284,7 @@
} }
} }
// 5. Don't extract plain image URLs from content - let markdown render them inline // 6. Don't extract plain image URLs from content - let markdown render them inline
// This ensures images appear where the URL is in the content, not at the top // This ensures images appear where the URL is in the content, not at the top
// Only extract images from tags (image, imeta, file) which are handled above // Only extract images from tags (image, imeta, file) which are handled above
@ -223,10 +308,69 @@
const otherMedia = $derived(mediaItems.filter((m) => m.source !== 'image-tag')); const otherMedia = $derived(mediaItems.filter((m) => m.source !== 'image-tag'));
let containerRef = $state<HTMLElement | null>(null); let containerRef = $state<HTMLElement | null>(null);
// Track loaded images for blurhash fade
let loadedImages = $state<Set<string>>(new Set());
// Helper function to get thumbnail URL with fallbacks
function getThumbnailUrl(item: MediaItem): string {
if (item.thumbnailUrl) {
return item.thumbnailUrl;
}
// Try query parameter for resizing (if server supports)
if (item.type === 'image') {
try {
const url = new URL(item.url);
url.searchParams.set('w', thumbnailWidth.toString());
return url.toString();
} catch {
// If URL parsing fails, return original
return item.url;
}
}
return item.url;
}
// Helper function to render blurhash as data URL
function renderBlurhash(blurhash: string, width: number = 32, height: number = 32): string | null {
try {
const pixels = decode(blurhash, width, height);
const canvas = document.createElement('canvas');
canvas.width = width;
canvas.height = height;
const ctx = canvas.getContext('2d');
if (!ctx) return null;
const imageData = ctx.createImageData(width, height);
for (let i = 0; i < pixels.length; i += 4) {
imageData.data[i] = pixels[i]; // R
imageData.data[i + 1] = pixels[i + 1]; // G
imageData.data[i + 2] = pixels[i + 2]; // B
imageData.data[i + 3] = pixels[i + 3]; // A
}
ctx.putImageData(imageData, 0, 0);
return canvas.toDataURL();
} catch (error) {
console.warn('Failed to render blurhash:', error);
return null;
}
}
// Get blurhash data URL for an item
function getBlurhashDataUrl(item: MediaItem): string | null {
if (!item.blurhash) return null;
// Use item dimensions if available, otherwise use aspect ratio or default
const width = item.width && item.height ? Math.min(32, Math.floor((item.width / item.height) * 32)) : 32;
const height = item.width && item.height ? Math.min(32, Math.floor((item.height / item.width) * 32)) : 32;
return renderBlurhash(item.blurhash, width, height);
}
</script> </script>
<div bind:this={containerRef}> <div bind:this={containerRef}>
{#if coverImage} {#if coverImage}
{@const thumbnailUrl = getThumbnailUrl(coverImage)}
{@const blurhashDataUrl = getBlurhashDataUrl(coverImage)}
{@const shouldBlur = isFeedView && !coverImage.blurhash}
<div class="cover-image mb-4"> <div class="cover-image mb-4">
{#if onMediaClick} {#if onMediaClick}
<button <button
@ -235,22 +379,56 @@
onclick={(e) => handleMediaClick(e, coverImage.url)} onclick={(e) => handleMediaClick(e, coverImage.url)}
aria-label={coverImage.alt || 'View image'} aria-label={coverImage.alt || 'View image'}
> >
<div class="image-container" class:feed-blur={shouldBlur}>
{#if blurhashDataUrl}
<img
src={blurhashDataUrl}
alt=""
class="blurhash-placeholder"
aria-hidden="true"
/>
{/if}
<img
src={thumbnailUrl}
alt={coverImage.alt || ''}
class="w-full max-h-96 object-cover rounded clickable-media"
class:feed-blur={shouldBlur}
class:loaded={loadedImages.has(coverImage.url)}
loading="lazy"
decoding="async"
style="width: {thumbnailWidth}px; max-width: 100%;"
onload={() => {
loadedImages.add(coverImage.url);
loadedImages = loadedImages; // Trigger reactivity
}}
/>
</div>
</button>
{:else}
<div class="image-container" class:feed-blur={shouldBlur}>
{#if blurhashDataUrl}
<img
src={blurhashDataUrl}
alt=""
class="blurhash-placeholder"
aria-hidden="true"
/>
{/if}
<img <img
src={coverImage.url} src={thumbnailUrl}
alt={coverImage.alt || ''} alt={coverImage.alt || ''}
class="w-full max-h-96 object-cover rounded clickable-media" class="w-full max-h-96 object-cover rounded"
class:feed-blur={shouldBlur}
class:loaded={loadedImages.has(coverImage.url)}
loading="lazy" loading="lazy"
decoding="async" decoding="async"
style="width: {thumbnailWidth}px; max-width: 100%;"
onload={() => {
loadedImages.add(coverImage.url);
loadedImages = loadedImages; // Trigger reactivity
}}
/> />
</button> </div>
{:else}
<img
src={coverImage.url}
alt={coverImage.alt || ''}
class="w-full max-h-96 object-cover rounded"
loading="lazy"
decoding="async"
/>
{/if} {/if}
{#if coverImage.alt} {#if coverImage.alt}
<div class="image-alt-text">{coverImage.alt}</div> <div class="image-alt-text">{coverImage.alt}</div>
@ -262,6 +440,9 @@
<div class="media-gallery mb-4"> <div class="media-gallery mb-4">
{#each otherMedia as item} {#each otherMedia as item}
{#if item.type === 'image'} {#if item.type === 'image'}
{@const thumbnailUrl = getThumbnailUrl(item)}
{@const blurhashDataUrl = getBlurhashDataUrl(item)}
{@const shouldBlur = isFeedView && !item.blurhash}
<div class="media-item"> <div class="media-item">
{#if onMediaClick} {#if onMediaClick}
<button <button
@ -270,22 +451,56 @@
onclick={(e) => handleMediaClick(e, item.url)} onclick={(e) => handleMediaClick(e, item.url)}
aria-label={item.alt || 'View image'} aria-label={item.alt || 'View image'}
> >
<div class="image-container" class:feed-blur={shouldBlur}>
{#if blurhashDataUrl}
<img
src={blurhashDataUrl}
alt=""
class="blurhash-placeholder"
aria-hidden="true"
/>
{/if}
<img
src={thumbnailUrl}
alt={item.alt || ''}
class="max-w-full rounded clickable-media"
class:feed-blur={shouldBlur}
class:loaded={loadedImages.has(item.url)}
loading="lazy"
decoding="async"
style="width: {thumbnailWidth}px; max-width: 100%;"
onload={() => {
loadedImages.add(item.url);
loadedImages = loadedImages; // Trigger reactivity
}}
/>
</div>
</button>
{:else}
<div class="image-container" class:feed-blur={shouldBlur}>
{#if blurhashDataUrl}
<img
src={blurhashDataUrl}
alt=""
class="blurhash-placeholder"
aria-hidden="true"
/>
{/if}
<img <img
src={item.url} src={thumbnailUrl}
alt={item.alt || ''} alt={item.alt || ''}
class="max-w-full rounded clickable-media" class="max-w-full rounded"
class:feed-blur={shouldBlur}
class:loaded={loadedImages.has(item.url)}
loading="lazy" loading="lazy"
decoding="async" decoding="async"
style="width: {thumbnailWidth}px; max-width: 100%;"
onload={() => {
loadedImages.add(item.url);
loadedImages = loadedImages; // Trigger reactivity
}}
/> />
</button> </div>
{:else}
<img
src={item.url}
alt={item.alt || ''}
class="max-w-full rounded"
loading="lazy"
decoding="async"
/>
{/if} {/if}
{#if item.alt} {#if item.alt}
<div class="image-alt-text">{item.alt}</div> <div class="image-alt-text">{item.alt}</div>
@ -450,4 +665,41 @@
opacity: 0.9; opacity: 0.9;
} }
.image-container {
position: relative;
display: inline-block;
width: 100%;
max-width: 100%;
}
.blurhash-placeholder {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
object-fit: cover;
opacity: 0;
transition: opacity 0.3s;
pointer-events: none;
}
.image-container img:not(.blurhash-placeholder) {
position: relative;
z-index: 1;
}
.image-container:has(img:not(.blurhash-placeholder):not(.loaded)) .blurhash-placeholder {
opacity: 1;
}
.feed-blur {
filter: blur(8px);
transition: filter 0.3s;
}
.feed-blur:hover {
filter: blur(4px);
}
</style> </style>

327
src/lib/components/content/RichTextEditor.svelte

@ -6,6 +6,14 @@
import GifPicker from './GifPicker.svelte'; import GifPicker from './GifPicker.svelte';
import EmojiPicker from './EmojiPicker.svelte'; import EmojiPicker from './EmojiPicker.svelte';
import Icon from '../ui/Icon.svelte'; import Icon from '../ui/Icon.svelte';
import { onMount } from 'svelte';
// Autofocus action for accessibility
function autofocus(node: HTMLInputElement) {
onMount(() => {
node.focus();
});
}
interface Props { interface Props {
value: string; value: string;
@ -38,6 +46,11 @@
let uploading = $state(false); let uploading = $state(false);
let uploadedFiles: Array<{ url: string; imetaTag: string[] }> = $state([]); let uploadedFiles: Array<{ url: string; imetaTag: string[] }> = $state([]);
// Alt text input state
let showAltTextModal = $state(false);
let pendingFiles: Array<{ file: File; altText: string }> = $state([]);
let currentFileIndex = $state(0);
// Generate unique ID for file input // Generate unique ID for file input
const fileInputId = `rich-text-file-upload-${Math.random().toString(36).substring(7)}`; const fileInputId = `rich-text-file-upload-${Math.random().toString(36).substring(7)}`;
@ -60,7 +73,7 @@
showEmojiPicker = false; showEmojiPicker = false;
} }
async function handleFileUpload(event: Event) { function handleFileUpload(event: Event) {
const input = event.target as HTMLInputElement; const input = event.target as HTMLInputElement;
const files = input.files; const files = input.files;
if (!files || files.length === 0) return; if (!files || files.length === 0) return;
@ -88,19 +101,52 @@
return; return;
} }
// Show alt text input modal for each file
pendingFiles = validFiles.map(file => ({ file, altText: '' }));
currentFileIndex = 0;
showAltTextModal = true;
}
function handleAltTextNext() {
if (currentFileIndex < pendingFiles.length - 1) {
currentFileIndex++;
} else {
// All files have alt text, proceed with upload
showAltTextModal = false;
startUpload();
}
}
function handleAltTextSkip() {
// Skip remaining files and proceed with upload
showAltTextModal = false;
startUpload();
}
function handleAltTextCancel() {
// Cancel upload
showAltTextModal = false;
pendingFiles = [];
currentFileIndex = 0;
if (fileInputRef) {
fileInputRef.value = '';
}
}
async function startUpload() {
uploading = true; uploading = true;
const uploadPromises: Promise<void>[] = []; const uploadPromises: Promise<void>[] = [];
// Process all files // Process all files with their alt text
for (const file of validFiles) { for (const { file, altText } of pendingFiles) {
const uploadPromise = (async () => { const uploadPromise = (async () => {
try { try {
// Upload file to media server // Upload file to media server
const uploadResult = await uploadFileToServer(file, uploadContext); const uploadResult = await uploadFileToServer(file, uploadContext);
console.log(`[${uploadContext}] Uploaded ${file.name} to ${uploadResult.url}`); console.log(`[${uploadContext}] Uploaded ${file.name} to ${uploadResult.url}`);
// Build imeta tag from upload response (NIP-92 format) // Build imeta tag from upload response (NIP-92 format) with alt text
const imetaTag = buildImetaTag(file, uploadResult); const imetaTag = buildImetaTag(file, uploadResult, altText);
// Store file with imeta tag // Store file with imeta tag
uploadedFiles.push({ uploadedFiles.push({
@ -127,6 +173,8 @@
await Promise.all(uploadPromises); await Promise.all(uploadPromises);
} finally { } finally {
uploading = false; uploading = false;
pendingFiles = [];
currentFileIndex = 0;
// Reset file input // Reset file input
if (fileInputRef) { if (fileInputRef) {
fileInputRef.value = ''; fileInputRef.value = '';
@ -330,6 +378,72 @@
<GifPicker open={showGifPicker} onSelect={handleGifSelect} onClose={() => showGifPicker = false} /> <GifPicker open={showGifPicker} onSelect={handleGifSelect} onClose={() => showGifPicker = false} />
<EmojiPicker open={showEmojiPicker} onSelect={handleEmojiSelect} onClose={() => showEmojiPicker = false} /> <EmojiPicker open={showEmojiPicker} onSelect={handleEmojiSelect} onClose={() => showEmojiPicker = false} />
{#if showAltTextModal && pendingFiles.length > 0}
<div
class="alt-text-modal-backdrop"
onclick={handleAltTextCancel}
onkeydown={(e) => {
if (e.key === 'Escape') {
handleAltTextCancel();
}
}}
role="button"
tabindex="-1"
aria-label="Close modal"
>
<div
class="alt-text-modal"
onclick={(e) => e.stopPropagation()}
onkeydown={(e) => e.stopPropagation()}
role="dialog"
aria-modal="true"
aria-labelledby="alt-text-title"
tabindex="-1"
>
<div class="alt-text-modal-header">
<h3 id="alt-text-title">Add Alt Text</h3>
<button type="button" class="alt-text-close" onclick={handleAltTextCancel} aria-label="Close">×</button>
</div>
<div class="alt-text-modal-content">
<p class="alt-text-file-info">
File {currentFileIndex + 1} of {pendingFiles.length}: <strong>{pendingFiles[currentFileIndex].file.name}</strong>
</p>
<label for="alt-text-input" class="alt-text-label">
Alt text (optional, for accessibility):
</label>
<input
type="text"
id="alt-text-input"
class="alt-text-input"
placeholder="Describe the image for screen readers..."
bind:value={pendingFiles[currentFileIndex].altText}
onkeydown={(e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
handleAltTextNext();
} else if (e.key === 'Escape') {
e.preventDefault();
handleAltTextCancel();
}
}}
use:autofocus
/>
</div>
<div class="alt-text-modal-footer">
<button type="button" class="alt-text-button alt-text-button-secondary" onclick={handleAltTextCancel}>
Cancel
</button>
<button type="button" class="alt-text-button alt-text-button-secondary" onclick={handleAltTextSkip}>
Skip All
</button>
<button type="button" class="alt-text-button alt-text-button-primary" onclick={handleAltTextNext}>
{currentFileIndex < pendingFiles.length - 1 ? 'Next' : 'Upload'}
</button>
</div>
</div>
</div>
{/if}
<style> <style>
.textarea-wrapper { .textarea-wrapper {
position: relative; position: relative;
@ -407,4 +521,207 @@
.hidden { .hidden {
display: none; display: none;
} }
.alt-text-modal-backdrop {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
z-index: 10000;
display: flex;
align-items: center;
justify-content: center;
padding: 1rem;
}
.alt-text-modal {
background: var(--fog-post, #ffffff);
border: 1px solid var(--fog-border, #e5e7eb);
border-radius: 0.5rem;
max-width: 500px;
width: 100%;
max-height: 90vh;
display: flex;
flex-direction: column;
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.2);
}
:global(.dark) .alt-text-modal {
background: var(--fog-dark-post, #1f2937);
border-color: var(--fog-dark-border, #374151);
}
.alt-text-modal-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1rem 1.5rem;
border-bottom: 1px solid var(--fog-border, #e5e7eb);
}
:global(.dark) .alt-text-modal-header {
border-bottom-color: var(--fog-dark-border, #374151);
}
.alt-text-modal-header h3 {
margin: 0;
font-size: 1.125rem;
font-weight: 600;
color: var(--fog-text, #1f2937);
}
:global(.dark) .alt-text-modal-header h3 {
color: var(--fog-dark-text, #f9fafb);
}
.alt-text-close {
background: none;
border: none;
font-size: 1.5rem;
line-height: 1;
color: var(--fog-text-light, #52667a);
cursor: pointer;
padding: 0;
width: 2rem;
height: 2rem;
display: flex;
align-items: center;
justify-content: center;
border-radius: 0.25rem;
transition: all 0.2s;
}
.alt-text-close:hover {
background: var(--fog-highlight, #f3f4f6);
color: var(--fog-text, #1f2937);
}
:global(.dark) .alt-text-close {
color: var(--fog-dark-text-light, #a8b8d0);
}
:global(.dark) .alt-text-close:hover {
background: var(--fog-dark-highlight, #374151);
color: var(--fog-dark-text, #f9fafb);
}
.alt-text-modal-content {
padding: 1.5rem;
flex: 1;
overflow-y: auto;
}
.alt-text-file-info {
margin: 0 0 1rem 0;
font-size: 0.875rem;
color: var(--fog-text-light, #52667a);
}
:global(.dark) .alt-text-file-info {
color: var(--fog-dark-text-light, #a8b8d0);
}
.alt-text-label {
display: block;
margin-bottom: 0.5rem;
font-size: 0.875rem;
font-weight: 500;
color: var(--fog-text, #1f2937);
}
:global(.dark) .alt-text-label {
color: var(--fog-dark-text, #f9fafb);
}
.alt-text-input {
width: 100%;
padding: 0.75rem;
border: 1px solid var(--fog-border, #e5e7eb);
border-radius: 0.25rem;
background: var(--fog-post, #ffffff);
color: var(--fog-text, #1f2937);
font-size: 0.875rem;
box-sizing: border-box;
}
.alt-text-input:focus {
outline: none;
border-color: var(--fog-accent, #64748b);
}
:global(.dark) .alt-text-input {
background: var(--fog-dark-post, #1f2937);
border-color: var(--fog-dark-border, #374151);
color: var(--fog-dark-text, #f9fafb);
}
:global(.dark) .alt-text-input:focus {
border-color: var(--fog-dark-accent, #94a3b8);
}
.alt-text-modal-footer {
display: flex;
justify-content: flex-end;
gap: 0.5rem;
padding: 1rem 1.5rem;
border-top: 1px solid var(--fog-border, #e5e7eb);
}
:global(.dark) .alt-text-modal-footer {
border-top-color: var(--fog-dark-border, #374151);
}
.alt-text-button {
padding: 0.5rem 1rem;
border: 1px solid var(--fog-border, #e5e7eb);
border-radius: 0.25rem;
font-size: 0.875rem;
font-weight: 500;
cursor: pointer;
transition: all 0.2s;
}
.alt-text-button-secondary {
background: var(--fog-post, #ffffff);
color: var(--fog-text, #1f2937);
}
.alt-text-button-secondary:hover {
background: var(--fog-highlight, #f3f4f6);
border-color: var(--fog-accent, #64748b);
}
.alt-text-button-primary {
background: var(--fog-accent, #64748b);
color: white;
border-color: var(--fog-accent, #64748b);
}
.alt-text-button-primary:hover {
background: var(--fog-text, #475569);
border-color: var(--fog-text, #475569);
}
:global(.dark) .alt-text-button-secondary {
background: var(--fog-dark-post, #1f2937);
color: var(--fog-dark-text, #f9fafb);
border-color: var(--fog-dark-border, #374151);
}
:global(.dark) .alt-text-button-secondary:hover {
background: var(--fog-dark-highlight, #374151);
border-color: var(--fog-dark-accent, #94a3b8);
}
:global(.dark) .alt-text-button-primary {
background: var(--fog-dark-accent, #94a3b8);
border-color: var(--fog-dark-accent, #94a3b8);
}
:global(.dark) .alt-text-button-primary:hover {
background: var(--fog-dark-text, #cbd5e1);
border-color: var(--fog-dark-text, #cbd5e1);
}
</style> </style>

326
src/lib/components/write/AdvancedEditor.svelte

@ -23,6 +23,13 @@
import hljs from 'highlight.js'; import hljs from 'highlight.js';
import 'highlight.js/styles/vs2015.css'; import 'highlight.js/styles/vs2015.css';
// Autofocus action for accessibility
function autofocus(node: HTMLInputElement) {
onMount(() => {
node.focus();
});
}
interface Props { interface Props {
value: string; value: string;
mode: 'markdown' | 'asciidoc'; mode: 'markdown' | 'asciidoc';
@ -46,6 +53,11 @@
let fileInputRef: HTMLInputElement | null = $state(null); let fileInputRef: HTMLInputElement | null = $state(null);
let uploading = $state(false); let uploading = $state(false);
let uploadedFiles: Array<{ url: string; imetaTag: string[] }> = $state([]); let uploadedFiles: Array<{ url: string; imetaTag: string[] }> = $state([]);
// Alt text input state
let showAltTextModal = $state(false);
let pendingFiles: Array<{ file: File; altText: string }> = $state([]);
let currentFileIndex = $state(0);
let previewContent = $state<string>(''); let previewContent = $state<string>('');
let previewEvent = $state<NostrEvent | null>(null); let previewEvent = $state<NostrEvent | null>(null);
let eventJson = $state('{}'); let eventJson = $state('{}');
@ -650,7 +662,7 @@
} }
} }
async function handleFileUpload(event: Event) { function handleFileUpload(event: Event) {
const input = event.target as HTMLInputElement; const input = event.target as HTMLInputElement;
const files = input.files; const files = input.files;
if (!files || files.length === 0) return; if (!files || files.length === 0) return;
@ -678,19 +690,52 @@
return; return;
} }
// Show alt text input modal for each file
pendingFiles = validFiles.map(file => ({ file, altText: '' }));
currentFileIndex = 0;
showAltTextModal = true;
}
function handleAltTextNext() {
if (currentFileIndex < pendingFiles.length - 1) {
currentFileIndex++;
} else {
// All files have alt text, proceed with upload
showAltTextModal = false;
startUpload();
}
}
function handleAltTextSkip() {
// Skip remaining files and proceed with upload
showAltTextModal = false;
startUpload();
}
function handleAltTextCancel() {
// Cancel upload
showAltTextModal = false;
pendingFiles = [];
currentFileIndex = 0;
if (fileInputRef) {
fileInputRef.value = '';
}
}
async function startUpload() {
uploading = true; uploading = true;
const uploadPromises: Promise<void>[] = []; const uploadPromises: Promise<void>[] = [];
// Process all files // Process all files with their alt text
for (const file of validFiles) { for (const { file, altText } of pendingFiles) {
const uploadPromise = (async () => { const uploadPromise = (async () => {
try { try {
// Upload file to media server // Upload file to media server
const uploadResult = await uploadFileToServer(file, 'AdvancedEditor'); const uploadResult = await uploadFileToServer(file, 'AdvancedEditor');
console.log(`[AdvancedEditor] Uploaded ${file.name} to ${uploadResult.url}`); console.log(`[AdvancedEditor] Uploaded ${file.name} to ${uploadResult.url}`);
// Build imeta tag from upload response (NIP-92 format) // Build imeta tag from upload response (NIP-92 format) with alt text
const imetaTag = buildImetaTag(file, uploadResult); const imetaTag = buildImetaTag(file, uploadResult, altText);
// Store file with imeta tag // Store file with imeta tag
uploadedFiles.push({ uploadedFiles.push({
@ -715,6 +760,8 @@
await Promise.all(uploadPromises); await Promise.all(uploadPromises);
} finally { } finally {
uploading = false; uploading = false;
pendingFiles = [];
currentFileIndex = 0;
// Reset file input // Reset file input
if (fileInputRef) { if (fileInputRef) {
fileInputRef.value = ''; fileInputRef.value = '';
@ -1161,6 +1208,72 @@
{#if mediaViewerUrl && mediaViewerOpen} {#if mediaViewerUrl && mediaViewerOpen}
<MediaViewer url={mediaViewerUrl} isOpen={mediaViewerOpen} onClose={closeMediaViewer} /> <MediaViewer url={mediaViewerUrl} isOpen={mediaViewerOpen} onClose={closeMediaViewer} />
{#if showAltTextModal && pendingFiles.length > 0}
<div
class="alt-text-modal-backdrop"
onclick={handleAltTextCancel}
onkeydown={(e) => {
if (e.key === 'Escape') {
handleAltTextCancel();
}
}}
role="button"
tabindex="-1"
aria-label="Close modal"
>
<div
class="alt-text-modal"
onclick={(e) => e.stopPropagation()}
onkeydown={(e) => e.stopPropagation()}
role="dialog"
aria-modal="true"
aria-labelledby="alt-text-title-advanced"
tabindex="-1"
>
<div class="alt-text-modal-header">
<h3 id="alt-text-title-advanced">Add Alt Text</h3>
<button type="button" class="alt-text-close" onclick={handleAltTextCancel} aria-label="Close">×</button>
</div>
<div class="alt-text-modal-content">
<p class="alt-text-file-info">
File {currentFileIndex + 1} of {pendingFiles.length}: <strong>{pendingFiles[currentFileIndex].file.name}</strong>
</p>
<label for="alt-text-input-advanced" class="alt-text-label">
Alt text (optional, for accessibility):
</label>
<input
type="text"
id="alt-text-input-advanced"
class="alt-text-input"
placeholder="Describe the image for screen readers..."
bind:value={pendingFiles[currentFileIndex].altText}
onkeydown={(e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
handleAltTextNext();
} else if (e.key === 'Escape') {
e.preventDefault();
handleAltTextCancel();
}
}}
use:autofocus
/>
</div>
<div class="alt-text-modal-footer">
<button type="button" class="alt-text-button alt-text-button-secondary" onclick={handleAltTextCancel}>
Cancel
</button>
<button type="button" class="alt-text-button alt-text-button-secondary" onclick={handleAltTextSkip}>
Skip All
</button>
<button type="button" class="alt-text-button alt-text-button-primary" onclick={handleAltTextNext}>
{currentFileIndex < pendingFiles.length - 1 ? 'Next' : 'Upload'}
</button>
</div>
</div>
</div>
{/if}
{/if} {/if}
<style> <style>
@ -1745,4 +1858,207 @@
margin: 0 0.125rem; margin: 0 0.125rem;
} }
} }
.alt-text-modal-backdrop {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
z-index: 10000;
display: flex;
align-items: center;
justify-content: center;
padding: 1rem;
}
.alt-text-modal {
background: var(--fog-post, #ffffff);
border: 1px solid var(--fog-border, #e5e7eb);
border-radius: 0.5rem;
max-width: 500px;
width: 100%;
max-height: 90vh;
display: flex;
flex-direction: column;
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.2);
}
:global(.dark) .alt-text-modal {
background: var(--fog-dark-post, #1f2937);
border-color: var(--fog-dark-border, #374151);
}
.alt-text-modal-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1rem 1.5rem;
border-bottom: 1px solid var(--fog-border, #e5e7eb);
}
:global(.dark) .alt-text-modal-header {
border-bottom-color: var(--fog-dark-border, #374151);
}
.alt-text-modal-header h3 {
margin: 0;
font-size: 1.125rem;
font-weight: 600;
color: var(--fog-text, #1f2937);
}
:global(.dark) .alt-text-modal-header h3 {
color: var(--fog-dark-text, #f9fafb);
}
.alt-text-close {
background: none;
border: none;
font-size: 1.5rem;
line-height: 1;
color: var(--fog-text-light, #52667a);
cursor: pointer;
padding: 0;
width: 2rem;
height: 2rem;
display: flex;
align-items: center;
justify-content: center;
border-radius: 0.25rem;
transition: all 0.2s;
}
.alt-text-close:hover {
background: var(--fog-highlight, #f3f4f6);
color: var(--fog-text, #1f2937);
}
:global(.dark) .alt-text-close {
color: var(--fog-dark-text-light, #a8b8d0);
}
:global(.dark) .alt-text-close:hover {
background: var(--fog-dark-highlight, #374151);
color: var(--fog-dark-text, #f9fafb);
}
.alt-text-modal-content {
padding: 1.5rem;
flex: 1;
overflow-y: auto;
}
.alt-text-file-info {
margin: 0 0 1rem 0;
font-size: 0.875rem;
color: var(--fog-text-light, #52667a);
}
:global(.dark) .alt-text-file-info {
color: var(--fog-dark-text-light, #a8b8d0);
}
.alt-text-label {
display: block;
margin-bottom: 0.5rem;
font-size: 0.875rem;
font-weight: 500;
color: var(--fog-text, #1f2937);
}
:global(.dark) .alt-text-label {
color: var(--fog-dark-text, #f9fafb);
}
.alt-text-input {
width: 100%;
padding: 0.75rem;
border: 1px solid var(--fog-border, #e5e7eb);
border-radius: 0.25rem;
background: var(--fog-post, #ffffff);
color: var(--fog-text, #1f2937);
font-size: 0.875rem;
box-sizing: border-box;
}
.alt-text-input:focus {
outline: none;
border-color: var(--fog-accent, #64748b);
}
:global(.dark) .alt-text-input {
background: var(--fog-dark-post, #1f2937);
border-color: var(--fog-dark-border, #374151);
color: var(--fog-dark-text, #f9fafb);
}
:global(.dark) .alt-text-input:focus {
border-color: var(--fog-dark-accent, #94a3b8);
}
.alt-text-modal-footer {
display: flex;
justify-content: flex-end;
gap: 0.5rem;
padding: 1rem 1.5rem;
border-top: 1px solid var(--fog-border, #e5e7eb);
}
:global(.dark) .alt-text-modal-footer {
border-top-color: var(--fog-dark-border, #374151);
}
.alt-text-button {
padding: 0.5rem 1rem;
border: 1px solid var(--fog-border, #e5e7eb);
border-radius: 0.25rem;
font-size: 0.875rem;
font-weight: 500;
cursor: pointer;
transition: all 0.2s;
}
.alt-text-button-secondary {
background: var(--fog-post, #ffffff);
color: var(--fog-text, #1f2937);
}
.alt-text-button-secondary:hover {
background: var(--fog-highlight, #f3f4f6);
border-color: var(--fog-accent, #64748b);
}
.alt-text-button-primary {
background: var(--fog-accent, #64748b);
color: white;
border-color: var(--fog-accent, #64748b);
}
.alt-text-button-primary:hover {
background: var(--fog-text, #475569);
border-color: var(--fog-text, #475569);
}
:global(.dark) .alt-text-button-secondary {
background: var(--fog-dark-post, #1f2937);
color: var(--fog-dark-text, #f9fafb);
border-color: var(--fog-dark-border, #374151);
}
:global(.dark) .alt-text-button-secondary:hover {
background: var(--fog-dark-highlight, #374151);
border-color: var(--fog-dark-accent, #94a3b8);
}
:global(.dark) .alt-text-button-primary {
background: var(--fog-dark-accent, #94a3b8);
border-color: var(--fog-dark-accent, #94a3b8);
}
:global(.dark) .alt-text-button-primary:hover {
background: var(--fog-dark-text, #cbd5e1);
border-color: var(--fog-dark-text, #cbd5e1);
}
</style> </style>

2
src/lib/modules/discussions/DiscussionCard.svelte

@ -237,7 +237,7 @@
<div class="post-content mb-2"> <div class="post-content mb-2">
{#if shouldAutoRenderMedia} {#if shouldAutoRenderMedia}
<MediaAttachments event={thread} forceRender={isMediaKind} onMediaClick={handleMediaUrlClick} /> <MediaAttachments event={thread} forceRender={isMediaKind} onMediaClick={handleMediaUrlClick} isFeedView={false} />
{/if} {/if}
<MarkdownRenderer content={thread.content} event={thread} /> <MarkdownRenderer content={thread.content} event={thread} />
</div> </div>

38
src/lib/modules/feed/FeedPost.svelte

@ -946,8 +946,9 @@
{/if} {/if}
<div class="post-content mb-2"> <div class="post-content mb-2">
<!-- Always render media attachments in full view -->
{#if (shouldAutoRenderMedia || fullView) && (post.content && post.content.trim() || isMediaKind)} {#if (shouldAutoRenderMedia || fullView) && (post.content && post.content.trim() || isMediaKind)}
<MediaAttachments event={post} forceRender={isMediaKind} onMediaClick={(url, e) => handleMediaUrlClick(e, url)} /> <MediaAttachments event={post} forceRender={isMediaKind} onMediaClick={(url, e) => handleMediaUrlClick(e, url)} isFeedView={false} />
{/if} {/if}
{#if post.kind === KIND.POLL && fullView} {#if post.kind === KIND.POLL && fullView}
<PollCard pollEvent={post} /> <PollCard pollEvent={post} />
@ -1016,9 +1017,8 @@
{/if} {/if}
<div bind:this={contentElement} class="post-content mb-2" class:collapsed-content={!fullView && shouldCollapse && !isExpanded}> <div bind:this={contentElement} class="post-content mb-2" class:collapsed-content={!fullView && shouldCollapse && !isExpanded}>
{#if shouldAutoRenderMedia} <!-- Always render media attachments in feed view -->
<MediaAttachments event={post} forceRender={isMediaKind} onMediaClick={(url, e) => handleMediaUrlClick(e, url)} /> <MediaAttachments event={post} forceRender={isMediaKind} onMediaClick={(url, e) => handleMediaUrlClick(e, url)} isFeedView={true} />
{/if}
<p class="text-fog-text dark:text-fog-dark-text whitespace-pre-wrap word-wrap"> <p class="text-fog-text dark:text-fog-dark-text whitespace-pre-wrap word-wrap">
{#each parseContentWithNIP21Links() as segment} {#each parseContentWithNIP21Links() as segment}
{@const highlightContent = getHighlightContent()} {@const highlightContent = getHighlightContent()}
@ -1087,22 +1087,6 @@
{/if} {/if}
{/each} {/each}
</p> </p>
{#if getMediaUrls().length > 0}
{@const mediaUrls = getMediaUrls()}
<div class="media-urls mt-2">
{#each mediaUrls as url}
<button
type="button"
onclick={(e) => handleMediaUrlClick(e, url)}
class="media-url-link text-fog-accent dark:text-fog-dark-accent hover:underline break-all bg-transparent border-none p-0 cursor-pointer text-left"
style="font-size: 0.875em;"
>
{url}
</button>
{/each}
</div>
{/if}
</div> </div>
{#if !fullView && shouldCollapse} {#if !fullView && shouldCollapse}
@ -1279,20 +1263,6 @@
} }
.media-urls {
display: flex;
flex-direction: column;
gap: 0.5rem;
align-items: flex-start; /* Left-align URLs */
}
.media-url-link {
word-break: break-all;
overflow-wrap: anywhere;
text-align: left; /* Ensure left alignment */
max-width: 100%;
}
.nostr-event-link { .nostr-event-link {
word-break: break-all !important; word-break: break-all !important;
overflow-wrap: anywhere !important; overflow-wrap: anywhere !important;

11
src/lib/services/nostr/file-upload.ts

@ -138,11 +138,13 @@ export async function uploadFileToServer(
* Build an imeta tag (NIP-92) from upload response * Build an imeta tag (NIP-92) from upload response
* @param file - The original file * @param file - The original file
* @param uploadResult - The upload result with URL and tags * @param uploadResult - The upload result with URL and tags
* @param altText - Optional alt text for accessibility
* @returns imeta tag array in NIP-92 format: ['imeta', 'url <url>', 'm <mime>', ...] * @returns imeta tag array in NIP-92 format: ['imeta', 'url <url>', 'm <mime>', ...]
*/ */
export function buildImetaTag( export function buildImetaTag(
file: File, file: File,
uploadResult: UploadResult uploadResult: UploadResult,
altText?: string
): string[] { ): string[] {
const { url, tags } = uploadResult; const { url, tags } = uploadResult;
const imetaFields: string[] = []; const imetaFields: string[] = [];
@ -163,9 +165,14 @@ export function buildImetaTag(
imetaFields.push(`m ${file.type}`); imetaFields.push(`m ${file.type}`);
} }
// Add alt text if provided
if (altText && altText.trim()) {
imetaFields.push(`alt ${altText.trim()}`);
}
// Add all other tags from upload response // Add all other tags from upload response
for (const [name, value] of tags) { for (const [name, value] of tags) {
if (name !== 'url' && name !== 'm' && value) { if (name !== 'url' && name !== 'm' && name !== 'alt' && value) {
imetaFields.push(`${name} ${value}`); imetaFields.push(`${name} ${value}`);
} }
} }

7
src/routes/about/+page.svelte

@ -5,7 +5,7 @@
import Icon from '../../lib/components/ui/Icon.svelte'; import Icon from '../../lib/components/ui/Icon.svelte';
import { getAppVersion } from '../../lib/services/version-manager.js'; import { getAppVersion } from '../../lib/services/version-manager.js';
let appVersion = $state('0.3.0'); let appVersion = $state('0.3.1');
onMount(async () => { onMount(async () => {
appVersion = await getAppVersion(); appVersion = await getAppVersion();
@ -21,6 +21,11 @@
// Changelog for current version // Changelog for current version
const changelog: Record<string, string[]> = { const changelog: Record<string, string[]> = {
'0.3.1': [
'Media attachments rendering in all feeds and views',
'NIP-92/NIP-94 image tags support',
'Blossom support for media attachments',
],
'0.3.0': [ '0.3.0': [
'Version history modal added to event menu', 'Version history modal added to event menu',
'Event and npub deletion/reporting added', 'Event and npub deletion/reporting added',

Loading…
Cancel
Save