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 is a new bookmarksColumns
field on GlobalSettings (same idiom as the other scalar admin knobs)
and rides along in the public GET /api/bookmarks response so the
sidebar picks it up without a separate request.
This commit is contained in:
Claude
2026-08-02 00:00:40 +00:00
parent 615b86fe27
commit ac154322f8
11 changed files with 87 additions and 14 deletions
+2 -2
View File
@@ -91,8 +91,8 @@ export async function registerPublicRoutes(app: FastifyInstance) {
app.get('/api/bookmarks', async (req) => {
const bookmarks = bookmarksDb.listBookmarks();
if (hasPrivateAccess(req)) return bookmarks;
return bookmarks.filter((b) => !b.isPrivate);
const items = hasPrivateAccess(req) ? bookmarks : bookmarks.filter((b) => !b.isPrivate);
return { items, columns: settingsDb.getSettings().bookmarksColumns };
});
app.get('/api/poe2', async () => {
+4
View File
@@ -195,6 +195,7 @@ export function migrate() {
widget_weather_enabled INTEGER NOT NULL DEFAULT 1,
widget_stocks_enabled INTEGER NOT NULL DEFAULT 1,
widget_bookmarks_enabled INTEGER NOT NULL DEFAULT 1,
bookmarks_columns INTEGER NOT NULL DEFAULT 1, -- 1 | 2 | 3 — sidebar/admin bookmark list layout width
widget_poe2_enabled INTEGER NOT NULL DEFAULT 1,
widget_order TEXT NOT NULL DEFAULT '["weather","stocks","poe2","bookmarks"]', -- JSON array, admin-sortable via the Widgets tab
weather_location_name TEXT,
@@ -418,6 +419,9 @@ export function migrate() {
if (!hasColumn('global_settings', 'synthesis_num_predict')) {
db.exec('ALTER TABLE global_settings ADD COLUMN synthesis_num_predict INTEGER NOT NULL DEFAULT 700');
}
if (!hasColumn('global_settings', 'bookmarks_columns')) {
db.exec('ALTER TABLE global_settings ADD COLUMN bookmarks_columns INTEGER NOT NULL DEFAULT 1');
}
// Seed default categories if none exist yet. "News" sits right under "Top stories" —
// general news sources belong here, not on "Top stories" itself, which isn't a real
+3 -1
View File
@@ -27,6 +27,7 @@ function rowToSettings(row: any): GlobalSettings {
poe2: !!row.widget_poe2_enabled
},
widgetOrder: JSON.parse(row.widget_order),
bookmarksColumns: row.bookmarks_columns,
retention: {
publishedArticleMaxAgeDays: row.published_article_max_age_days,
rawItemMaxAgeDays: row.raw_item_max_age_days,
@@ -88,7 +89,7 @@ export function updateSettings(patch: Partial<GlobalSettings>): GlobalSettings {
synthesis_num_ctx=$synthesis_num_ctx, synthesis_num_predict=$synthesis_num_predict,
widget_weather_enabled=$widget_weather_enabled, widget_stocks_enabled=$widget_stocks_enabled,
widget_bookmarks_enabled=$widget_bookmarks_enabled, widget_poe2_enabled=$widget_poe2_enabled,
widget_order=$widget_order,
widget_order=$widget_order, bookmarks_columns=$bookmarks_columns,
published_article_max_age_days=$published_article_max_age_days, raw_item_max_age_days=$raw_item_max_age_days,
storage_cap_enabled=$storage_cap_enabled, storage_cap_value=$storage_cap_value, storage_cap_unit=$storage_cap_unit,
weather_location_name=$weather_location_name, weather_latitude=$weather_latitude, weather_longitude=$weather_longitude,
@@ -120,6 +121,7 @@ export function updateSettings(patch: Partial<GlobalSettings>): GlobalSettings {
$widget_bookmarks_enabled: merged.widgets.bookmarks ? 1 : 0,
$widget_poe2_enabled: merged.widgets.poe2 ? 1 : 0,
$widget_order: JSON.stringify(merged.widgetOrder),
$bookmarks_columns: merged.bookmarksColumns,
$published_article_max_age_days: merged.retention.publishedArticleMaxAgeDays,
$raw_item_max_age_days: merged.retention.rawItemMaxAgeDays,
$storage_cap_enabled: merged.retention.storageCapEnabled ? 1 : 0,
+2
View File
@@ -306,6 +306,8 @@ export interface GlobalSettings {
};
/** Sidebar widget display order, admin-sortable via the Widgets tab's up/down arrows — mirrored exactly by Sidebar.svelte. */
widgetOrder: ('weather' | 'stocks' | 'bookmarks' | 'poe2')[];
/** How many columns the bookmark list lays out in, both in the sidebar and the admin panel. */
bookmarksColumns: 1 | 2 | 3;
retention: {
publishedArticleMaxAgeDays: number | null;
rawItemMaxAgeDays: number | null;
+2
View File
@@ -148,6 +148,8 @@ export interface AdminSettings {
synthesisNumPredict: number;
widgets: AdminWidgetsEnabled;
widgetOrder: ('weather' | 'stocks' | 'bookmarks' | 'poe2')[];
/** How many columns the bookmark list lays out in, both in the sidebar and the admin panel. */
bookmarksColumns: 1 | 2 | 3;
retention: RetentionSettings;
categoryPriority: CategoryPriority[];
weather: AdminWeatherSettings;
+3 -3
View File
@@ -1,5 +1,5 @@
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> {
// 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/stocks', fetchFn);
}
export function getBookmarks(fetchFn?: typeof fetch): Promise<Bookmark[]> {
return get<Bookmark[]>('/api/bookmarks', fetchFn);
export function getBookmarks(fetchFn?: typeof fetch): Promise<BookmarksFeed> {
return get<BookmarksFeed>('/api/bookmarks', fetchFn);
}
export function getPoe2(fetchFn?: typeof fetch): Promise<Poe2Data> {
@@ -1,12 +1,20 @@
<script lang="ts">
import type { AdminBookmark } from '$lib/adminTypes';
import { addBookmark, updateBookmark, deleteBookmark } from '$lib/adminApi';
import { addBookmark, updateBookmark, deleteBookmark, updateSettings } from '$lib/adminApi';
let { bookmarks: initial }: { bookmarks: AdminBookmark[] } = $props();
let { bookmarks: initial, columns: initialColumns }: { bookmarks: AdminBookmark[]; columns: 1 | 2 | 3 } = $props();
let bookmarks = $state([...initial]);
let columns = $state(initialColumns);
let showAdd = $state(false);
let newBookmark = $state({ name: '', url: '', isPrivate: false });
async function setColumns(n: 1 | 2 | 3) {
columns = n;
await updateSettings({ bookmarksColumns: n });
}
const columnOptions: (1 | 2 | 3)[] = [1, 2, 3];
let editingId = $state<string | null>(null);
let editForm = $state({ name: '', url: '' });
@@ -47,6 +55,14 @@
<div class="toolbar">
<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>
</div>
@@ -102,12 +118,40 @@
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
flex-wrap: wrap;
margin-bottom: 12px;
}
.count {
font-size: 12px;
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 {
font-size: 12px;
padding: 6px 12px;
@@ -61,7 +61,7 @@
{:else if key === 'stocks'}
<StocksTab tickers={stockTickers} />
{:else if key === 'bookmarks'}
<BookmarksTab {bookmarks} />
<BookmarksTab {bookmarks} columns={settings.bookmarksColumns} />
{:else if key === 'poe2'}
<Poe2Tab {settings} watchlist={poe2Watchlist} />
{/if}
@@ -1,13 +1,13 @@
<script lang="ts">
import type { Bookmark } from '$lib/types';
let { bookmarks }: { bookmarks: Bookmark[] } = $props();
let { bookmarks, columns = 1 }: { bookmarks: Bookmark[]; columns?: 1 | 2 | 3 } = $props();
</script>
<div class="widget">
<span class="title">Bookmarks</span>
{#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)}
<a class="row" href={bookmark.url} target="_blank" rel="noopener noreferrer">{bookmark.name}</a>
{/each}
@@ -33,6 +33,10 @@
flex-direction: column;
margin-top: 8px;
}
.list.grid {
display: grid;
gap: 6px;
}
.row {
font-size: 13px;
padding: 6px 0;
@@ -42,6 +46,15 @@
.row:first-child {
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 {
color: var(--text-accent);
}
@@ -1,6 +1,6 @@
<script lang="ts">
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 StocksWidget from './StocksWidget.svelte';
import BookmarksWidget from './BookmarksWidget.svelte';
@@ -15,7 +15,7 @@
}: {
weather: Weather;
stocks: StockTicker[];
bookmarks: Bookmark[];
bookmarks: BookmarksFeed;
poe2: Poe2Data;
widgetsEnabled: WidgetsEnabled;
} = $props();
@@ -98,7 +98,7 @@
{:else if key === 'poe2' && widgetsEnabled.poe2}
<Poe2Widget {poe2} />
{:else if key === 'bookmarks' && widgetsEnabled.bookmarks}
<BookmarksWidget {bookmarks} />
<BookmarksWidget bookmarks={bookmarks.items} columns={bookmarks.columns} />
{/if}
{/each}
</div>
+6
View File
@@ -159,6 +159,12 @@ export interface Bookmark {
isPrivate: boolean;
}
/** Response from GET /api/bookmarks — items plus the admin-configured sidebar layout width. */
export interface BookmarksFeed {
items: Bookmark[];
columns: 1 | 2 | 3;
}
export interface Poe2WatchlistEntry {
id: string;
baseName: string;