Many Improvements
This commit is contained in:
@@ -49,6 +49,17 @@ export const getSettings = (fetchFn?: typeof fetch) =>
|
||||
export const updateSettings = (patch: Partial<AdminSettings>, fetchFn?: typeof fetch) =>
|
||||
request<AdminSettings>('/api/admin/settings', { method: 'PATCH', body: JSON.stringify(patch) }, fetchFn);
|
||||
|
||||
// Categories
|
||||
export const createCategory = (name: string, fetchFn?: typeof fetch) =>
|
||||
request<{ id: string; name: string; priorityRank: number; isDefault: boolean }>(
|
||||
'/api/admin/categories',
|
||||
{ method: 'POST', body: JSON.stringify({ name }) },
|
||||
fetchFn
|
||||
);
|
||||
|
||||
export const deleteCategory = (id: string, fetchFn?: typeof fetch) =>
|
||||
request<void>(`/api/admin/categories/${id}`, { method: 'DELETE' }, fetchFn);
|
||||
|
||||
// Sources
|
||||
export const getSources = (fetchFn?: typeof fetch) =>
|
||||
request<AdminSource[]>('/api/admin/sources', {}, fetchFn);
|
||||
|
||||
+18
-6
@@ -1,5 +1,5 @@
|
||||
import { getBackendUrl } from './config';
|
||||
import type { MergedArticle, Tag, TrackedEventPublic } from './types';
|
||||
import type { MergedArticle, Tag, TrackedEventPublic, Category } from './types';
|
||||
|
||||
async function get<T>(path: string, fetchFn: typeof fetch = fetch): Promise<T> {
|
||||
const res = await fetchFn(`${getBackendUrl()}${path}`);
|
||||
@@ -7,11 +7,19 @@ async function get<T>(path: string, fetchFn: typeof fetch = fetch): Promise<T> {
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export function getFeed(
|
||||
params: { category?: string; geo?: string; eventId?: string; tag?: string } = {},
|
||||
fetchFn?: typeof fetch
|
||||
): Promise<MergedArticle[]> {
|
||||
const qs = new URLSearchParams(params as Record<string, string>).toString();
|
||||
export interface FeedParams {
|
||||
category?: string;
|
||||
geo?: string;
|
||||
eventId?: string;
|
||||
tag?: string;
|
||||
before?: string;
|
||||
limit?: number;
|
||||
}
|
||||
|
||||
export function getFeed(params: FeedParams = {}, fetchFn?: typeof fetch): Promise<MergedArticle[]> {
|
||||
const qs = new URLSearchParams(
|
||||
Object.fromEntries(Object.entries(params).filter(([, v]) => v !== undefined).map(([k, v]) => [k, String(v)]))
|
||||
).toString();
|
||||
return get<MergedArticle[]>(`/api/feed${qs ? `?${qs}` : ''}`, fetchFn);
|
||||
}
|
||||
|
||||
@@ -26,3 +34,7 @@ export function getTags(fetchFn?: typeof fetch): Promise<Tag[]> {
|
||||
export function getEvents(fetchFn?: typeof fetch): Promise<TrackedEventPublic[]> {
|
||||
return get<TrackedEventPublic[]>('/api/events', fetchFn);
|
||||
}
|
||||
|
||||
export function getCategories(fetchFn?: typeof fetch): Promise<Category[]> {
|
||||
return get<Category[]>('/api/categories', fetchFn);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
<script lang="ts">
|
||||
import type { MergedArticle } from '$lib/types';
|
||||
import { timeAgo, exactTime, excerpt } from '$lib/format';
|
||||
import { resolveMediaUrl } from '$lib/config';
|
||||
|
||||
let { article }: { article: MergedArticle } = $props();
|
||||
|
||||
const sourceLabel = $derived(
|
||||
article.sourceCount > 1
|
||||
? `⇄ ${article.sourceCount} sources`
|
||||
: (article.sources[0]?.sourceName ?? 'Single source')
|
||||
);
|
||||
</script>
|
||||
|
||||
<a class="row" href={`/article/${article.id}`}>
|
||||
{#if article.heroImage}
|
||||
<img class="thumb" src={resolveMediaUrl(article.heroImage.url)} alt="" loading="lazy" />
|
||||
{:else}
|
||||
<div class="thumb placeholder"></div>
|
||||
{/if}
|
||||
|
||||
<div class="content">
|
||||
<div class="meta">
|
||||
<span>{article.category[0] ?? ''}</span>
|
||||
<span>·</span>
|
||||
<span>{sourceLabel}</span>
|
||||
{#if article.video}
|
||||
<span>·</span>
|
||||
<span>▶ Video</span>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="title">{article.title}</div>
|
||||
<div class="excerpt">{excerpt(article.body)}</div>
|
||||
<div class="time">{timeAgo(article.publishedAt)} · {exactTime(article.publishedAt)}</div>
|
||||
</div>
|
||||
</a>
|
||||
|
||||
<style>
|
||||
.row {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
padding: 16px 0;
|
||||
border-bottom: 0.5px solid var(--border);
|
||||
color: inherit;
|
||||
}
|
||||
.row:hover {
|
||||
text-decoration: none;
|
||||
}
|
||||
.row:hover .title {
|
||||
text-decoration: underline;
|
||||
}
|
||||
.thumb {
|
||||
width: 88px;
|
||||
height: 88px;
|
||||
flex-shrink: 0;
|
||||
object-fit: cover;
|
||||
border-radius: var(--radius);
|
||||
background: var(--surface-1);
|
||||
}
|
||||
.thumb.placeholder {
|
||||
display: block;
|
||||
}
|
||||
.content {
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
.meta {
|
||||
font-size: 11px;
|
||||
color: var(--text-accent);
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.title {
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
line-height: 1.35;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.excerpt {
|
||||
font-size: 13px;
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.5;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.time {
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,90 @@
|
||||
<script lang="ts">
|
||||
import type { MergedArticle } from '$lib/types';
|
||||
import { getFeed, type FeedParams } from '$lib/api';
|
||||
import ArticleListRow from './ArticleListRow.svelte';
|
||||
|
||||
let { initial, filters, pageSize = 15 }: { initial: MergedArticle[]; filters: FeedParams; pageSize?: number } = $props();
|
||||
|
||||
let articles = $state<MergedArticle[]>(initial);
|
||||
let loading = $state(false);
|
||||
let done = $state(initial.length < pageSize);
|
||||
let sentinel = $state<HTMLDivElement>();
|
||||
|
||||
// Re-syncs when the page's load data changes on a *subsequent* navigation —
|
||||
// necessary because SvelteKit reuses this component instance across client-side
|
||||
// navigations between category pages (only the route param changes), so a
|
||||
// one-time state init would leave stale articles on screen after navigating e.g.
|
||||
// Tech -> World. Guarded to skip the first run: articles is already correctly
|
||||
// initialized from `initial` above, and re-running this unconditionally on mount
|
||||
// created a render race where SSR output briefly reflected an empty array instead.
|
||||
let firstEffectRun = true;
|
||||
$effect(() => {
|
||||
initial;
|
||||
pageSize;
|
||||
if (firstEffectRun) {
|
||||
firstEffectRun = false;
|
||||
return;
|
||||
}
|
||||
articles = [...initial];
|
||||
done = initial.length < pageSize;
|
||||
});
|
||||
|
||||
async function loadMore() {
|
||||
if (loading || done) return;
|
||||
loading = true;
|
||||
try {
|
||||
const last = articles[articles.length - 1];
|
||||
const next = await getFeed({ ...filters, before: last?.publishedAt, limit: pageSize });
|
||||
if (next.length < pageSize) done = true;
|
||||
if (next.length === 0) return;
|
||||
articles = [...articles, ...next];
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
if (!sentinel) return;
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
if (entries[0].isIntersecting) loadMore();
|
||||
},
|
||||
{ rootMargin: '400px' }
|
||||
);
|
||||
observer.observe(sentinel);
|
||||
return () => observer.disconnect();
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="list">
|
||||
{#each articles as article (article.id)}
|
||||
<ArticleListRow {article} />
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
{#if !done}
|
||||
<div class="sentinel" bind:this={sentinel}></div>
|
||||
{/if}
|
||||
|
||||
{#if loading}
|
||||
<div class="status">Loading more…</div>
|
||||
{:else if done && articles.length > 0}
|
||||
<div class="status">You're caught up.</div>
|
||||
{:else if articles.length === 0}
|
||||
<div class="status">No stories here yet.</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.list {
|
||||
max-width: 720px;
|
||||
}
|
||||
.sentinel {
|
||||
height: 1px;
|
||||
}
|
||||
.status {
|
||||
text-align: center;
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
padding: 20px 0;
|
||||
}
|
||||
</style>
|
||||
@@ -1,6 +1,6 @@
|
||||
<script lang="ts">
|
||||
import type { AdminSettings } from '$lib/adminTypes';
|
||||
import { updateSettings } from '$lib/adminApi';
|
||||
import { updateSettings, createCategory, deleteCategory } from '$lib/adminApi';
|
||||
import SaveStatus from './SaveStatus.svelte';
|
||||
|
||||
let { settings }: { settings: AdminSettings } = $props();
|
||||
@@ -8,6 +8,8 @@
|
||||
let local = $state({ ...settings, categoryPriority: [...settings.categoryPriority] });
|
||||
let status = $state<'idle' | 'saving' | 'saved' | 'error'>('idle');
|
||||
let saveTimer: ReturnType<typeof setTimeout>;
|
||||
let newCategoryName = $state('');
|
||||
let addingCategory = $state(false);
|
||||
|
||||
function scheduleSave() {
|
||||
status = 'saving';
|
||||
@@ -32,6 +34,30 @@
|
||||
}, 500);
|
||||
}
|
||||
|
||||
async function addCategory() {
|
||||
const name = newCategoryName.trim();
|
||||
if (!name) return;
|
||||
addingCategory = true;
|
||||
try {
|
||||
const created = await createCategory(name);
|
||||
local.categoryPriority = [...local.categoryPriority, created];
|
||||
newCategoryName = '';
|
||||
} finally {
|
||||
addingCategory = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function removeCategory(id: string, isDefault: boolean, name: string) {
|
||||
if (isDefault) {
|
||||
// Sensible-default categories can still be removed — e.g. a fresh install's
|
||||
// Business/Culture defaults aren't everyone's interest — just a lighter check.
|
||||
const confirmed = confirm(`Remove "${name}"? Sources already tagged with it will keep the label but stop appearing under a nav tab.`);
|
||||
if (!confirmed) return;
|
||||
}
|
||||
await deleteCategory(id);
|
||||
local.categoryPriority = local.categoryPriority.filter((c) => c.id !== id);
|
||||
}
|
||||
|
||||
function move(index: number, dir: -1 | 1) {
|
||||
const target = index + dir;
|
||||
if (target < 0 || target >= local.categoryPriority.length) return;
|
||||
@@ -117,7 +143,8 @@
|
||||
<span class="panel-title">Category priority</span>
|
||||
<p class="hint">
|
||||
Synthesis queue processes higher-ranked categories first. Nothing is dropped — lower
|
||||
categories just wait longer when the queue is busy.
|
||||
categories just wait longer when the queue is busy. This list also drives the site's nav —
|
||||
remove anything you're not interested in (Business, Culture, etc.) or add your own.
|
||||
</p>
|
||||
<div class="priority-list">
|
||||
{#each local.categoryPriority as cat, i (cat.id)}
|
||||
@@ -131,9 +158,25 @@
|
||||
disabled={i === local.categoryPriority.length - 1}
|
||||
aria-label="Move down">▼</button
|
||||
>
|
||||
{#if cat.name.toLowerCase() !== 'top stories'}
|
||||
<button class="icon-btn danger" onclick={() => removeCategory(cat.id, cat.isDefault, cat.name)} aria-label={`Remove ${cat.name}`}
|
||||
>✕</button
|
||||
>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
<div class="add-row">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="New category name"
|
||||
bind:value={newCategoryName}
|
||||
onkeydown={(e) => e.key === 'Enter' && addCategory()}
|
||||
/>
|
||||
<button onclick={addCategory} disabled={addingCategory || !newCategoryName.trim()}>
|
||||
{addingCategory ? 'Adding…' : '+ Add'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="panel">
|
||||
@@ -256,4 +299,15 @@
|
||||
opacity: 0.4;
|
||||
cursor: default;
|
||||
}
|
||||
.icon-btn.danger:hover {
|
||||
color: var(--text-danger);
|
||||
}
|
||||
.add-row {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-top: 10px;
|
||||
}
|
||||
.add-row input {
|
||||
flex: 1;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -20,3 +20,15 @@ export function setBackendUrl(url: string) {
|
||||
localStorage.setItem(STORAGE_KEY, url);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Media served by the backend (see storage/media in the backend) comes back as a
|
||||
* relative path like "/media/abc.jpg" — deliberately, since the backend doesn't need
|
||||
* to know its own externally-reachable URL. The frontend does know it (this is exactly
|
||||
* what getBackendUrl() is for), so relative media paths get resolved against it here.
|
||||
* Anything already absolute (e.g. a hotlinked fallback URL) passes through unchanged.
|
||||
*/
|
||||
export function resolveMediaUrl(url: string): string {
|
||||
if (/^https?:\/\//i.test(url)) return url;
|
||||
return `${getBackendUrl()}${url}`;
|
||||
}
|
||||
|
||||
@@ -7,3 +7,31 @@ export function timeAgo(iso: string): string {
|
||||
const days = Math.round(hours / 24);
|
||||
return `${days}d ago`;
|
||||
}
|
||||
|
||||
/** "05/31/2025 - 05:34 PM" — the original feed publish time (or synthesis time for merged articles), alongside the relative timeAgo(). */
|
||||
export function exactTime(iso: string): string {
|
||||
const d = new Date(iso);
|
||||
const mm = String(d.getMonth() + 1).padStart(2, '0');
|
||||
const dd = String(d.getDate()).padStart(2, '0');
|
||||
const yyyy = d.getFullYear();
|
||||
let hours = d.getHours();
|
||||
const minutes = String(d.getMinutes()).padStart(2, '0');
|
||||
const ampm = hours >= 12 ? 'PM' : 'AM';
|
||||
hours = hours % 12 || 12;
|
||||
return `${mm}/${dd}/${yyyy} - ${String(hours).padStart(2, '0')}:${minutes} ${ampm}`;
|
||||
}
|
||||
|
||||
export function slugify(name: string): string {
|
||||
return name
|
||||
.toLowerCase()
|
||||
.trim()
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/(^-|-$)/g, '');
|
||||
}
|
||||
|
||||
/** Roughly the first N sentences of a plain-text body — enough to give a sense of the article without reading it. */
|
||||
export function excerpt(body: string, sentenceCount = 2): string {
|
||||
const singleLine = body.replace(/\s+/g, ' ').trim();
|
||||
const sentences = singleLine.match(/[^.!?]+[.!?]+/g) ?? [singleLine];
|
||||
return sentences.slice(0, sentenceCount).join(' ').trim();
|
||||
}
|
||||
|
||||
@@ -42,3 +42,10 @@ export interface TrackedEventPublic {
|
||||
active: boolean;
|
||||
cadence: string;
|
||||
}
|
||||
|
||||
export interface Category {
|
||||
id: string;
|
||||
name: string;
|
||||
priorityRank: number;
|
||||
isDefault: boolean;
|
||||
}
|
||||
|
||||
@@ -2,17 +2,21 @@
|
||||
import '../lib/styles/app.css';
|
||||
import { page } from '$app/stores';
|
||||
import ThemeToggle from '$lib/components/ThemeToggle.svelte';
|
||||
import { slugify } from '$lib/format';
|
||||
import type { LayoutData } from './$types';
|
||||
|
||||
let { children } = $props();
|
||||
let { children, data }: { children: any; data: LayoutData } = $props();
|
||||
|
||||
const categories = [
|
||||
{ label: 'Top stories', href: '/' },
|
||||
{ label: 'Local', href: '/category/local' },
|
||||
{ label: 'World', href: '/category/world' },
|
||||
{ label: 'Business', href: '/category/business' },
|
||||
{ label: 'Tech', href: '/category/tech' },
|
||||
{ label: 'Culture', href: '/category/culture' }
|
||||
];
|
||||
// "Top stories" is a real Category row (it drives synthesis queue priority) but
|
||||
// isn't itself a filterable category — it always means "everything, chronological",
|
||||
// i.e. the homepage. Every other admin-defined category gets its own /category/:slug
|
||||
// page. See MergeTab's category priority list for where these are managed.
|
||||
const navItems = $derived(
|
||||
data.categories.map((cat) => ({
|
||||
label: cat.name,
|
||||
href: cat.name.toLowerCase() === 'top stories' ? '/' : `/category/${slugify(cat.name)}`
|
||||
}))
|
||||
);
|
||||
|
||||
function isActive(href: string): boolean {
|
||||
if (href === '/') return $page.url.pathname === '/';
|
||||
@@ -27,8 +31,8 @@
|
||||
<span class="brand-tag">self-hosted</span>
|
||||
</div>
|
||||
<nav class="tabs">
|
||||
{#each categories as cat}
|
||||
<a class="tab" class:active={isActive(cat.href)} href={cat.href}>{cat.label}</a>
|
||||
{#each navItems as item}
|
||||
<a class="tab" class:active={isActive(item.href)} href={item.href}>{item.label}</a>
|
||||
{/each}
|
||||
</nav>
|
||||
<div class="controls">
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
import type { LayoutLoad } from './$types';
|
||||
import { getCategories } from '$lib/api';
|
||||
|
||||
export const load: LayoutLoad = async ({ fetch }) => {
|
||||
const categories = await getCategories(fetch);
|
||||
return { categories };
|
||||
};
|
||||
@@ -1,124 +1,23 @@
|
||||
<script lang="ts">
|
||||
import type { PageData } from './$types';
|
||||
import ArticleCard from '$lib/components/ArticleCard.svelte';
|
||||
import { timeAgo } from '$lib/format';
|
||||
import InfiniteFeed from '$lib/components/InfiniteFeed.svelte';
|
||||
|
||||
let { data }: { data: PageData } = $props();
|
||||
</script>
|
||||
|
||||
{#if data.hero}
|
||||
<a class="hero" href={`/article/${data.hero.id}`}>
|
||||
<div class="hero-meta">
|
||||
{#if data.hero.sourceCount > 1}
|
||||
<span>⇄ Merged from {data.hero.sourceCount} sources</span>
|
||||
{/if}
|
||||
<span>{data.hero.category.join(', ')}</span>
|
||||
</div>
|
||||
{#if data.hero.heroImage}
|
||||
<img class="hero-img" src={data.hero.heroImage.url} alt="" />
|
||||
{/if}
|
||||
<div class="hero-title">{data.hero.title}</div>
|
||||
<div class="hero-sub">{timeAgo(data.hero.publishedAt)}</div>
|
||||
</a>
|
||||
{/if}
|
||||
<div class="head">
|
||||
<span class="title">Top stories</span>
|
||||
</div>
|
||||
|
||||
<section>
|
||||
<div class="section-head">
|
||||
<span class="section-title">Local · Philadelphia</span>
|
||||
<a href="/category/local">See all</a>
|
||||
</div>
|
||||
<div class="grid">
|
||||
{#each data.local as article}
|
||||
<ArticleCard {article} />
|
||||
{/each}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<div class="section-head">
|
||||
<span class="section-title">Business</span>
|
||||
<a href="/category/business">See all</a>
|
||||
</div>
|
||||
<div class="grid">
|
||||
{#each data.business as article}
|
||||
<ArticleCard {article} />
|
||||
{/each}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<div class="section-head">
|
||||
<span class="section-title">Tech</span>
|
||||
<a href="/category/tech">See all</a>
|
||||
</div>
|
||||
<div class="grid">
|
||||
{#each data.tech as article}
|
||||
<ArticleCard {article} />
|
||||
{/each}
|
||||
</div>
|
||||
</section>
|
||||
<InfiniteFeed initial={data.initial} filters={{}} pageSize={data.pageSize} />
|
||||
|
||||
<style>
|
||||
.hero {
|
||||
display: block;
|
||||
color: inherit;
|
||||
margin: 24px 0 36px;
|
||||
max-width: 720px;
|
||||
.head {
|
||||
margin: 24px 0 8px;
|
||||
}
|
||||
.hero:hover {
|
||||
text-decoration: none;
|
||||
}
|
||||
.hero:hover .hero-title {
|
||||
text-decoration: underline;
|
||||
}
|
||||
.hero-meta {
|
||||
font-size: 12px;
|
||||
color: var(--text-accent);
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.hero-img {
|
||||
width: 100%;
|
||||
aspect-ratio: 16 / 8;
|
||||
object-fit: cover;
|
||||
border-radius: 12px;
|
||||
margin-bottom: 12px;
|
||||
background: var(--surface-1);
|
||||
}
|
||||
.hero-title {
|
||||
.title {
|
||||
font-family: var(--font-voice);
|
||||
font-size: 30px;
|
||||
font-size: 26px;
|
||||
font-weight: 500;
|
||||
line-height: 1.25;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.hero-sub {
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
section {
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
.section-head {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.section-title {
|
||||
font-family: var(--font-voice);
|
||||
font-size: 19px;
|
||||
font-weight: 500;
|
||||
}
|
||||
.section-head a {
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
||||
gap: 20px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,19 +1,9 @@
|
||||
import type { PageLoad } from './$types';
|
||||
import { getFeed } from '$lib/api';
|
||||
|
||||
const PAGE_SIZE = 15;
|
||||
|
||||
export const load: PageLoad = async ({ fetch }) => {
|
||||
const [all, local] = await Promise.all([
|
||||
getFeed({}, fetch),
|
||||
getFeed({ geo: 'philadelphia' }, fetch)
|
||||
]);
|
||||
|
||||
const byCategory = (name: string) =>
|
||||
all.filter((a) => a.category.some((c) => c.toLowerCase() === name.toLowerCase()));
|
||||
|
||||
return {
|
||||
hero: all[0] ?? null,
|
||||
local,
|
||||
business: byCategory('business'),
|
||||
tech: byCategory('tech')
|
||||
};
|
||||
const initial = await getFeed({ limit: PAGE_SIZE }, fetch);
|
||||
return { initial, pageSize: PAGE_SIZE };
|
||||
};
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<script lang="ts">
|
||||
import type { PageData } from './$types';
|
||||
import { timeAgo } from '$lib/format';
|
||||
import { timeAgo, exactTime } from '$lib/format';
|
||||
import { resolveMediaUrl } from '$lib/config';
|
||||
|
||||
let { data }: { data: PageData } = $props();
|
||||
const a = $derived(data.article);
|
||||
@@ -24,14 +25,14 @@
|
||||
<h1>{a.title}</h1>
|
||||
|
||||
<div class="dates">
|
||||
<span>Published {timeAgo(a.publishedAt)}</span>
|
||||
<span>Published {timeAgo(a.publishedAt)} · {exactTime(a.publishedAt)}</span>
|
||||
{#if a.updatedAt !== a.publishedAt}
|
||||
<span>· Updated {timeAgo(a.updatedAt)}</span>
|
||||
<span>· Updated {timeAgo(a.updatedAt)} · {exactTime(a.updatedAt)}</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if a.heroImage}
|
||||
<img class="hero-img" src={a.heroImage.url} alt="" />
|
||||
<img class="hero-img" src={resolveMediaUrl(a.heroImage.url)} alt="" />
|
||||
<div class="img-caption">
|
||||
Image via <span class="accent">{a.sources[0]?.sourceName ?? 'source'}</span>
|
||||
</div>
|
||||
|
||||
@@ -1,31 +1,19 @@
|
||||
<script lang="ts">
|
||||
import type { PageData } from './$types';
|
||||
import ArticleCard from '$lib/components/ArticleCard.svelte';
|
||||
import InfiniteFeed from '$lib/components/InfiniteFeed.svelte';
|
||||
|
||||
let { data }: { data: PageData } = $props();
|
||||
</script>
|
||||
|
||||
<div class="head">
|
||||
<span class="title">{data.name}</span>
|
||||
<span class="count">{data.articles.length} stories</span>
|
||||
</div>
|
||||
|
||||
{#if data.articles.length === 0}
|
||||
<p class="empty">No stories in this category yet.</p>
|
||||
{:else}
|
||||
<div class="grid">
|
||||
{#each data.articles as article}
|
||||
<ArticleCard {article} />
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
<InfiniteFeed initial={data.initial} filters={data.filters} pageSize={data.pageSize} />
|
||||
|
||||
<style>
|
||||
.head {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
margin: 24px 0 20px;
|
||||
margin: 24px 0 8px;
|
||||
}
|
||||
.title {
|
||||
font-family: var(--font-voice);
|
||||
@@ -33,17 +21,4 @@
|
||||
font-weight: 500;
|
||||
text-transform: capitalize;
|
||||
}
|
||||
.count {
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.empty {
|
||||
color: var(--text-muted);
|
||||
font-size: 14px;
|
||||
}
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
||||
gap: 20px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import type { PageLoad } from './$types';
|
||||
import { getFeed } from '$lib/api';
|
||||
|
||||
const PAGE_SIZE = 15;
|
||||
|
||||
export const load: PageLoad = async ({ params, fetch }) => {
|
||||
const name = params.name;
|
||||
const isLocal = name.toLowerCase() === 'local';
|
||||
const articles = await getFeed(
|
||||
isLocal ? { geo: 'philadelphia' } : { category: name },
|
||||
fetch
|
||||
);
|
||||
return { articles, name };
|
||||
const filters = isLocal ? { geo: 'philadelphia' } : { category: name };
|
||||
const initial = await getFeed({ ...filters, limit: PAGE_SIZE }, fetch);
|
||||
return { initial, filters, name, pageSize: PAGE_SIZE };
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user