Frontend¶
The UI is one server-rendered dashboard with interactive pieces layered on as client components. Filter state
lives in the URL; changes go through the API and end in router.refresh().
Dashboard composition¶
src/app/(main)/page.tsx renders, top to bottom:
UrlInput: the saving bar.- A
<details>element labeled "Filters", open whenever any filter parameter is set, containingSearchInput,CollectionTabs(withCollectionMenubeside it when a real collection is selected) andFilterTabs. - Inside
RefreshProvider:- the bookmark count and the toolbar:
DeleteAllButton(given the displayed IDs),ImportButton,ExportButton(given the displayed URLs),ShareButtonandRefreshButton; BookmarkListWrapperaroundBookmarkList, which renders aBookmarkCardper bookmark.
- the bookmark count and the toolbar:
Component map¶
File (under src/components/) |
Kind | Role |
|---|---|---|
auth/login-buttons.tsx |
Client | GitHub and Google buttons; hands sign-in to the system browser in Electron; shows sign-in errors |
bookmarks/url-input.tsx |
Client | Save form with collection and priority menus; page-wide drag and drop |
bookmarks/bookmark-list.tsx |
Server | Empty state, or the list of cards |
bookmarks/bookmark-list-wrapper.tsx |
Client | Fades the list while a refresh is pending |
bookmarks/bookmark-card.tsx |
Client | A card with read toggle, ⋮ menu (priority, move) and delete |
bookmarks/bookmark-card-readonly.tsx |
Client | The share page's card |
bookmarks/priority-badge.tsx |
No directive (rendered inside client cards) | Badge for non-normal priorities |
bookmarks/repo-stats.tsx |
No directive (inside client cards) | Stars, forks and language |
bookmarks/filter-tabs.tsx |
Client | Filter, Priority and Sort pill rows |
bookmarks/search-input.tsx |
Client | Debounced search box |
bookmarks/import-button.tsx |
Client | Opens the bulk import dialog |
bookmarks/bulk-import.tsx |
Client | The bulk import dialog |
bookmarks/export-button.tsx |
Client | Downloads bookmarks.txt |
bookmarks/delete-all-button.tsx |
Client | Deletes the displayed bookmarks |
bookmarks/refresh-button.tsx |
Client | The refresh icon |
bookmarks/refresh-context.tsx |
Client | RefreshProvider and useRefresh |
collections/collection-tabs.tsx |
Client | Collection pills and + New |
collections/collection-menu.tsx |
Client | Rename and delete for the selected collection |
layout/header.tsx |
Server | Header with version, user, links and buttons |
layout/sign-out-button.tsx |
Client | Local-scope sign-out |
layout/theme-toggle.tsx |
Client | Dark/light switch |
share-button.tsx |
Client | The share dialog |
URL state¶
The URL is the single source of filter state. FilterTabs and CollectionTabs copy useSearchParams(), set or
delete one key, and call router.push("/?" + params). SearchInput waits 300 ms after the last keystroke before
pushing, and re-syncs its text from the URL when the user goes back or forward. Each push re-renders the server
page, which reads searchParams and queries Supabase with those filters (the parameters are listed in the User
Guide's URL parameters).
Mutations and refresh¶
Components call an /api/* route with fetch and, on success, router.refresh(), which re-runs the server
components and swaps in fresh data without losing client state. Card actions report failures with alert();
UrlInput shows the API's error message above the field.
- Delete runs
fadeCollapse(src/lib/utils/fade-collapse.ts: slide right and fade out over 300 ms, then collapse height, margins and padding over 250 ms) at the same time as theDELETErequest. - Delete displayed (
DeleteAllButton) deletes one ID at a time, one request each. - Refresh:
RefreshProvider(refresh-context.tsx) wrapsrouter.refresh()inuseTransitionand keepsisPendingtrue for at least 600 ms.RefreshButtonspins while it's pending, andBookmarkListWrapperfades the list toopacity-0.
Drag and drop¶
UrlInput attaches dragenter, dragover, dragleave and drop listeners to document, so a link can be
dropped anywhere on the page. It counts enter and leave events to show the overlay only while something is
over the page. On drop it takes the first line of text/uri-list that starts with http, else text/plain if
that starts with http:// or https://, and saves it.
Theme¶
- An inline script in the
<head>ofsrc/app/layout.tsxadds thedarkclass to<html>unlesslocalStorage.themeis"light"(and also when reading storage fails), before the first paint. Dark is therefore the default. <html>hassuppressHydrationWarning, because that script changes itsclassbefore React hydrates.ThemeTogglereads the class after mounting and renders nothing until then, so server and client markup agree. Clicking it toggles the class and stores"dark"or"light"inlocalStorage.src/app/globals.cssdeclares Tailwind v4's class-based variant:@custom-variant dark (&:where(.dark, .dark *));.
Priority utilities¶
src/lib/utils/priority.ts is shared by the cards, the filters, the saving bar and the API's validation:
| Export | Holds |
|---|---|
PRIORITY_LEVELS |
["urgent", "high", "normal", "low"], in order; also defines the Priority type |
PRIORITY_ORDER |
0 to 3, mirrored by the database's priority_order column |
PRIORITY_LABELS |
"Urgent", "High", "Normal", "Low" |
PRIORITY_BORDER |
Left-border classes; normal is empty, so the card falls back to the blue unread border |
PRIORITY_BADGE |
Badge color classes |
Small utilities¶
src/lib/utils/ui.ts:pillClass(active)(the pill buttons' classes),getDisplayUrl(url)(the hostname shown on cards) andresolveImageUrl(src, pageUrl)(see Metadata Fetching).src/lib/utils/time.ts:timeAgo(date), givingjust now, then minutes, hours, days, months (of 30 days) and years:5m ago,3h ago,2d ago,4mo ago,1y ago.formatCountinrepo-stats.tsxshortens counts to1.2kand1.2m.
Conventions¶
- The path alias
@/*maps tosrc/*. - The header and the sign-in page show the version by importing
versionfrompackage.json. - Avatars use
referrerPolicy="no-referrer": Google's avatar host refuses requests that carry a referrer. - Thumbnails are plain
<img>elements (notnext/image, since they come from any domain) that hide themselvesonError. - Outbound links use
target="_blank" rel="noopener noreferrer". - Geist and Geist Mono are loaded with
next/font/googleand assigned to Tailwind's--font-sansand--font-mono, butsrc/app/globals.csssetsbody { font-family: Arial, Helvetica, sans-serif; }and no component usesfont-sansorfont-mono, so body text renders in Arial rather than Geist.