Skip to content

Web App

The BitMarks web app provides a full-featured interface for managing your bookmarks from any browser.

Production https://app.bitmarks.sh

Development http://localhost:4321


ViewDescription
TableSpreadsheet-like interface with sortable, resizable columns
CardsVisual grid of bookmark cards with favicons
FindermacOS Finder-style hierarchical folder browser
ReaderDistraction-free reading with content preview
  • 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
  • Folders: Hierarchical organization
  • Tags: Flexible tagging system
  • Smart folders: Auto-organized by domain, date, etc.
  • Drag and drop: Easy reorganization

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.json

  1. Terminal window
    git clone https://github.com/bitmarks-sh/bitmarks.git
    cd bitmarks
  2. Terminal window
    bun install
  3. Terminal window
    cd packages/app
    bun run dev
  4. Navigate to http://localhost:4321


The web app uses a typed API client:

lib/api-client.ts
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();

contexts/AppContext.tsx
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>
);
}
hooks/useAppState.ts
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,
// ...
};
}

For handling large bookmark collections (10k+), the app uses a custom VirtualList:

components/VirtualList.tsx
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>
);
}

The app uses a custom fuzzy matching algorithm:

lib/fuzzy.ts
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 };
}

The web app is deployed alongside the API as a unified Cloudflare Worker:

Terminal window
# Build the app
cd packages/app
bun run build
# Deploy (from root)
cd ../..
wrangler deploy

The Worker serves:

  • /api/* → Hono API handlers
  • /* → Static assets from packages/app/dist

The app supports light and dark themes:

// Theme configuration
const 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',
},
};
ShortcutAction
⌘/Ctrl + KFocus search
⌘/Ctrl + NNew bookmark
⌘/Ctrl + 1-4Switch view
↑/↓Navigate items
EnterOpen selected
DeleteDelete selected
EscapeClear selection