8 changed files with 181 additions and 416 deletions
@ -1,131 +0,0 @@ |
|||||||
<script lang="ts"> |
|
||||||
import type { OpenGraphData } from '../../services/content/opengraph-fetcher.js'; |
|
||||||
|
|
||||||
interface Props { |
|
||||||
data: OpenGraphData; |
|
||||||
url: string; |
|
||||||
} |
|
||||||
|
|
||||||
let { data, url }: Props = $props(); |
|
||||||
</script> |
|
||||||
|
|
||||||
<a href={url} target="_blank" rel="noopener noreferrer" class="opengraph-card"> |
|
||||||
{#if data.image} |
|
||||||
<div class="opengraph-image"> |
|
||||||
<img src={data.image} alt={data.title || ''} loading="lazy" /> |
|
||||||
</div> |
|
||||||
{/if} |
|
||||||
<div class="opengraph-content"> |
|
||||||
{#if data.siteName} |
|
||||||
<div class="opengraph-site">{data.siteName}</div> |
|
||||||
{/if} |
|
||||||
{#if data.title} |
|
||||||
<div class="opengraph-title">{data.title}</div> |
|
||||||
{/if} |
|
||||||
{#if data.description} |
|
||||||
<div class="opengraph-description">{data.description}</div> |
|
||||||
{/if} |
|
||||||
</div> |
|
||||||
</a> |
|
||||||
|
|
||||||
<style> |
|
||||||
.opengraph-card { |
|
||||||
display: flex; |
|
||||||
border: 1px solid var(--fog-border, #e5e7eb); |
|
||||||
border-radius: 0.5rem; |
|
||||||
overflow: hidden; |
|
||||||
margin: 1rem 0; |
|
||||||
text-decoration: none; |
|
||||||
color: inherit; |
|
||||||
transition: all 0.2s; |
|
||||||
background: var(--fog-post, #ffffff); |
|
||||||
} |
|
||||||
|
|
||||||
:global(.dark) .opengraph-card { |
|
||||||
border-color: var(--fog-dark-border, #374151); |
|
||||||
background: var(--fog-dark-post, #1f2937); |
|
||||||
} |
|
||||||
|
|
||||||
.opengraph-card:hover { |
|
||||||
border-color: var(--fog-accent, #64748b); |
|
||||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); |
|
||||||
} |
|
||||||
|
|
||||||
:global(.dark) .opengraph-card:hover { |
|
||||||
border-color: var(--fog-dark-accent, #94a3b8); |
|
||||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3); |
|
||||||
} |
|
||||||
|
|
||||||
.opengraph-image { |
|
||||||
flex-shrink: 0; |
|
||||||
width: 200px; |
|
||||||
height: 150px; |
|
||||||
overflow: hidden; |
|
||||||
background: var(--fog-border, #e5e7eb); |
|
||||||
} |
|
||||||
|
|
||||||
:global(.dark) .opengraph-image { |
|
||||||
background: var(--fog-dark-border, #374151); |
|
||||||
} |
|
||||||
|
|
||||||
.opengraph-image img { |
|
||||||
width: 100%; |
|
||||||
height: 100%; |
|
||||||
object-fit: cover; |
|
||||||
} |
|
||||||
|
|
||||||
.opengraph-content { |
|
||||||
flex: 1; |
|
||||||
padding: 1rem; |
|
||||||
display: flex; |
|
||||||
flex-direction: column; |
|
||||||
gap: 0.5rem; |
|
||||||
} |
|
||||||
|
|
||||||
.opengraph-site { |
|
||||||
font-size: 0.75rem; |
|
||||||
color: var(--fog-text-light, #6b7280); |
|
||||||
text-transform: uppercase; |
|
||||||
letter-spacing: 0.05em; |
|
||||||
} |
|
||||||
|
|
||||||
:global(.dark) .opengraph-site { |
|
||||||
color: var(--fog-dark-text-light, #9ca3af); |
|
||||||
} |
|
||||||
|
|
||||||
.opengraph-title { |
|
||||||
font-size: 1rem; |
|
||||||
font-weight: 600; |
|
||||||
color: var(--fog-text, #1f2937); |
|
||||||
line-height: 1.4; |
|
||||||
} |
|
||||||
|
|
||||||
:global(.dark) .opengraph-title { |
|
||||||
color: var(--fog-dark-text, #f9fafb); |
|
||||||
} |
|
||||||
|
|
||||||
.opengraph-description { |
|
||||||
font-size: 0.875rem; |
|
||||||
color: var(--fog-text-light, #6b7280); |
|
||||||
line-height: 1.5; |
|
||||||
display: -webkit-box; |
|
||||||
-webkit-line-clamp: 2; |
|
||||||
-webkit-box-orient: vertical; |
|
||||||
overflow: hidden; |
|
||||||
} |
|
||||||
|
|
||||||
:global(.dark) .opengraph-description { |
|
||||||
color: var(--fog-dark-text-light, #9ca3af); |
|
||||||
} |
|
||||||
|
|
||||||
@media (max-width: 640px) { |
|
||||||
.opengraph-card { |
|
||||||
flex-direction: column; |
|
||||||
} |
|
||||||
|
|
||||||
.opengraph-image { |
|
||||||
width: 100%; |
|
||||||
height: 200px; |
|
||||||
} |
|
||||||
} |
|
||||||
</style> |
|
||||||
@ -0,0 +1,93 @@ |
|||||||
|
/** |
||||||
|
* Draft storage in IndexedDB |
||||||
|
* Replaces localStorage for better scalability and structure |
||||||
|
*/ |
||||||
|
|
||||||
|
import { getDB } from './indexeddb-store.js'; |
||||||
|
|
||||||
|
export interface DraftData { |
||||||
|
id: string; // e.g., 'write', 'comment_<eventId>'
|
||||||
|
content: string; |
||||||
|
tags?: string[][]; |
||||||
|
selectedKind?: number; |
||||||
|
updatedAt: number; |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Save a draft |
||||||
|
*/ |
||||||
|
export async function saveDraft( |
||||||
|
id: string, |
||||||
|
data: Omit<DraftData, 'id' | 'updatedAt'> |
||||||
|
): Promise<void> { |
||||||
|
try { |
||||||
|
const db = await getDB(); |
||||||
|
const draft: DraftData = { |
||||||
|
id, |
||||||
|
...data, |
||||||
|
updatedAt: Date.now() |
||||||
|
}; |
||||||
|
await db.put('drafts', draft); |
||||||
|
} catch (error) { |
||||||
|
console.warn('Error saving draft to IndexedDB:', error); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Get a draft by ID |
||||||
|
*/ |
||||||
|
export async function getDraft(id: string): Promise<DraftData | null> { |
||||||
|
try { |
||||||
|
const db = await getDB(); |
||||||
|
const draft = await db.get('drafts', id); |
||||||
|
return (draft as DraftData) || null; |
||||||
|
} catch (error) { |
||||||
|
console.warn('Error reading draft from IndexedDB:', error); |
||||||
|
return null; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Delete a draft |
||||||
|
*/ |
||||||
|
export async function deleteDraft(id: string): Promise<void> { |
||||||
|
try { |
||||||
|
const db = await getDB(); |
||||||
|
await db.delete('drafts', id); |
||||||
|
} catch (error) { |
||||||
|
console.warn('Error deleting draft from IndexedDB:', error); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* List all drafts |
||||||
|
*/ |
||||||
|
export async function listDrafts(): Promise<DraftData[]> { |
||||||
|
try { |
||||||
|
const db = await getDB(); |
||||||
|
const drafts: DraftData[] = []; |
||||||
|
const tx = db.transaction('drafts', 'readonly'); |
||||||
|
|
||||||
|
for await (const cursor of tx.store.iterate()) { |
||||||
|
drafts.push(cursor.value as DraftData); |
||||||
|
} |
||||||
|
|
||||||
|
await tx.done; |
||||||
|
return drafts; |
||||||
|
} catch (error) { |
||||||
|
console.warn('Error listing drafts from IndexedDB:', error); |
||||||
|
return []; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Clear all drafts |
||||||
|
*/ |
||||||
|
export async function clearAllDrafts(): Promise<void> { |
||||||
|
try { |
||||||
|
const db = await getDB(); |
||||||
|
await db.clear('drafts'); |
||||||
|
} catch (error) { |
||||||
|
console.warn('Error clearing all drafts:', error); |
||||||
|
} |
||||||
|
} |
||||||
@ -1,217 +0,0 @@ |
|||||||
/** |
|
||||||
* OpenGraph metadata fetcher service |
|
||||||
* Fetches OpenGraph metadata from URLs and caches results |
|
||||||
*/ |
|
||||||
|
|
||||||
export interface OpenGraphData { |
|
||||||
title?: string; |
|
||||||
description?: string; |
|
||||||
image?: string; |
|
||||||
url?: string; |
|
||||||
siteName?: string; |
|
||||||
type?: string; |
|
||||||
cachedAt: number; |
|
||||||
} |
|
||||||
|
|
||||||
const CACHE_DURATION = 7 * 24 * 60 * 60 * 1000; // 7 days
|
|
||||||
const CACHE_KEY_PREFIX = 'opengraph_'; |
|
||||||
|
|
||||||
/** |
|
||||||
* Fetch OpenGraph metadata from a URL |
|
||||||
* Uses a CORS proxy if needed, caches results in localStorage |
|
||||||
*/ |
|
||||||
export async function fetchOpenGraph(url: string): Promise<OpenGraphData | null> { |
|
||||||
// Check cache first
|
|
||||||
const cached = getCachedOpenGraph(url); |
|
||||||
if (cached && Date.now() - cached.cachedAt < CACHE_DURATION) { |
|
||||||
return cached; |
|
||||||
} |
|
||||||
|
|
||||||
try { |
|
||||||
// Try to fetch the page HTML
|
|
||||||
// Note: Direct fetch may fail due to CORS, so we'll use a simple approach
|
|
||||||
// In production, you might want to use a backend proxy or service
|
|
||||||
const response = await fetch(url, { |
|
||||||
method: 'GET', |
|
||||||
headers: { |
|
||||||
'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8', |
|
||||||
'User-Agent': 'Mozilla/5.0 (compatible; aitherboard/1.0)' |
|
||||||
}, |
|
||||||
mode: 'cors', |
|
||||||
cache: 'no-cache' |
|
||||||
}); |
|
||||||
|
|
||||||
if (!response.ok) { |
|
||||||
throw new Error(`HTTP ${response.status}`); |
|
||||||
} |
|
||||||
|
|
||||||
const html = await response.text(); |
|
||||||
const ogData = parseOpenGraph(html, url); |
|
||||||
|
|
||||||
// Cache the result
|
|
||||||
if (ogData) { |
|
||||||
cacheOpenGraph(url, ogData); |
|
||||||
} |
|
||||||
|
|
||||||
return ogData; |
|
||||||
} catch (error) { |
|
||||||
console.warn('Failed to fetch OpenGraph data:', error); |
|
||||||
// Return cached data even if expired, or null
|
|
||||||
return cached || null; |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
/** |
|
||||||
* Parse OpenGraph metadata from HTML |
|
||||||
*/ |
|
||||||
function parseOpenGraph(html: string, url: string): OpenGraphData | null { |
|
||||||
const og: Partial<OpenGraphData> = { |
|
||||||
cachedAt: Date.now() |
|
||||||
}; |
|
||||||
|
|
||||||
// Extract OpenGraph meta tags
|
|
||||||
const ogTitleMatch = html.match(/<meta\s+property=["']og:title["']\s+content=["']([^"']+)["']/i) || |
|
||||||
html.match(/<meta\s+content=["']([^"']+)["']\s+property=["']og:title["']/i); |
|
||||||
if (ogTitleMatch) { |
|
||||||
og.title = decodeHtmlEntities(ogTitleMatch[1]); |
|
||||||
} |
|
||||||
|
|
||||||
const ogDescriptionMatch = html.match(/<meta\s+property=["']og:description["']\s+content=["']([^"']+)["']/i) || |
|
||||||
html.match(/<meta\s+content=["']([^"']+)["']\s+property=["']og:description["']/i); |
|
||||||
if (ogDescriptionMatch) { |
|
||||||
og.description = decodeHtmlEntities(ogDescriptionMatch[1]); |
|
||||||
} |
|
||||||
|
|
||||||
const ogImageMatch = html.match(/<meta\s+property=["']og:image["']\s+content=["']([^"']+)["']/i) || |
|
||||||
html.match(/<meta\s+content=["']([^"']+)["']\s+property=["']og:image["']/i); |
|
||||||
if (ogImageMatch) { |
|
||||||
og.image = ogImageMatch[1]; |
|
||||||
// Make image URL absolute if relative
|
|
||||||
if (og.image && !og.image.startsWith('http')) { |
|
||||||
try { |
|
||||||
const baseUrl = new URL(url); |
|
||||||
og.image = new URL(og.image, baseUrl).href; |
|
||||||
} catch { |
|
||||||
// Invalid URL, keep as is
|
|
||||||
} |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
const ogUrlMatch = html.match(/<meta\s+property=["']og:url["']\s+content=["']([^"']+)["']/i) || |
|
||||||
html.match(/<meta\s+content=["']([^"']+)["']\s+property=["']og:url["']/i); |
|
||||||
if (ogUrlMatch) { |
|
||||||
og.url = ogUrlMatch[1]; |
|
||||||
} else { |
|
||||||
og.url = url; |
|
||||||
} |
|
||||||
|
|
||||||
const ogSiteNameMatch = html.match(/<meta\s+property=["']og:site_name["']\s+content=["']([^"']+)["']/i) || |
|
||||||
html.match(/<meta\s+content=["']([^"']+)["']\s+property=["']og:site_name["']/i); |
|
||||||
if (ogSiteNameMatch) { |
|
||||||
og.siteName = decodeHtmlEntities(ogSiteNameMatch[1]); |
|
||||||
} |
|
||||||
|
|
||||||
const ogTypeMatch = html.match(/<meta\s+property=["']og:type["']\s+content=["']([^"']+)["']/i) || |
|
||||||
html.match(/<meta\s+content=["']([^"']+)["']\s+property=["']og:type["']/i); |
|
||||||
if (ogTypeMatch) { |
|
||||||
og.type = ogTypeMatch[1]; |
|
||||||
} |
|
||||||
|
|
||||||
// Fallback to regular meta tags if OpenGraph not available
|
|
||||||
if (!og.title) { |
|
||||||
const titleMatch = html.match(/<title[^>]*>([^<]+)<\/title>/i); |
|
||||||
if (titleMatch) { |
|
||||||
og.title = decodeHtmlEntities(titleMatch[1].trim()); |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
if (!og.description) { |
|
||||||
const metaDescriptionMatch = html.match(/<meta\s+name=["']description["']\s+content=["']([^"']+)["']/i) || |
|
||||||
html.match(/<meta\s+content=["']([^"']+)["']\s+name=["']description["']/i); |
|
||||||
if (metaDescriptionMatch) { |
|
||||||
og.description = decodeHtmlEntities(metaDescriptionMatch[1]); |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
// Return null if we have no useful data
|
|
||||||
if (!og.title && !og.description && !og.image) { |
|
||||||
return null; |
|
||||||
} |
|
||||||
|
|
||||||
return og as OpenGraphData; |
|
||||||
} |
|
||||||
|
|
||||||
/** |
|
||||||
* Decode HTML entities |
|
||||||
*/ |
|
||||||
function decodeHtmlEntities(text: string): string { |
|
||||||
const textarea = document.createElement('textarea'); |
|
||||||
textarea.innerHTML = text; |
|
||||||
return textarea.value; |
|
||||||
} |
|
||||||
|
|
||||||
/** |
|
||||||
* Get cached OpenGraph data |
|
||||||
*/ |
|
||||||
function getCachedOpenGraph(url: string): OpenGraphData | null { |
|
||||||
if (typeof window === 'undefined') return null; |
|
||||||
|
|
||||||
try { |
|
||||||
const key = CACHE_KEY_PREFIX + url; |
|
||||||
const cached = localStorage.getItem(key); |
|
||||||
if (cached) { |
|
||||||
return JSON.parse(cached) as OpenGraphData; |
|
||||||
} |
|
||||||
} catch (error) { |
|
||||||
console.warn('Error reading cached OpenGraph data:', error); |
|
||||||
} |
|
||||||
|
|
||||||
return null; |
|
||||||
} |
|
||||||
|
|
||||||
/** |
|
||||||
* Cache OpenGraph data |
|
||||||
*/ |
|
||||||
function cacheOpenGraph(url: string, data: OpenGraphData): void { |
|
||||||
if (typeof window === 'undefined') return; |
|
||||||
|
|
||||||
const key = CACHE_KEY_PREFIX + url; |
|
||||||
try { |
|
||||||
localStorage.setItem(key, JSON.stringify(data)); |
|
||||||
} catch (error) { |
|
||||||
console.warn('Error caching OpenGraph data:', error); |
|
||||||
// If storage is full, try to clear old entries
|
|
||||||
try { |
|
||||||
clearOldCacheEntries(); |
|
||||||
localStorage.setItem(key, JSON.stringify(data)); |
|
||||||
} catch { |
|
||||||
// Give up
|
|
||||||
} |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
/** |
|
||||||
* Clear old cache entries to free up space |
|
||||||
*/ |
|
||||||
function clearOldCacheEntries(): void { |
|
||||||
if (typeof window === 'undefined') return; |
|
||||||
|
|
||||||
const now = Date.now(); |
|
||||||
const keysToRemove: string[] = []; |
|
||||||
|
|
||||||
for (let i = 0; i < localStorage.length; i++) { |
|
||||||
const key = localStorage.key(i); |
|
||||||
if (key && key.startsWith(CACHE_KEY_PREFIX)) { |
|
||||||
try { |
|
||||||
const data = JSON.parse(localStorage.getItem(key) || '{}') as OpenGraphData; |
|
||||||
if (now - data.cachedAt > CACHE_DURATION) { |
|
||||||
keysToRemove.push(key); |
|
||||||
} |
|
||||||
} catch { |
|
||||||
keysToRemove.push(key); |
|
||||||
} |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
keysToRemove.forEach(key => localStorage.removeItem(key)); |
|
||||||
} |
|
||||||
Loading…
Reference in new issue