From e4d75730a6b6eb53beb6d9f8141facaae2b4a532 Mon Sep 17 00:00:00 2001 From: David Orban Date: Wed, 25 Mar 2026 09:27:29 +0100 Subject: [PATCH] feat: add download as markdown button to bookmark cards MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a FileText button (visible on hover, left of the existing media download button) that exports a bookmark as a Markdown file. The exported file contains: - Author, date, tweet URL, and categories as a header - Full stored tweet text (with URLs preserved, so truncated threads still show their t.co continuation link) - A note when the text appears to be a truncated thread (ends with t.co URL) - Embedded image links for photo media; video/GIF as linked references The download is entirely client-side — no new API routes, no new dependencies. The file is named tweet-{tweetId}.md. Co-Authored-By: Oz --- components/bookmark-card.tsx | 63 +++++++++++++++++++++++++++++++++++- 1 file changed, 62 insertions(+), 1 deletion(-) diff --git a/components/bookmark-card.tsx b/components/bookmark-card.tsx index be48a7a..e536889 100644 --- a/components/bookmark-card.tsx +++ b/components/bookmark-card.tsx @@ -1,7 +1,7 @@ 'use client' import React, { useRef, useEffect, useState } from 'react' -import { ExternalLink, Download, Play, Pencil, X, Check, ImageOff, Bookmark, Globe } from 'lucide-react' +import { ExternalLink, Download, FileText, Play, Pencil, X, Check, ImageOff, Bookmark, Globe } from 'lucide-react' import type { BookmarkWithMedia, Category } from '@/lib/types' // ── URL helpers ──────────────────────────────────────────────────────────────── @@ -625,6 +625,60 @@ export default function BookmarkCard({ bookmark }: BookmarkCardProps) { document.body.removeChild(a) } + function handleDownloadMarkdown() { + const lines: string[] = [] + + // Header + if (isKnownAuthor) { + lines.push(`# Tweet by @${bookmark.authorHandle}`) + lines.push('') + lines.push(`**Author:** ${bookmark.authorName} (@${bookmark.authorHandle})`) + } else { + lines.push(`# Bookmarked Tweet`) + } + if (dateStr) lines.push(`**Date:** ${dateStr}`) + lines.push(`**URL:** ${tweetUrl}`) + if (categories.length > 0) { + lines.push(`**Categories:** ${categories.map((c) => c.name).join(', ')}`) + } + lines.push('') + lines.push('---') + lines.push('') + + // Full stored text — keep URLs so truncated threads show the continuation link + if (bookmark.text) lines.push(bookmark.text) + + // If text ends with a t.co link the tweet may be part of a longer thread + if (TCO_REGEX.test(bookmark.text)) { + lines.push('') + lines.push(`> *This tweet may be part of a longer thread. [Read on X ↗](${tweetUrl})*`) + } + + // Media + if (bookmark.mediaItems.length > 0) { + lines.push('') + lines.push('---') + lines.push('') + for (const m of bookmark.mediaItems) { + if (m.type === 'photo') { + lines.push(`![Image](${m.url})`) + } else { + lines.push(`[${m.type === 'video' ? 'Video' : 'GIF'} — view on X](${tweetUrl})`) + } + } + } + + const blob = new Blob([lines.join('\n')], { type: 'text/markdown' }) + const url = URL.createObjectURL(blob) + const a = document.createElement('a') + a.href = url + a.download = `tweet-${bookmark.tweetId}.md` + document.body.appendChild(a) + a.click() + document.body.removeChild(a) + URL.revokeObjectURL(url) + } + // Only show download if media is a photo or a real video (not a thumbnail JPEG stored as video) const isDownloadable = firstMedia !== null && (firstMedia.type === 'photo' || isVideoUrl(firstMedia.url)) @@ -662,6 +716,13 @@ export default function BookmarkCard({ bookmark }: BookmarkCardProps) { {/* Actions — visible on hover */}
+ {isDownloadable && (