Web App
Web App
Section titled “Web App”The BitMarks web app provides a full-featured interface for managing your bookmarks from any browser.
Access
Section titled “Access”Production https://app.bitmarks.sh
Development http://localhost:4321
Features
Section titled “Features”| View | Description |
|---|---|
| Table | Spreadsheet-like interface with sortable, resizable columns |
| Cards | Visual grid of bookmark cards with favicons |
| Finder | macOS Finder-style hierarchical folder browser |
| Reader | Distraction-free reading with content preview |
Search
Section titled “Search”- Fuzzy search: Find bookmarks even with typos
- Field filters:
title:react,url:github,tag:work - Advanced operators: AND, OR, NOT
- Real-time results: Instant filtering as you type
Organization
Section titled “Organization”- Folders: Hierarchical organization
- Tags: Flexible tagging system
- Smart folders: Auto-organized by domain, date, etc.
- Drag and drop: Easy reorganization
Architecture
Section titled “Architecture”The web app is built with:
- Framework: Astro
- UI: React with TypeScript
- Styling: Inline styles with design tokens
- State: React Context + custom hooks
- Virtualization: Custom VirtualList for performance
packages/app/├── src/│ ├── components/│ │ ├── views/│ │ │ ├── CardsView.tsx│ │ │ ├── FinderView.tsx│ │ │ ├── ReaderView.tsx│ │ │ └── TableView.tsx│ │ ├── BookmarkManager.tsx│ │ ├── DataPane.tsx│ │ ├── Header.tsx│ │ ├── PreviewPane.tsx│ │ ├── Sidebar.tsx│ │ └── VirtualList.tsx│ ├── contexts/│ │ └── AppContext.tsx│ ├── hooks/│ │ └── useAppState.ts│ ├── layouts/│ │ └── Layout.astro│ ├── lib/│ │ ├── api-client.ts│ │ ├── fuzzy.ts│ │ └── schemas.ts│ └── pages/│ ├── app.astro│ ├── index.astro│ └── login.astro├── public/└── package.jsonRunning Locally
Section titled “Running Locally”-
Clone the Repository
Section titled “Clone the Repository”Terminal window git clone https://github.com/bitmarks-sh/bitmarks.gitcd bitmarks -
Install Dependencies
Section titled “Install Dependencies”Terminal window bun install -
Start Development Server
Section titled “Start Development Server”Terminal window cd packages/appbun run dev -
Open in Browser
Section titled “Open in Browser”Navigate to
http://localhost:4321
API Client
Section titled “API Client”The web app uses a typed API client:
export class APIClient { private baseUrl: string;
constructor(baseUrl = '/api/v1') { this.baseUrl = baseUrl; }
private async request<T>( endpoint: string, options?: RequestInit ): Promise<T> { const response = await fetch(`${this.baseUrl}${endpoint}`, { ...options, credentials: 'include', headers: { 'Content-Type': 'application/json', ...options?.headers, }, });
if (!response.ok) { const error = await response.json(); throw new APIError(error.error, response.status); }
return response.json(); }
// Auth async getSession() { return this.request<SessionResponse>('/auth/session'); }
async login(returnTo?: string) { return this.request<{ authorizationUrl: string }>('/auth/login', { method: 'POST', body: JSON.stringify({ returnTo }), }); }
async logout() { return this.request<{ success: boolean }>('/auth/logout', { method: 'POST', }); }
// Sync async getSyncStatus() { return this.request<SyncStatus>('/sync/status'); }
async pull(since = 0, limit = 1000) { return this.request<PullResponse>('/sync/pull', { method: 'POST', body: JSON.stringify({ since, limit }), }); }
async push(changes: SyncEvent[]) { return this.request<PushResponse>('/sync/push', { method: 'POST', body: JSON.stringify({ changes }), }); }}
export const api = new APIClient();State Management
Section titled “State Management”App Context
Section titled “App Context”interface AppState { user: User | null; bookmarks: Bookmark[]; selectedIds: Set<string>; viewMode: ViewMode; searchQuery: string; sortColumn: string; sortOrder: 'asc' | 'desc'; encryptionKey: Uint8Array | null;}
interface AppContextValue extends AppState { setUser: (user: User | null) => void; setBookmarks: (bookmarks: Bookmark[]) => void; toggleSelection: (id: string) => void; setViewMode: (mode: ViewMode) => void; setSearchQuery: (query: string) => void; // ...}
export const AppContext = createContext<AppContextValue | null>(null);
export function AppProvider({ children }: { children: ReactNode }) { const state = useAppState(); return ( <AppContext.Provider value={state}> {children} </AppContext.Provider> );}useAppState Hook
Section titled “useAppState Hook”export function useAppState(): AppContextValue { const [user, setUser] = useState<User | null>(null); const [bookmarks, setBookmarks] = useState<Bookmark[]>([]); const [viewMode, setViewMode] = useState<ViewMode>('table'); // ...
// Filtered and sorted bookmarks const filteredBookmarks = useMemo(() => { let result = bookmarks;
if (searchQuery) { result = result.filter(bm => fuzzyMatch(bm.title, searchQuery).match || fuzzyMatch(bm.url || '', searchQuery).match ); }
result.sort((a, b) => { const aVal = a[sortColumn] || ''; const bVal = b[sortColumn] || ''; const cmp = aVal.localeCompare(bVal); return sortOrder === 'asc' ? cmp : -cmp; });
return result; }, [bookmarks, searchQuery, sortColumn, sortOrder]);
return { user, setUser, bookmarks: filteredBookmarks, // ... };}Virtualization
Section titled “Virtualization”For handling large bookmark collections (10k+), the app uses a custom VirtualList:
interface VirtualListProps<T> { items: T[]; itemHeight: number; renderItem: (item: T, index: number) => ReactNode; overscan?: number;}
export function VirtualList<T>({ items, itemHeight, renderItem, overscan = 5}: VirtualListProps<T>) { const containerRef = useRef<HTMLDivElement>(null); const [scrollTop, setScrollTop] = useState(0);
const visibleCount = Math.ceil( (containerRef.current?.clientHeight || 0) / itemHeight );
const startIndex = Math.max(0, Math.floor(scrollTop / itemHeight) - overscan ); const endIndex = Math.min( items.length, startIndex + visibleCount + 2 * overscan );
const visibleItems = items.slice(startIndex, endIndex); const offsetY = startIndex * itemHeight;
return ( <div ref={containerRef} onScroll={(e) => setScrollTop(e.currentTarget.scrollTop)} style={{ overflow: 'auto', height: '100%' }} > <div style={{ height: items.length * itemHeight, position: 'relative' }}> <div style={{ transform: `translateY(${offsetY}px)` }}> {visibleItems.map((item, i) => renderItem(item, startIndex + i))} </div> </div> </div> );}Fuzzy Search
Section titled “Fuzzy Search”The app uses a custom fuzzy matching algorithm:
interface FuzzyResult { match: boolean; score: number; indices: number[];}
export function fuzzyMatch(text: string, pattern: string): FuzzyResult { const textLower = text.toLowerCase(); const patternLower = pattern.toLowerCase();
let ti = 0; let pi = 0; let score = 0; const indices: number[] = [];
while (ti < textLower.length && pi < patternLower.length) { if (textLower[ti] === patternLower[pi]) { indices.push(ti); score += ti === 0 || text[ti - 1] === ' ' ? 10 : 1; pi++; } ti++; }
const match = pi === patternLower.length;
if (match) { // Bonus for consecutive matches for (let i = 1; i < indices.length; i++) { if (indices[i] === indices[i - 1] + 1) { score += 5; } }
// Bonus for shorter text score += 100 / text.length; }
return { match, score, indices };}Deployment
Section titled “Deployment”The web app is deployed alongside the API as a unified Cloudflare Worker:
# Build the appcd packages/appbun run build
# Deploy (from root)cd ../..wrangler deployThe Worker serves:
/api/*→ Hono API handlers/*→ Static assets frompackages/app/dist
Customization
Section titled “Customization”The app supports light and dark themes:
// Theme configurationconst colors = { light: { background: '#ffffff', surface: '#f5f5f5', text: '#1a1a1a', textSecondary: '#666666', border: '#e0e0e0', primary: '#6366f1', }, dark: { background: '#0a0a0a', surface: '#1a1a1a', text: '#ffffff', textSecondary: '#999999', border: '#333333', primary: '#818cf8', },};Keyboard Shortcuts
Section titled “Keyboard Shortcuts”| Shortcut | Action |
|---|---|
⌘/Ctrl + K | Focus search |
⌘/Ctrl + N | New bookmark |
⌘/Ctrl + 1-4 | Switch view |
↑/↓ | Navigate items |
Enter | Open selected |
Delete | Delete selected |
Escape | Clear selection |