From 5297701376255187406b45b8a83a3dc8a3fb12ba Mon Sep 17 00:00:00 2001 From: Ben Clinkinbeard Date: Sun, 29 Mar 2026 14:32:34 -0400 Subject: [PATCH 1/2] Add links list mode with URL previews and tweet embeds --- api/link-preview.js | 100 +++++++++++++++++++++++ app.css | 67 +++++++++++++++- app.js | 189 +++++++++++++++++++++++++++++++++++++++++--- index.html | 2 + 4 files changed, 345 insertions(+), 13 deletions(-) create mode 100644 api/link-preview.js diff --git a/api/link-preview.js b/api/link-preview.js new file mode 100644 index 0000000..8c58618 --- /dev/null +++ b/api/link-preview.js @@ -0,0 +1,100 @@ +import { jsonResponse, serverError } from '../server/json.js'; + +const MAX_HTML_LENGTH = 180000; +const REQUEST_TIMEOUT_MS = 6000; + +function extractMetaTag(html, attribute, value) { + const pattern = new RegExp(`]*${attribute}=["']${value}["'][^>]*content=["']([^"']+)["'][^>]*>`, 'i'); + const match = html.match(pattern); + return match ? decodeHtml(match[1].trim()) : ''; +} + +function extractTitle(html) { + const match = html.match(/]*>([\s\S]*?)<\/title>/i); + return match ? decodeHtml(match[1].replace(/\s+/g, ' ').trim()) : ''; +} + +function decodeHtml(value) { + return String(value || '') + .replace(/&/g, '&') + .replace(/</g, '<') + .replace(/>/g, '>') + .replace(/"/g, '"') + .replace(/'/g, '\''); +} + +function truncate(value, maxLength = 280) { + if (!value) return ''; + return value.length <= maxLength ? value : `${value.slice(0, maxLength - 1).trim()}…`; +} + +async function fetchHtml(url) { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS); + try { + const response = await fetch(url, { + redirect: 'follow', + signal: controller.signal, + headers: { + 'User-Agent': 'voice-notes-link-preview/1.0', + Accept: 'text/html,application/xhtml+xml', + }, + }); + if (!response.ok) { + throw new Error(`Upstream request failed with ${response.status}`); + } + const text = await response.text(); + return text.slice(0, MAX_HTML_LENGTH); + } finally { + clearTimeout(timeout); + } +} + +function buildPreview(html, url) { + const ogTitle = extractMetaTag(html, 'property', 'og:title'); + const twitterTitle = extractMetaTag(html, 'name', 'twitter:title'); + const title = truncate(ogTitle || twitterTitle || extractTitle(html) || url, 180); + + const ogDescription = extractMetaTag(html, 'property', 'og:description'); + const metaDescription = extractMetaTag(html, 'name', 'description'); + const twitterDescription = extractMetaTag(html, 'name', 'twitter:description'); + const description = truncate(ogDescription || twitterDescription || metaDescription, 320); + + const siteName = truncate(extractMetaTag(html, 'property', 'og:site_name'), 100); + + return { + title, + description, + summary: description, + siteName, + url, + }; +} + +export async function GET(request) { + try { + const requestUrl = new URL(request.url); + const target = requestUrl.searchParams.get('url') || ''; + if (!target) { + return jsonResponse({ ok: false, error: 'Missing url query parameter.' }, { status: 400 }); + } + + let parsed; + try { + parsed = new URL(target); + } catch { + return jsonResponse({ ok: false, error: 'Invalid URL.' }, { status: 400 }); + } + + if (!/^https?:$/i.test(parsed.protocol)) { + return jsonResponse({ ok: false, error: 'Only HTTP(S) URLs are supported.' }, { status: 400 }); + } + + const html = await fetchHtml(parsed.toString()); + const preview = buildPreview(html, parsed.toString()); + + return jsonResponse({ ok: true, preview }); + } catch (error) { + return serverError(error.message || 'Failed to fetch link preview.'); + } +} diff --git a/app.css b/app.css index 02b2e96..4aa3717 100644 --- a/app.css +++ b/app.css @@ -640,6 +640,11 @@ header { color: var(--accent); } +.list-mode-badge[data-mode="links"] { + background: rgba(37, 99, 235, 0.18); + color: #93c5fd; +} + .list-card-count { font-size: 0.75rem; color: var(--text-muted); @@ -697,6 +702,11 @@ header { color: var(--accent); } +#list-detail-mode[data-mode="links"] { + background: rgba(37, 99, 235, 0.18); + color: #93c5fd; +} + #list-detail-actions { display: flex; gap: 4px; @@ -975,6 +985,60 @@ header { font-style: italic; } +.note-link, +.link-preview-title { + color: #93c5fd; + text-decoration: none; +} + +.note-link:hover, +.note-link:focus-visible, +.link-preview-title:hover, +.link-preview-title:focus-visible { + text-decoration: underline; +} + +.note-link { + margin-top: 10px; + display: inline-block; + font-size: 0.78rem; +} + +.link-preview { + border: 1px solid var(--surface-2); + border-radius: 12px; + padding: 10px; + background: rgba(255, 255, 255, 0.02); +} + +.link-preview.loading { + color: var(--text-muted); + font-size: 0.8rem; +} + +.link-preview-excerpt { + margin: 6px 0 0; + font-size: 0.8rem; + color: var(--text-muted); + line-height: 1.45; +} + +.link-preview-footer { + margin-top: 8px; + font-size: 0.72rem; + color: var(--text-muted); + text-transform: uppercase; + letter-spacing: 0.04em; +} + +.tweet-embed { + border: none; + width: 100%; + min-height: 420px; + border-radius: 12px; + background: #fff; +} + .note-actions { display: flex; flex-direction: column; @@ -1150,7 +1214,8 @@ header { } #mode-selector { - display: flex; + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 8px; margin-bottom: 8px; } diff --git a/app.js b/app.js index e774351..91ff677 100644 --- a/app.js +++ b/app.js @@ -17,8 +17,10 @@ const DB_VERSION = 4; const AUTO_PUSH_DELAY_MS = 1200; const MODE_DESCRIPTIONS = { capture: 'Record and save voice notes.', - accomplish: 'Track tasks with checkboxes and reordering.' + accomplish: 'Track tasks with checkboxes and reordering.', + links: 'Save URLs and preview each link as a card.' }; +const LINK_PREVIEW_ENDPOINT = '/api/link-preview'; // --- DOM References --- @@ -102,6 +104,7 @@ let syncBusy = false; let autoPushTimer = null; let autoPushQueued = false; let textEntryBusy = false; +const linkPreviewCache = new Map(); const SYNC_META_KEYS = { lastCloudPullAt: 'voice-notes-last-cloud-pull-at', @@ -1060,6 +1063,124 @@ function normalizeManualNoteText(text) { return String(text || '').replace(/\r\n?/g, '\n').trim(); } +function normalizeLinkInput(value) { + const trimmed = String(value || '').trim(); + if (!trimmed) return ''; + if (/^https?:\/\//i.test(trimmed)) return trimmed; + return `https://${trimmed}`; +} + +function isTweetUrl(value) { + try { + const parsed = new URL(value); + const host = parsed.hostname.replace(/^www\./, '').toLowerCase(); + if (host !== 'twitter.com' && host !== 'x.com') return false; + return /\/status\/\d+/i.test(parsed.pathname); + } catch { + return false; + } +} + +function buildTwitframeUrl(url) { + return `https://twitframe.com/show?url=${encodeURIComponent(url)}`; +} + +function toHttpUrlOrNull(value) { + try { + const parsed = new URL(value); + return /^https?:$/i.test(parsed.protocol) ? parsed.toString() : null; + } catch { + return null; + } +} + +async function fetchLinkPreview(url) { + if (linkPreviewCache.has(url)) { + return linkPreviewCache.get(url); + } + + const request = fetch(`${LINK_PREVIEW_ENDPOINT}?url=${encodeURIComponent(url)}`) + .then((response) => { + if (!response.ok) { + throw new Error('Preview unavailable'); + } + return response.json(); + }) + .then((payload) => { + if (!payload || !payload.ok || !payload.preview) { + throw new Error('Preview unavailable'); + } + return payload.preview; + }) + .catch(() => null); + + linkPreviewCache.set(url, request); + return request; +} + +function createExternalLink(url, label = 'Open original') { + const safeUrl = toHttpUrlOrNull(url); + if (!safeUrl) return document.createTextNode(''); + const anchor = document.createElement('a'); + anchor.className = 'note-link'; + anchor.href = safeUrl; + anchor.target = '_blank'; + anchor.rel = 'noopener noreferrer'; + anchor.textContent = label; + return anchor; +} + +async function populateLinkPreview(url, container) { + const safeUrl = toHttpUrlOrNull(url); + if (!safeUrl) { + container.textContent = 'Invalid URL'; + return; + } + if (isTweetUrl(safeUrl)) { + const frame = document.createElement('iframe'); + frame.className = 'tweet-embed'; + frame.loading = 'lazy'; + frame.referrerPolicy = 'no-referrer-when-downgrade'; + frame.src = buildTwitframeUrl(safeUrl); + frame.title = 'Embedded tweet'; + container.replaceChildren(frame); + return; + } + + const preview = await fetchLinkPreview(safeUrl); + container.innerHTML = ''; + + const title = document.createElement('a'); + title.className = 'link-preview-title'; + title.href = safeUrl; + title.target = '_blank'; + title.rel = 'noopener noreferrer'; + title.textContent = preview && preview.title ? preview.title : safeUrl; + container.appendChild(title); + + const excerptValue = preview && (preview.summary || preview.description); + if (excerptValue) { + const excerpt = document.createElement('p'); + excerpt.className = 'link-preview-excerpt'; + excerpt.textContent = excerptValue; + container.appendChild(excerpt); + } + + const footer = document.createElement('div'); + footer.className = 'link-preview-footer'; + const siteText = preview && preview.siteName + ? preview.siteName + : (() => { + try { + return new URL(safeUrl).hostname; + } catch { + return 'Open link'; + } + })(); + footer.textContent = siteText; + container.appendChild(footer); +} + function shouldSubmitTextEntryOnEnter() { return !window.matchMedia('(pointer: coarse)').matches; } @@ -1090,9 +1211,14 @@ async function saveEditedNote(noteId, nextText) { function updateEntryModeUi() { const hasList = Boolean(currentListId); const isTextMode = currentEntryMode === 'text'; + const currentMode = listDetailMode ? listDetailMode.dataset.mode : ''; + const isLinks = currentMode === 'links'; + if (hasList && isLinks && !isTextMode) { + currentEntryMode = 'text'; + } if (entryModeToggle) { - entryModeToggle.classList.toggle('hidden', !hasList); + entryModeToggle.classList.toggle('hidden', !hasList || isLinks); } if (voiceEntryBtn) { @@ -1106,7 +1232,7 @@ function updateEntryModeUi() { } if (recorderEl) { - recorderEl.classList.toggle('hidden', !hasList || isTextMode); + recorderEl.classList.toggle('hidden', !hasList || isTextMode || isLinks); } if (textEntryPanel) { @@ -1115,22 +1241,26 @@ function updateEntryModeUi() { if (!hasList) return; - const isAccomplish = listDetailMode && listDetailMode.dataset.mode === 'accomplish'; + const isAccomplish = currentMode === 'accomplish'; if (textNoteInput) { - textNoteInput.placeholder = isAccomplish ? 'Type a task' : 'Type a note'; + textNoteInput.placeholder = isLinks + ? 'Paste a URL' + : (isAccomplish ? 'Type a task' : 'Type a note'); } if (textEntryHint) { textEntryHint.textContent = shouldSubmitTextEntryOnEnter() - ? (isAccomplish + ? (isLinks + ? 'Press Enter to save the link. Use Shift+Enter for a line break.' + : (isAccomplish ? 'Press Enter to add the task and keep moving. Use Shift+Enter for a line break.' - : 'Press Enter to add another note. Use Shift+Enter for a new line.') + : 'Press Enter to add another note. Use Shift+Enter for a new line.')) : 'Use the button to add another note. Return inserts a new line on touch keyboards.'; } if (textNoteSubmitBtn) { - textNoteSubmitBtn.textContent = isAccomplish ? 'Add Task' : 'Add Note'; + textNoteSubmitBtn.textContent = isLinks ? 'Add Link' : (isAccomplish ? 'Add Task' : 'Add Note'); textNoteSubmitBtn.disabled = textEntryBusy; } @@ -1149,7 +1279,10 @@ async function createTextNote() { if (textEntryBusy || !currentListId || !textNoteInput) return false; const listIdAtCreate = currentListId; - const transcription = normalizeManualNoteText(textNoteInput.value); + const currentMode = listDetailMode ? listDetailMode.dataset.mode : ''; + const isLinks = currentMode === 'links'; + const rawValue = normalizeManualNoteText(textNoteInput.value); + const transcription = isLinks ? normalizeLinkInput(rawValue) : rawValue; if (!transcription) { focusTextEntryInput(); return false; @@ -1160,6 +1293,17 @@ async function createTextNote() { try { const list = await getList(listIdAtCreate); if (!list) return false; + if (list.mode === 'links') { + try { + const parsed = new URL(transcription); + if (!/^https?:$/i.test(parsed.protocol)) { + throw new Error('Only http(s) URLs are supported.'); + } + } catch { + alert('Please enter a valid URL.'); + return false; + } + } if (!list.noteOrder) { list.noteOrder = []; @@ -1188,6 +1332,7 @@ function createNoteCard(note, list) { card.className = 'note-card'; card.dataset.noteId = note.id; const isAccomplish = list && list.mode === 'accomplish'; + const isLinks = list && list.mode === 'links'; const isEditing = editingNoteId === note.id; if (isEditing) { card.classList.add('editing'); @@ -1272,6 +1417,15 @@ function createNoteCard(note, list) { editActions.appendChild(saveBtn); content.appendChild(editActions); + } else if (isLinks) { + const previewContainer = document.createElement('div'); + previewContainer.className = 'link-preview loading'; + previewContainer.textContent = 'Loading link preview…'; + content.appendChild(previewContainer); + populateLinkPreview(note.transcription, previewContainer).finally(() => { + previewContainer.classList.remove('loading'); + }); + content.appendChild(createExternalLink(note.transcription)); } else { const transcriptionEl = document.createElement('p'); transcriptionEl.className = 'note-transcription'; @@ -1284,7 +1438,7 @@ function createNoteCard(note, list) { content.appendChild(transcriptionEl); } - const hasAudio = !!note.audioBlob; + const hasAudio = !!note.audioBlob && !isLinks; if (hasAudio) { const meta = document.createElement('div'); meta.className = 'note-meta'; @@ -1444,7 +1598,9 @@ function createListCard(list, noteCount) { const modeBadge = document.createElement('span'); modeBadge.className = 'list-mode-badge'; - modeBadge.textContent = list.mode === 'accomplish' ? 'Accomplish' : 'Capture'; + modeBadge.textContent = list.mode === 'accomplish' + ? 'Accomplish' + : (list.mode === 'links' ? 'Links' : 'Capture'); modeBadge.dataset.mode = list.mode; const count = document.createElement('span'); @@ -1511,8 +1667,15 @@ async function renderListDetail(listId) { if (!list) return; listDetailName.textContent = list.name; - listDetailMode.textContent = list.mode === 'accomplish' ? 'Accomplish' : 'Capture'; + listDetailMode.textContent = list.mode === 'accomplish' + ? 'Accomplish' + : (list.mode === 'links' ? 'Links' : 'Capture'); listDetailMode.dataset.mode = list.mode; + if (listEmptyState) { + listEmptyState.textContent = list.mode === 'links' + ? 'No links yet. Paste a URL to save your first link.' + : 'No notes yet. Record one or switch to text mode.'; + } const notes = await getNotesByList(listId); @@ -1927,6 +2090,8 @@ if (textNoteInput) { recordBtn.addEventListener('click', async () => { if (recordBusy) return; if (!currentListId) return; + const activeList = await getList(currentListId); + if (!activeList || activeList.mode === 'links') return; try { recordBusy = true; diff --git a/index.html b/index.html index 11cc332..4bdb263 100644 --- a/index.html +++ b/index.html @@ -100,6 +100,7 @@

New List

+

Record and save voice notes.

@@ -122,6 +123,7 @@

How to Use Voice Notes

Capture mode — Record voice notes with audio playback. Great for memos, thoughts, and anything you want to listen back to.

Accomplish mode — Speak your to-do items and they become a checklist. Say "and" between items to create multiple entries at once (e.g. "milk and eggs and bread"). Audio is not saved — only the text.

+

Links mode — Save one URL per note. Tweets are embedded inline, and other links show a title plus a short summary.

Text mode — Switch from voice to text when you want to type quickly. Press Enter to save the current note and keep moving, or use Shift+Enter for a new line inside the same note.

Tap + New List to get started, then record or type notes. You can edit note text anytime, and cloud sync is optional if you want the same notes on multiple devices.

From 47b3d4020927c22d1fb228ea1440f868742ae618 Mon Sep 17 00:00:00 2001 From: Ben Clinkinbeard Date: Sun, 29 Mar 2026 15:48:24 -0400 Subject: [PATCH 2/2] Harden links input and add persistent preview caching --- api/link-preview.js | 34 +++++++++++++++++-- app.js | 82 ++++++++++++++++++++++++++++++++++++++++++--- 2 files changed, 109 insertions(+), 7 deletions(-) diff --git a/api/link-preview.js b/api/link-preview.js index 8c58618..3f8f338 100644 --- a/api/link-preview.js +++ b/api/link-preview.js @@ -2,6 +2,8 @@ import { jsonResponse, serverError } from '../server/json.js'; const MAX_HTML_LENGTH = 180000; const REQUEST_TIMEOUT_MS = 6000; +const PREVIEW_CACHE_TTL_MS = 1000 * 60 * 60 * 12; +const previewCache = new Map(); function extractMetaTag(html, attribute, value) { const pattern = new RegExp(`]*${attribute}=["']${value}["'][^>]*content=["']([^"']+)["'][^>]*>`, 'i'); @@ -28,6 +30,21 @@ function truncate(value, maxLength = 280) { return value.length <= maxLength ? value : `${value.slice(0, maxLength - 1).trim()}…`; } +function stripTags(value) { + return String(value || '') + .replace(//gi, ' ') + .replace(//gi, ' ') + .replace(/<[^>]+>/g, ' ') + .replace(/\s+/g, ' ') + .trim(); +} + +function extractFirstParagraph(html) { + const match = html.match(/]*>([\s\S]*?)<\/p>/i); + if (!match) return ''; + return decodeHtml(stripTags(match[1])); +} + async function fetchHtml(url) { const controller = new AbortController(); const timeout = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS); @@ -58,7 +75,8 @@ function buildPreview(html, url) { const ogDescription = extractMetaTag(html, 'property', 'og:description'); const metaDescription = extractMetaTag(html, 'name', 'description'); const twitterDescription = extractMetaTag(html, 'name', 'twitter:description'); - const description = truncate(ogDescription || twitterDescription || metaDescription, 320); + const firstParagraph = extractFirstParagraph(html); + const description = truncate(ogDescription || twitterDescription || metaDescription || firstParagraph, 320); const siteName = truncate(extractMetaTag(html, 'property', 'og:site_name'), 100); @@ -90,8 +108,18 @@ export async function GET(request) { return jsonResponse({ ok: false, error: 'Only HTTP(S) URLs are supported.' }, { status: 400 }); } - const html = await fetchHtml(parsed.toString()); - const preview = buildPreview(html, parsed.toString()); + const normalizedUrl = parsed.toString(); + const cached = previewCache.get(normalizedUrl); + if (cached && Date.now() - cached.cachedAt < PREVIEW_CACHE_TTL_MS) { + return jsonResponse({ ok: true, preview: cached.preview, cached: true }); + } + + const html = await fetchHtml(normalizedUrl); + const preview = buildPreview(html, normalizedUrl); + previewCache.set(normalizedUrl, { + cachedAt: Date.now(), + preview, + }); return jsonResponse({ ok: true, preview }); } catch (error) { diff --git a/app.js b/app.js index 91ff677..811e814 100644 --- a/app.js +++ b/app.js @@ -21,6 +21,8 @@ const MODE_DESCRIPTIONS = { links: 'Save URLs and preview each link as a card.' }; const LINK_PREVIEW_ENDPOINT = '/api/link-preview'; +const LINK_PREVIEW_STORAGE_KEY = 'voice-notes-link-previews-v1'; +const LINK_PREVIEW_TTL_MS = 1000 * 60 * 60 * 24; // --- DOM References --- @@ -105,6 +107,7 @@ let autoPushTimer = null; let autoPushQueued = false; let textEntryBusy = false; const linkPreviewCache = new Map(); +let persistedLinkPreviewCacheLoaded = false; const SYNC_META_KEYS = { lastCloudPullAt: 'voice-notes-last-cloud-pull-at', @@ -1063,11 +1066,27 @@ function normalizeManualNoteText(text) { return String(text || '').replace(/\r\n?/g, '\n').trim(); } +function extractFirstUrlCandidate(value) { + const normalized = String(value || '').trim(); + if (!normalized) return ''; + const markdownMatch = normalized.match(/\[[^\]]+\]\((https?:\/\/[^)\s]+)\)/i); + if (markdownMatch && markdownMatch[1]) { + return markdownMatch[1]; + } + const directMatch = normalized.match(/https?:\/\/[^\s<>"')]+/i); + if (directMatch && directMatch[0]) { + return directMatch[0]; + } + return normalized; +} + function normalizeLinkInput(value) { - const trimmed = String(value || '').trim(); + const extracted = extractFirstUrlCandidate(value); + const trimmed = String(extracted || '').trim(); if (!trimmed) return ''; - if (/^https?:\/\//i.test(trimmed)) return trimmed; - return `https://${trimmed}`; + const withoutTrailingPunctuation = trimmed.replace(/[),.;!?]+$/g, ''); + if (/^https?:\/\//i.test(withoutTrailingPunctuation)) return withoutTrailingPunctuation; + return `https://${withoutTrailingPunctuation}`; } function isTweetUrl(value) { @@ -1095,6 +1114,7 @@ function toHttpUrlOrNull(value) { } async function fetchLinkPreview(url) { + ensurePersistedLinkPreviewCacheLoaded(); if (linkPreviewCache.has(url)) { return linkPreviewCache.get(url); } @@ -1110,14 +1130,63 @@ async function fetchLinkPreview(url) { if (!payload || !payload.ok || !payload.preview) { throw new Error('Preview unavailable'); } + cachePreviewPayload(url, payload.preview); return payload.preview; }) - .catch(() => null); + .catch(() => readPersistedPreview(url)); linkPreviewCache.set(url, request); return request; } +function ensurePersistedLinkPreviewCacheLoaded() { + if (persistedLinkPreviewCacheLoaded) return; + persistedLinkPreviewCacheLoaded = true; + try { + const raw = localStorage.getItem(LINK_PREVIEW_STORAGE_KEY); + if (!raw) return; + const parsed = JSON.parse(raw); + if (!parsed || typeof parsed !== 'object') return; + const now = Date.now(); + for (const [url, entry] of Object.entries(parsed)) { + if (!entry || typeof entry !== 'object') continue; + if (!entry.preview || !entry.savedAt || now - entry.savedAt > LINK_PREVIEW_TTL_MS) continue; + linkPreviewCache.set(url, Promise.resolve(entry.preview)); + } + } catch { + // Ignore cache parse/storage failures. + } +} + +function readPersistedPreview(url) { + try { + const raw = localStorage.getItem(LINK_PREVIEW_STORAGE_KEY); + if (!raw) return null; + const parsed = JSON.parse(raw); + const entry = parsed && parsed[url]; + if (!entry || !entry.preview || !entry.savedAt) return null; + if (Date.now() - entry.savedAt > LINK_PREVIEW_TTL_MS) return null; + return entry.preview; + } catch { + return null; + } +} + +function cachePreviewPayload(url, preview) { + try { + const raw = localStorage.getItem(LINK_PREVIEW_STORAGE_KEY); + const parsed = raw ? JSON.parse(raw) : {}; + const next = parsed && typeof parsed === 'object' ? parsed : {}; + next[url] = { + savedAt: Date.now(), + preview, + }; + localStorage.setItem(LINK_PREVIEW_STORAGE_KEY, JSON.stringify(next)); + } catch { + // Ignore cache storage failures. + } +} + function createExternalLink(url, label = 'Open original') { const safeUrl = toHttpUrlOrNull(url); if (!safeUrl) return document.createTextNode(''); @@ -1164,6 +1233,11 @@ async function populateLinkPreview(url, container) { excerpt.className = 'link-preview-excerpt'; excerpt.textContent = excerptValue; container.appendChild(excerpt); + } else if (!preview) { + const excerpt = document.createElement('p'); + excerpt.className = 'link-preview-excerpt'; + excerpt.textContent = 'Preview unavailable. Open the original link for details.'; + container.appendChild(excerpt); } const footer = document.createElement('div');