diff --git a/api/link-preview.js b/api/link-preview.js new file mode 100644 index 0000000..3f8f338 --- /dev/null +++ b/api/link-preview.js @@ -0,0 +1,128 @@ +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'); + 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()}…`; +} + +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); + 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 firstParagraph = extractFirstParagraph(html); + const description = truncate(ogDescription || twitterDescription || metaDescription || firstParagraph, 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 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) { + 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..811e814 100644 --- a/app.js +++ b/app.js @@ -17,8 +17,12 @@ 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'; +const LINK_PREVIEW_STORAGE_KEY = 'voice-notes-link-previews-v1'; +const LINK_PREVIEW_TTL_MS = 1000 * 60 * 60 * 24; // --- DOM References --- @@ -102,6 +106,8 @@ let syncBusy = false; 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', @@ -1060,6 +1066,195 @@ 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 extracted = extractFirstUrlCandidate(value); + const trimmed = String(extracted || '').trim(); + if (!trimmed) return ''; + const withoutTrailingPunctuation = trimmed.replace(/[),.;!?]+$/g, ''); + if (/^https?:\/\//i.test(withoutTrailingPunctuation)) return withoutTrailingPunctuation; + return `https://${withoutTrailingPunctuation}`; +} + +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) { + ensurePersistedLinkPreviewCacheLoaded(); + 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'); + } + cachePreviewPayload(url, payload.preview); + return payload.preview; + }) + .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(''); + 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); + } 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'); + 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 +1285,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 +1306,7 @@ function updateEntryModeUi() { } if (recorderEl) { - recorderEl.classList.toggle('hidden', !hasList || isTextMode); + recorderEl.classList.toggle('hidden', !hasList || isTextMode || isLinks); } if (textEntryPanel) { @@ -1115,22 +1315,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 +1353,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 +1367,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 +1406,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 +1491,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 +1512,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 +1672,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 +1741,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 +2164,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.