8 changed files with 181 additions and 416 deletions
@ -1,131 +0,0 @@
@@ -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 @@
@@ -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 @@
@@ -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