Alpine.js frontend that fetches from the readlater public API. Includes source filtering, search, sort toggle, and Post button for each saved item.
251 lines
11 KiB
Plaintext
251 lines
11 KiB
Plaintext
---
|
|
layout: layouts/base.njk
|
|
title: Reading List
|
|
permalink: /readlater/
|
|
---
|
|
<div class="readlater-page" x-data="readlaterApp()" x-init="init()">
|
|
<header class="mb-6 sm:mb-8">
|
|
<h1 class="text-2xl sm:text-3xl md:text-4xl font-bold text-surface-900 dark:text-surface-100 mb-2">
|
|
<svg class="w-8 h-8 inline-block mr-2 text-primary-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 5a2 2 0 012-2h10a2 2 0 012 2v16l-7-3.5L5 21V5z"/>
|
|
</svg>
|
|
Reading List
|
|
</h1>
|
|
<p class="text-surface-600 dark:text-surface-400">
|
|
Articles and links saved for later - <span x-text="items.length" class="font-medium"></span> items
|
|
</p>
|
|
</header>
|
|
|
|
<div class="layout-with-sidebar">
|
|
{# Main Content #}
|
|
<div class="main-content">
|
|
{# Loading State #}
|
|
<div x-show="loading" class="text-center py-12">
|
|
<svg class="w-8 h-8 mx-auto text-primary-600 animate-spin mb-4" fill="none" viewBox="0 0 24 24">
|
|
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
|
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
|
</svg>
|
|
<p class="text-surface-600 dark:text-surface-400">Loading...</p>
|
|
</div>
|
|
|
|
{# Error State #}
|
|
<div x-show="error" class="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg p-4 mb-6">
|
|
<p class="text-red-700 dark:text-red-400" x-text="error"></p>
|
|
<button @click="fetchData()" class="mt-2 text-sm text-red-600 hover:text-red-700 underline">Try again</button>
|
|
</div>
|
|
|
|
{# Filter by Source #}
|
|
<div x-show="!loading && items.length > 0" class="mb-6">
|
|
<div class="flex flex-wrap items-center gap-3">
|
|
<div class="relative">
|
|
<select
|
|
x-model="selectedSource"
|
|
@change="fetchData()"
|
|
class="appearance-none bg-white dark:bg-surface-800 border border-surface-300 dark:border-surface-600 rounded-lg pl-3 pr-8 py-2 text-sm focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
|
|
>
|
|
<option value="">All sources</option>
|
|
<template x-for="src in sources" :key="src">
|
|
<option :value="src" x-text="src"></option>
|
|
</template>
|
|
</select>
|
|
<svg class="absolute right-2 top-1/2 -translate-y-1/2 w-4 h-4 text-surface-400 pointer-events-none" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"/>
|
|
</svg>
|
|
</div>
|
|
<div class="relative flex-1 max-w-xs">
|
|
<input
|
|
type="search"
|
|
x-model.debounce.300ms="searchQuery"
|
|
@input="fetchData()"
|
|
placeholder="Search..."
|
|
class="w-full bg-white dark:bg-surface-800 border border-surface-300 dark:border-surface-600 rounded-lg pl-9 pr-3 py-2 text-sm focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
|
|
/>
|
|
<svg class="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-surface-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"/>
|
|
</svg>
|
|
</div>
|
|
<button
|
|
@click="toggleSort()"
|
|
class="inline-flex items-center gap-1 px-3 py-2 bg-white dark:bg-surface-800 border border-surface-300 dark:border-surface-600 rounded-lg text-sm hover:bg-surface-50 dark:hover:bg-surface-700 transition-colors"
|
|
:title="sortDir === 'desc' ? 'Newest first' : 'Oldest first'"
|
|
>
|
|
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 4h13M3 8h9m-9 4h6m4 0l4-4m0 0l4 4m-4-4v12"/>
|
|
</svg>
|
|
<span x-text="sortDir === 'desc' ? 'Newest' : 'Oldest'"></span>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{# Items List #}
|
|
<div x-show="!loading" class="space-y-4">
|
|
<template x-for="item in items" :key="item.id">
|
|
<article class="bg-white dark:bg-surface-800 rounded-xl border border-surface-200 dark:border-surface-700 p-4 sm:p-5 hover:border-primary-400 dark:hover:border-primary-600 transition-colors">
|
|
<div class="flex items-start gap-4">
|
|
<div class="flex-shrink-0 w-10 h-10 rounded-lg bg-gradient-to-br from-primary-400 to-primary-600 flex items-center justify-center">
|
|
<svg class="w-5 h-5 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 5a2 2 0 012-2h10a2 2 0 012 2v16l-7-3.5L5 21V5z"/>
|
|
</svg>
|
|
</div>
|
|
<div class="flex-1 min-w-0">
|
|
<h2 class="font-semibold text-surface-900 dark:text-surface-100 mb-1">
|
|
<a :href="item.url" class="hover:text-primary-600 dark:hover:text-primary-400 transition-colors" target="_blank" rel="noopener" x-text="item.title"></a>
|
|
</h2>
|
|
<div class="flex flex-wrap items-center gap-2 text-sm text-surface-500">
|
|
<span
|
|
class="inline-flex items-center px-2 py-0.5 bg-surface-100 dark:bg-surface-700 rounded-full text-xs"
|
|
x-text="item.source"
|
|
></span>
|
|
<time :datetime="item.savedAt" x-text="formatDate(item.savedAt)"></time>
|
|
</div>
|
|
<p class="text-xs text-surface-400 mt-1 truncate" x-text="item.url"></p>
|
|
</div>
|
|
</div>
|
|
|
|
{# Actions #}
|
|
<div class="flex flex-wrap items-center gap-3 mt-3 pt-3 border-t border-surface-200 dark:border-surface-700">
|
|
<a
|
|
:href="item.url"
|
|
class="inline-flex items-center gap-2 text-sm text-primary-600 hover:text-primary-700 dark:text-primary-400"
|
|
target="_blank"
|
|
rel="noopener"
|
|
>
|
|
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14"/>
|
|
</svg>
|
|
Read
|
|
</a>
|
|
<button
|
|
class="share-post-btn"
|
|
:data-share-url="item.url"
|
|
:data-share-title="item.title"
|
|
title="Create post"
|
|
aria-label="Create post"
|
|
>
|
|
<span class="share-post-icon">✏️</span>
|
|
<span class="share-post-label">Post</span>
|
|
</button>
|
|
</div>
|
|
</article>
|
|
</template>
|
|
</div>
|
|
|
|
{# Empty State #}
|
|
<div x-show="!loading && items.length === 0 && !error" class="text-center py-12">
|
|
<svg class="w-16 h-16 mx-auto text-surface-300 dark:text-surface-600 mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M5 5a2 2 0 012-2h10a2 2 0 012 2v16l-7-3.5L5 21V5z"/>
|
|
</svg>
|
|
<p class="text-surface-600 dark:text-surface-400 text-lg">No saved items yet.</p>
|
|
<p class="text-surface-500 text-sm mt-2">Save articles from around the web using the bookmark button.</p>
|
|
</div>
|
|
</div>
|
|
|
|
{# Sidebar #}
|
|
<aside class="sidebar">
|
|
{# Stats Widget #}
|
|
<div class="widget">
|
|
<h3 class="widget-title">Stats</h3>
|
|
<div class="grid grid-cols-2 gap-3 text-center">
|
|
<div class="p-3 bg-surface-50 dark:bg-surface-800 rounded-lg">
|
|
<span class="text-2xl font-bold text-primary-600 dark:text-primary-400 block" x-text="items.length"></span>
|
|
<span class="text-xs text-surface-500 uppercase">Saved</span>
|
|
</div>
|
|
<div class="p-3 bg-surface-50 dark:bg-surface-800 rounded-lg">
|
|
<span class="text-2xl font-bold text-primary-600 dark:text-primary-400 block" x-text="sources.length"></span>
|
|
<span class="text-xs text-surface-500 uppercase">Sources</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{# Sources Quick Filter #}
|
|
<div class="widget" x-show="sources.length > 0">
|
|
<h3 class="widget-title flex items-center gap-2">
|
|
<svg class="w-5 h-5 text-primary-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 4a1 1 0 011-1h16a1 1 0 011 1v2.586a1 1 0 01-.293.707l-6.414 6.414a1 1 0 00-.293.707V17l-4 4v-6.586a1 1 0 00-.293-.707L3.293 7.293A1 1 0 013 6.586V4z"/>
|
|
</svg>
|
|
Sources
|
|
</h3>
|
|
<ul class="space-y-1 mt-4">
|
|
<li>
|
|
<button
|
|
@click="selectedSource = ''; fetchData()"
|
|
:class="selectedSource === '' ? 'bg-primary-100 dark:bg-primary-900/30 text-primary-700 dark:text-primary-400' : 'hover:bg-surface-100 dark:hover:bg-surface-700'"
|
|
class="w-full text-left px-3 py-2 rounded-lg text-sm transition-colors"
|
|
>
|
|
All
|
|
</button>
|
|
</li>
|
|
<template x-for="src in sources" :key="src">
|
|
<li>
|
|
<button
|
|
@click="selectedSource = src; fetchData()"
|
|
:class="selectedSource === src ? 'bg-primary-100 dark:bg-primary-900/30 text-primary-700 dark:text-primary-400' : 'hover:bg-surface-100 dark:hover:bg-surface-700'"
|
|
class="w-full text-left px-3 py-2 rounded-lg text-sm transition-colors"
|
|
x-text="src"
|
|
></button>
|
|
</li>
|
|
</template>
|
|
</ul>
|
|
</div>
|
|
</aside>
|
|
</div>
|
|
</div>
|
|
|
|
<script>
|
|
function readlaterApp() {
|
|
return {
|
|
items: [],
|
|
sources: [],
|
|
loading: true,
|
|
error: null,
|
|
selectedSource: '',
|
|
searchQuery: '',
|
|
sortDir: 'desc',
|
|
|
|
async init() {
|
|
await this.fetchData();
|
|
},
|
|
|
|
async fetchData() {
|
|
this.loading = true;
|
|
this.error = null;
|
|
|
|
try {
|
|
const params = new URLSearchParams();
|
|
if (this.selectedSource) params.set('source', this.selectedSource);
|
|
if (this.searchQuery) params.set('q', this.searchQuery);
|
|
if (this.sortDir) params.set('sort', this.sortDir);
|
|
|
|
const qs = params.toString();
|
|
const [itemsRes, sourcesRes] = await Promise.all([
|
|
fetch(`/readlater/api/items${qs ? '?' + qs : ''}`).then(r => r.json()),
|
|
fetch('/readlater/api/sources').then(r => r.json())
|
|
]);
|
|
|
|
this.items = itemsRes.items || [];
|
|
this.sources = sourcesRes.items || [];
|
|
} catch (err) {
|
|
this.error = 'Failed to load reading list: ' + err.message;
|
|
console.error('ReadLater fetch error:', err);
|
|
} finally {
|
|
this.loading = false;
|
|
}
|
|
},
|
|
|
|
toggleSort() {
|
|
this.sortDir = this.sortDir === 'desc' ? 'asc' : 'desc';
|
|
this.fetchData();
|
|
},
|
|
|
|
formatDate(dateStr) {
|
|
if (!dateStr) return '';
|
|
const date = new Date(dateStr);
|
|
if (isNaN(date.getTime())) return '';
|
|
return date.toLocaleDateString(undefined, {
|
|
month: 'short', day: 'numeric', year: 'numeric'
|
|
});
|
|
}
|
|
};
|
|
}
|
|
</script>
|