Add 1/2/3-column layout option to the Bookmarks widget

The bookmarks list can now render as 1 (default), 2, or 3 columns
instead of always being a single-column list, admin-configurable via
a pill picker on the Widgets tab. Column count lives in widget_kv
(same idiom as weather's config) and rides along in the public
GET /api/widget/bookmarks response so the sidebar picks it up without
a separate request.
This commit is contained in:
Claude
2026-08-01 23:55:18 +00:00
parent ef59b2e91a
commit 55622d0a1a
12 changed files with 147 additions and 27 deletions
+12
View File
@@ -1,5 +1,6 @@
import { randomUUID } from 'node:crypto'; import { randomUUID } from 'node:crypto';
import { db } from '../../storage/db/index.js'; import { db } from '../../storage/db/index.js';
import { getKv, setKv } from '../../storage/db/widgetKv.js';
import type { Bookmark } from '../../storage/db/types.js'; import type { Bookmark } from '../../storage/db/types.js';
function rowToBookmark(row: any): Bookmark { function rowToBookmark(row: any): Bookmark {
@@ -44,3 +45,14 @@ export function updateBookmark(id: string, patch: { name?: string; url?: string;
export function deleteBookmark(id: string) { export function deleteBookmark(id: string) {
db.prepare('DELETE FROM widget_bookmarks_items WHERE id = ?').run(id); db.prepare('DELETE FROM widget_bookmarks_items WHERE id = ?').run(id);
} }
// How many columns the sidebar/admin panel lays the bookmark list out in — stored in
// widget_kv rather than a bespoke table since it's a single scalar, same idiom as weather's
// config (see widgets/weather/db.ts).
export function getColumns(): 1 | 2 | 3 {
return getKv<{ columns: 1 | 2 | 3 }>('bookmarks', 'config')?.columns ?? 1;
}
export function setColumns(columns: 1 | 2 | 3) {
setKv('bookmarks', 'config', { columns });
}
+13 -2
View File
@@ -31,14 +31,25 @@ export const bookmarksPlugin: WidgetPlugin = {
registerPublicRoutes(app) { registerPublicRoutes(app) {
app.get('/api/widget/bookmarks', async (req) => { app.get('/api/widget/bookmarks', async (req) => {
const bookmarks = bookmarksDb.listBookmarks(); const bookmarks = bookmarksDb.listBookmarks();
if (hasPrivateAccess(req)) return bookmarks; const items = hasPrivateAccess(req) ? bookmarks : bookmarks.filter((b) => !b.isPrivate);
return bookmarks.filter((b) => !b.isPrivate); return { items, columns: bookmarksDb.getColumns() };
}); });
}, },
registerAdminRoutes(app) { registerAdminRoutes(app) {
app.get('/api/admin/widget/bookmarks', async () => bookmarksDb.listBookmarks()); app.get('/api/admin/widget/bookmarks', async () => bookmarksDb.listBookmarks());
app.get('/api/admin/widget/bookmarks/config', async () => ({ columns: bookmarksDb.getColumns() }));
app.patch('/api/admin/widget/bookmarks/config', async (req, reply) => {
const { columns } = req.body as { columns?: number };
if (columns !== 1 && columns !== 2 && columns !== 3) {
return reply.code(400).send({ error: 'columns must be 1, 2, or 3' });
}
bookmarksDb.setColumns(columns);
return { columns };
});
app.post('/api/admin/widget/bookmarks', async (req, reply) => { app.post('/api/admin/widget/bookmarks', async (req, reply) => {
const { name, url, isPrivate } = req.body as { name?: string; url?: string; isPrivate?: boolean }; const { name, url, isPrivate } = req.body as { name?: string; url?: string; isPrivate?: boolean };
if (!name || !name.trim() || !url || !url.trim()) { if (!name || !name.trim() || !url || !url.trim()) {
+11
View File
@@ -12,6 +12,7 @@ import type {
GeocodeResult, GeocodeResult,
AdminStockTicker, AdminStockTicker,
AdminBookmark, AdminBookmark,
AdminBookmarksConfig,
Poe2BrowseEntry, Poe2BrowseEntry,
AdminPoe2Entry, AdminPoe2Entry,
AdminWeatherSettings, AdminWeatherSettings,
@@ -239,6 +240,16 @@ export const updateBookmark = (id: string, patch: { name?: string; url?: string;
export const deleteBookmark = (id: string, fetchFn?: typeof fetch) => export const deleteBookmark = (id: string, fetchFn?: typeof fetch) =>
request<void>(`/api/admin/widget/bookmarks/${id}`, { method: 'DELETE' }, fetchFn); request<void>(`/api/admin/widget/bookmarks/${id}`, { method: 'DELETE' }, fetchFn);
export const getBookmarksConfig = (fetchFn?: typeof fetch) =>
request<AdminBookmarksConfig>('/api/admin/widget/bookmarks/config', {}, fetchFn);
export const updateBookmarksConfig = (columns: 1 | 2 | 3, fetchFn?: typeof fetch) =>
request<AdminBookmarksConfig>(
'/api/admin/widget/bookmarks/config',
{ method: 'PATCH', body: JSON.stringify({ columns }) },
fetchFn
);
// PoE2 — league is always auto-detected, never admin-set (see widgets/poe2/poll.ts). // PoE2 — league is always auto-detected, never admin-set (see widgets/poe2/poll.ts).
export const browsePoe2Currencies = (fetchFn?: typeof fetch) => export const browsePoe2Currencies = (fetchFn?: typeof fetch) =>
request<Poe2BrowseEntry[]>('/api/admin/widget/poe2/browse', {}, fetchFn); request<Poe2BrowseEntry[]>('/api/admin/widget/poe2/browse', {}, fetchFn);
+5
View File
@@ -95,6 +95,11 @@ export interface AdminBookmark {
isPrivate: boolean; isPrivate: boolean;
} }
/** Response from GET/PATCH /api/admin/widget/bookmarks/config — how many columns the sidebar/widget layout uses. */
export interface AdminBookmarksConfig {
columns: 1 | 2 | 3;
}
export interface Poe2BrowseEntry { export interface Poe2BrowseEntry {
id: string; id: string;
name: string; name: string;
+3 -3
View File
@@ -1,5 +1,5 @@
import { getBackendUrl } from './config'; import { getBackendUrl } from './config';
import type { MergedArticle, Tag, TrackedEventPublic, Category, Weather, StockTicker, Bookmark, Poe2Data, WidgetsEnabled } from './types'; import type { MergedArticle, Tag, TrackedEventPublic, Category, Weather, StockTicker, BookmarksFeed, Poe2Data, WidgetsEnabled } from './types';
async function get<T>(path: string, fetchFn: typeof fetch = fetch): Promise<T> { async function get<T>(path: string, fetchFn: typeof fetch = fetch): Promise<T> {
// credentials: 'include' so the private-access cookie (see lib/privateAccess.ts) // credentials: 'include' so the private-access cookie (see lib/privateAccess.ts)
@@ -54,8 +54,8 @@ export function getStocks(fetchFn?: typeof fetch): Promise<StockTicker[]> {
return get<StockTicker[]>('/api/widget/stocks', fetchFn); return get<StockTicker[]>('/api/widget/stocks', fetchFn);
} }
export function getBookmarks(fetchFn?: typeof fetch): Promise<Bookmark[]> { export function getBookmarks(fetchFn?: typeof fetch): Promise<BookmarksFeed> {
return get<Bookmark[]>('/api/widget/bookmarks', fetchFn); return get<BookmarksFeed>('/api/widget/bookmarks', fetchFn);
} }
export function getPoe2(fetchFn?: typeof fetch): Promise<Poe2Data> { export function getPoe2(fetchFn?: typeof fetch): Promise<Poe2Data> {
@@ -1,12 +1,20 @@
<script lang="ts"> <script lang="ts">
import type { AdminBookmark } from '$lib/adminTypes'; import type { AdminBookmark, AdminBookmarksConfig } from '$lib/adminTypes';
import { addBookmark, updateBookmark, deleteBookmark } from '$lib/adminApi'; import { addBookmark, updateBookmark, deleteBookmark, updateBookmarksConfig } from '$lib/adminApi';
let { bookmarks: initial }: { bookmarks: AdminBookmark[] } = $props(); let { bookmarks: initial, config }: { bookmarks: AdminBookmark[]; config: AdminBookmarksConfig } = $props();
let bookmarks = $state([...initial]); let bookmarks = $state([...initial]);
let columns = $state(config.columns);
let showAdd = $state(false); let showAdd = $state(false);
let newBookmark = $state({ name: '', url: '', isPrivate: false }); let newBookmark = $state({ name: '', url: '', isPrivate: false });
async function setColumns(n: 1 | 2 | 3) {
columns = n;
await updateBookmarksConfig(n);
}
const columnOptions: (1 | 2 | 3)[] = [1, 2, 3];
let editingId = $state<string | null>(null); let editingId = $state<string | null>(null);
let editForm = $state({ name: '', url: '' }); let editForm = $state({ name: '', url: '' });
@@ -47,6 +55,14 @@
<div class="toolbar"> <div class="toolbar">
<span class="count">{bookmarks.length} bookmarks</span> <span class="count">{bookmarks.length} bookmarks</span>
<div class="columns-picker">
<span class="field-label">Columns</span>
<div class="pill-row">
{#each columnOptions as n}
<button class="pill" class:active={columns === n} onclick={() => setColumns(n)}>{n}</button>
{/each}
</div>
</div>
<button class="add-btn" onclick={() => (showAdd = !showAdd)}>+ New bookmark</button> <button class="add-btn" onclick={() => (showAdd = !showAdd)}>+ New bookmark</button>
</div> </div>
@@ -102,12 +118,40 @@
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
gap: 12px;
flex-wrap: wrap;
margin-bottom: 12px; margin-bottom: 12px;
} }
.count { .count {
font-size: 12px; font-size: 12px;
color: var(--text-muted); color: var(--text-muted);
} }
.columns-picker {
display: flex;
align-items: center;
gap: 8px;
}
.field-label {
font-size: 11px;
color: var(--text-muted);
}
.pill-row {
display: flex;
gap: 6px;
}
.pill {
font-size: 12px;
padding: 4px 10px;
border-radius: var(--radius);
border: 0.5px solid var(--border);
background: var(--surface-2);
color: var(--text-secondary);
}
.pill.active {
background: var(--pill-bg);
color: var(--pill-text);
border-color: var(--pill-bg);
}
.add-btn { .add-btn {
font-size: 12px; font-size: 12px;
padding: 6px 12px; padding: 6px 12px;
@@ -3,6 +3,7 @@
AdminSettings, AdminSettings,
AdminStockTicker, AdminStockTicker,
AdminBookmark, AdminBookmark,
AdminBookmarksConfig,
AdminPoe2Entry, AdminPoe2Entry,
AdminWeatherSettings, AdminWeatherSettings,
InstalledWidget, InstalledWidget,
@@ -20,6 +21,7 @@
settings, settings,
stockTickers, stockTickers,
bookmarks, bookmarks,
bookmarksConfig,
poe2Watchlist, poe2Watchlist,
weatherConfig, weatherConfig,
poe2, poe2,
@@ -28,6 +30,7 @@
settings: AdminSettings; settings: AdminSettings;
stockTickers: AdminStockTicker[]; stockTickers: AdminStockTicker[];
bookmarks: AdminBookmark[]; bookmarks: AdminBookmark[];
bookmarksConfig: AdminBookmarksConfig;
poe2Watchlist: AdminPoe2Entry[]; poe2Watchlist: AdminPoe2Entry[];
weatherConfig: AdminWeatherSettings; weatherConfig: AdminWeatherSettings;
poe2: Poe2Data; poe2: Poe2Data;
@@ -138,7 +141,7 @@
{:else if key === 'stocks'} {:else if key === 'stocks'}
<StocksTab tickers={stockTickers} /> <StocksTab tickers={stockTickers} />
{:else if key === 'bookmarks'} {:else if key === 'bookmarks'}
<BookmarksTab {bookmarks} /> <BookmarksTab {bookmarks} config={bookmarksConfig} />
{:else if key === 'poe2'} {:else if key === 'poe2'}
<Poe2Tab {poe2} watchlist={poe2Watchlist} /> <Poe2Tab {poe2} watchlist={poe2Watchlist} />
{/if} {/if}
@@ -1,13 +1,13 @@
<script lang="ts"> <script lang="ts">
import type { Bookmark } from '$lib/types'; import type { Bookmark } from '$lib/types';
let { bookmarks }: { bookmarks: Bookmark[] } = $props(); let { bookmarks, columns = 1 }: { bookmarks: Bookmark[]; columns?: 1 | 2 | 3 } = $props();
</script> </script>
<div class="widget"> <div class="widget">
<span class="title">Bookmarks</span> <span class="title">Bookmarks</span>
{#if bookmarks.length > 0} {#if bookmarks.length > 0}
<div class="list"> <div class="list" class:grid={columns > 1} style:grid-template-columns={columns > 1 ? `repeat(${columns}, 1fr)` : undefined}>
{#each bookmarks as bookmark (bookmark.id)} {#each bookmarks as bookmark (bookmark.id)}
<a class="row" href={bookmark.url} target="_blank" rel="noopener noreferrer">{bookmark.name}</a> <a class="row" href={bookmark.url} target="_blank" rel="noopener noreferrer">{bookmark.name}</a>
{/each} {/each}
@@ -33,6 +33,10 @@
flex-direction: column; flex-direction: column;
margin-top: 8px; margin-top: 8px;
} }
.list.grid {
display: grid;
gap: 6px;
}
.row { .row {
font-size: 13px; font-size: 13px;
padding: 6px 0; padding: 6px 0;
@@ -42,6 +46,15 @@
.row:first-child { .row:first-child {
border-top: none; border-top: none;
} }
.list.grid .row {
border-top: none;
padding: 6px 8px;
background: var(--surface-2);
border-radius: var(--radius);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.row:hover { .row:hover {
color: var(--text-accent); color: var(--text-accent);
} }
@@ -1,6 +1,6 @@
<script lang="ts"> <script lang="ts">
import { tick } from 'svelte'; import { tick } from 'svelte';
import type { Weather, StockTicker, Bookmark, Poe2Data, WidgetsEnabled } from '$lib/types'; import type { Weather, StockTicker, BookmarksFeed, Poe2Data, WidgetsEnabled } from '$lib/types';
import WeatherWidget from './WeatherWidget.svelte'; import WeatherWidget from './WeatherWidget.svelte';
import StocksWidget from './StocksWidget.svelte'; import StocksWidget from './StocksWidget.svelte';
import BookmarksWidget from './BookmarksWidget.svelte'; import BookmarksWidget from './BookmarksWidget.svelte';
@@ -17,7 +17,7 @@
}: { }: {
weather: Weather; weather: Weather;
stocks: StockTicker[]; stocks: StockTicker[];
bookmarks: Bookmark[]; bookmarks: BookmarksFeed;
poe2: Poe2Data; poe2: Poe2Data;
widgetsEnabled: WidgetsEnabled; widgetsEnabled: WidgetsEnabled;
} = $props(); } = $props();
@@ -100,7 +100,7 @@
{:else if key === 'poe2' && widgetsEnabled.poe2} {:else if key === 'poe2' && widgetsEnabled.poe2}
<Poe2Widget {poe2} /> <Poe2Widget {poe2} />
{:else if key === 'bookmarks' && widgetsEnabled.bookmarks} {:else if key === 'bookmarks' && widgetsEnabled.bookmarks}
<BookmarksWidget {bookmarks} /> <BookmarksWidget bookmarks={bookmarks.items} columns={bookmarks.columns} />
{/if} {/if}
{/each} {/each}
{#each widgetsEnabled.pluggable as w (w.id)} {#each widgetsEnabled.pluggable as w (w.id)}
+6
View File
@@ -159,6 +159,12 @@ export interface Bookmark {
isPrivate: boolean; isPrivate: boolean;
} }
/** Response from GET /api/widget/bookmarks — items plus the admin-configured sidebar layout width. */
export interface BookmarksFeed {
items: Bookmark[];
columns: 1 | 2 | 3;
}
export interface Poe2WatchlistEntry { export interface Poe2WatchlistEntry {
id: string; id: string;
baseName: string; baseName: string;
@@ -48,6 +48,7 @@
settings={data.settings} settings={data.settings}
stockTickers={data.stockTickers} stockTickers={data.stockTickers}
bookmarks={data.bookmarks} bookmarks={data.bookmarks}
bookmarksConfig={data.bookmarksConfig}
poe2Watchlist={data.poe2Watchlist} poe2Watchlist={data.poe2Watchlist}
weatherConfig={data.weatherConfig} weatherConfig={data.weatherConfig}
poe2={data.poe2} poe2={data.poe2}
+27 -13
View File
@@ -10,6 +10,7 @@ import {
getLogs, getLogs,
getStockTickers, getStockTickers,
getAdminBookmarks, getAdminBookmarks,
getBookmarksConfig,
getPoe2Watchlist, getPoe2Watchlist,
getWeatherConfig, getWeatherConfig,
listWidgets listWidgets
@@ -21,19 +22,31 @@ const EMPTY_MODELS: ModelCatalog = { embedding: [], image: [], synthesis: [] };
export const load: PageLoad = async ({ fetch }) => { export const load: PageLoad = async ({ fetch }) => {
try { try {
const [settings, sources, events, logs, stockTickers, bookmarks, poe2Watchlist, weatherConfig, poe2, installedWidgets] = const [
await Promise.all([ settings,
getSettings(fetch), sources,
getSources(fetch), events,
getEvents(fetch), logs,
getLogs({}, fetch), stockTickers,
getStockTickers(fetch), bookmarks,
getAdminBookmarks(fetch), bookmarksConfig,
getPoe2Watchlist(fetch), poe2Watchlist,
getWeatherConfig(fetch), weatherConfig,
getPoe2(fetch), poe2,
listWidgets(fetch) installedWidgets
]); ] = await Promise.all([
getSettings(fetch),
getSources(fetch),
getEvents(fetch),
getLogs({}, fetch),
getStockTickers(fetch),
getAdminBookmarks(fetch),
getBookmarksConfig(fetch),
getPoe2Watchlist(fetch),
getWeatherConfig(fetch),
getPoe2(fetch),
listWidgets(fetch)
]);
// The AI service (Ollama) may not be running yet — that shouldn't take down the // The AI service (Ollama) may not be running yet — that shouldn't take down the
// whole settings page, just leave the Models/Connections tabs showing "unreachable". // whole settings page, just leave the Models/Connections tabs showing "unreachable".
@@ -59,6 +72,7 @@ export const load: PageLoad = async ({ fetch }) => {
logs, logs,
stockTickers, stockTickers,
bookmarks, bookmarks,
bookmarksConfig,
poe2Watchlist, poe2Watchlist,
weatherConfig, weatherConfig,
poe2, poe2,