Skip to content

Instantly share code, notes, and snippets.

@sibbng
Last active May 7, 2025 17:52
Show Gist options
  • Select an option

  • Save sibbng/0921d924e918104eb4f580dad75c826b to your computer and use it in GitHub Desktop.

Select an option

Save sibbng/0921d924e918104eb4f580dad75c826b to your computer and use it in GitHub Desktop.
voby bookmark app
{
"imports": {
"voby": "https://esm.sh/voby"
}
}
@theme {
--font-sans: 'Inter', sans-serif;
}
@custom-variant dark (&:where(.dark, .dark *));
@layer {
body {
@apply bg-neutral-400;
}
}
import { $, render, For, If, type Observable, useMemo, useEffect, untrack, type FunctionMaybe } from 'voby';
// --- UTILITIES ---
const generateId = () => Math.random().toString(36).substring(2, 10);
// --- TYPES ---
type Bookmark = {
id: string;
url: Observable<string>;
title: Observable<string>;
description: Observable<string>;
tags: Observable<string[]>;
isFavorite: Observable<boolean>;
folderId: Observable<string | null>;
createdAt: number;
};
type Folder = {
id: string;
name: Observable<string>;
parentId: Observable<string | null>;
isOpen: Observable<boolean>;
};
type Theme = 'light' | 'dark' | 'system';
// --- GLOBAL STATE ---
const workFolderId = generateId();
const personalFolderId = generateId();
const techFolderId = generateId(); // Subfolder of Work
const bookmarks = $<Bookmark[]>([]);
const folders = $<Folder[]>([{ id: 'root', name: $('Root'), parentId: $<string | null>(null), isOpen: $(true) }]);
const allTags = useMemo<string[]>(() => { // Explicit generic for useMemo return if needed
const tagSet = new Set<string>();
bookmarks().forEach(bm => bm.tags().forEach(tag => tagSet.add(tag)));
return Array.from(tagSet).sort();
});
folders([
{ id: 'root', name: $('Root'), parentId: $(null), isOpen: $(true) },
{ id: workFolderId, name: $('Work'), parentId: $('root'), isOpen: $(true) },
{ id: personalFolderId, name: $('Personal'), parentId: $('root'), isOpen: $(true) },
{ id: techFolderId, name: $('Tech Reading'), parentId: $(workFolderId), isOpen: $(false) },
] as Folder[]);
bookmarks([
{
id: generateId(),
url: $('https://voby.dev/'),
title: $('Voby Official Documentation'),
description: $('The main website for Voby framework. Essential reading!'),
tags: $(['voby', 'javascript', 'framework', 'documentation']),
isFavorite: $(true),
folderId: $(techFolderId), // In "Tech Reading" under "Work"
createdAt: Date.now() - 100000, // একটু পুরানো
},
{
id: generateId(),
url: $('https://developer.mozilla.org/'),
title: $('MDN Web Docs'),
description: $('Comprehensive resource for web developers.'),
tags: $(['mdn', 'web standards', 'javascript', 'css', 'html']),
isFavorite: $(true),
folderId: $(techFolderId),
createdAt: Date.now() - 200000,
},
{
id: generateId(),
url: $('https://github.com/'),
title: $('GitHub'),
description: $('Platform for version control and collaboration.'),
tags: $(['git', 'code', 'repository', 'collaboration']),
isFavorite: $(false),
folderId: $(workFolderId), // In "Work"
createdAt: Date.now() - 50000,
},
{
id: generateId(),
url: $('https://news.ycombinator.com/'),
title: $('Hacker News'),
description: $('Tech news and discussions.'),
tags: $(['tech', 'news', 'startups']),
isFavorite: $(false),
folderId: $(null), // In Root (or use 'root' if you prefer explicit ID)
createdAt: Date.now() - 300000,
},
{
id: generateId(),
url: $('https://www.epicurious.com/'),
title: $('Epicurious - Recipes'),
description: $('Find amazing recipes for your next meal.'),
tags: $(['food', 'recipes', 'cooking']),
isFavorite: $(true),
folderId: $(personalFolderId), // In "Personal"
createdAt: Date.now() - 150000,
},
{
id: generateId(),
url: $('https://www.theverge.com/'),
title: $('The Verge'),
description: $('Technology news and media network.'),
tags: $(['tech', 'gadgets', 'news', 'reviews']),
isFavorite: $(false),
folderId: $(null), // In Root
createdAt: Date.now() - 250000,
},
{
id: generateId(),
url: $('https://stackoverflow.com/'),
title: $('Stack Overflow'),
description: $('Q&A site for professional and enthusiast programmers.'),
tags: $(['programming', 'q&a', 'development', 'help']),
isFavorite: $(false),
folderId: $(techFolderId),
createdAt: Date.now() - 120000,
}
] as Bookmark[]);
// UI State
const currentTheme = $<Theme>('system');
const effectiveTheme = useMemo<'light' | 'dark'>(() => {
const theme = currentTheme();
if (theme === 'system') {
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
}
return theme;
});
const searchTerm = $<string>('');
const selectedTags = $<string[]>([]);
const showFavoritesOnly = $<boolean>(false);
const currentFolderId = $<string | null>('root');
const showAddBookmarkModal = $<boolean>(false);
const editingBookmark = $<Bookmark | null>(null);
const showAddFolderModal = $<boolean>(false);
const editingFolder = $<Folder | null>(null);
const parentFolderForNew = $<string | null>('root');
const isSidebarOpen = $<boolean>(true);
// --- THEME LOGIC ---
useEffect(() => {
const applyTheme = (themeValue: 'dark' | 'light') => {
document.documentElement.classList.remove('dark', 'light');
document.documentElement.classList.add(themeValue);
};
useEffect(() => {
applyTheme(effectiveTheme());
});
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
const handleChange = () => {
if (untrack(currentTheme) === 'system') {
// This will trigger effectiveTheme re-computation
}
};
mediaQuery.addEventListener('change', handleChange);
return () => mediaQuery.removeEventListener('change', handleChange);
});
// --- DATA LOGIC ---
const getFolderChildren = (folderId: string | null): { folders: Folder[], bookmarks: Bookmark[] } => {
const childFolders = folders().filter(f => f.parentId() === folderId);
const childBookmarks = bookmarks().filter(b => b.folderId() === folderId);
return { folders: childFolders, bookmarks: childBookmarks };
};
const filteredAndSortedBookmarks = useMemo<Bookmark[]>(() => {
let bms = bookmarks();
const currentFolder = currentFolderId();
bms = bms.filter(bm => bm.folderId() === currentFolder);
const term = searchTerm().toLowerCase();
if (term) {
bms = bms.filter(bm =>
bm.title().toLowerCase().includes(term) ||
bm.url().toLowerCase().includes(term) ||
bm.description().toLowerCase().includes(term) ||
bm.tags().some(tag => tag.toLowerCase().includes(term))
);
}
const tagsToFilter = selectedTags();
if (tagsToFilter.length > 0) {
bms = bms.filter(bm => tagsToFilter.every(st => bm.tags().includes(st)));
}
if (showFavoritesOnly()) {
bms = bms.filter(bm => bm.isFavorite());
}
return bms.sort((a, b) => b.createdAt - a.createdAt);
});
// --- ACTIONS ---
const addOrUpdateBookmark = (data: {
id?: string;
url: string;
title: string;
description: string;
tags: string[];
isFavorite: boolean;
folderId: string | null;
}) => {
const currentEditingBookmark = editingBookmark();
if (currentEditingBookmark) {
currentEditingBookmark.url(data.url);
currentEditingBookmark.title(data.title);
currentEditingBookmark.description(data.description);
currentEditingBookmark.tags(data.tags);
currentEditingBookmark.isFavorite(data.isFavorite);
currentEditingBookmark.folderId(data.folderId);
} else {
const newBookmark: Bookmark = {
id: generateId(),
url: $(data.url),
title: $(data.title),
description: $(data.description),
tags: $(data.tags),
isFavorite: $(data.isFavorite),
folderId: $(data.folderId ?? currentFolderId()),
createdAt: Date.now(),
};
bookmarks([...bookmarks(), newBookmark]);
}
showAddBookmarkModal(false);
editingBookmark(null);
};
const deleteBookmark = (id: string) => {
bookmarks(bookmarks().filter(bm => bm.id !== id));
};
const addOrUpdateFolder = (data: { id?: string; name: string; parentId: string | null }) => {
const currentEditingFolder = editingFolder();
if (currentEditingFolder) {
currentEditingFolder.name(data.name);
} else {
const newFolder: Folder = {
id: generateId(),
name: $(data.name),
parentId: $(data.parentId),
isOpen: $(true),
};
folders([...folders(), newFolder]);
}
showAddFolderModal(false);
editingFolder(null);
};
const deleteFolder = (folderIdToDelete: string) => {
if (folderIdToDelete === 'root') {
alert("Cannot delete root folder.");
return;
}
const folderInstance = folders().find(f => f.id === folderIdToDelete);
if (!folderInstance) return;
const parentIdForChildren = folderInstance.parentId() ?? 'root';
bookmarks().forEach(bm => {
if (bm.folderId() === folderIdToDelete) {
bm.folderId(parentIdForChildren);
}
});
folders().forEach(f => {
if (f.parentId() === folderIdToDelete) {
f.parentId(parentIdForChildren);
}
});
folders(folders().filter(f => f.id !== folderIdToDelete));
if (currentFolderId() === folderIdToDelete) {
currentFolderId(parentIdForChildren);
}
};
const exportBookmarks = () => {
const dataToExport = {
bookmarks: untrack(() => bookmarks().map(bm => ({
id: bm.id,
url: bm.url(),
title: bm.title(),
description: bm.description(),
tags: bm.tags(),
isFavorite: bm.isFavorite(),
folderId: bm.folderId(),
createdAt: bm.createdAt,
}))),
folders: untrack(() => folders().map(f => ({
id: f.id,
name: f.name(),
parentId: f.parentId(),
}))),
};
const jsonString = JSON.stringify(dataToExport, null, 2);
const blob = new Blob([jsonString], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'bookmarks.json';
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
};
const importBookmarksNetscape = (htmlContent: string) => {
const parser = new DOMParser();
const doc = parser.parseFromString(htmlContent, 'text/html');
const importedBookmarks: Bookmark[] = [];
const importedFolders: Folder[] = [];
function processNode(node: Node, parentFolderId: string | null) {
if (node.nodeName === 'DT') {
const firstChild = node.firstChild as HTMLElement;
if (firstChild && firstChild.nodeName === 'H3') {
const folderName = firstChild.textContent || 'Untitled Folder';
const newFolderId = generateId();
importedFolders.push({
id: newFolderId,
name: $(folderName),
parentId: $(parentFolderId),
isOpen: $(true)
});
// @ts-ignore
const dlNodeCandidate = node.nextElementSibling;
if (dlNodeCandidate instanceof Element && dlNodeCandidate.nodeName === 'DL') {
Array.from(dlNodeCandidate.childNodes).forEach(child => processNode(child, newFolderId));
}
} else if (firstChild && firstChild.nodeName === 'A') {
const anchor = firstChild as HTMLAnchorElement;
const url = anchor.href;
const title = anchor.textContent || url;
const tagsAttr = anchor.getAttribute('tags');
const tags = tagsAttr ? tagsAttr.split(',').map(t => t.trim()).filter(Boolean) : [];
importedBookmarks.push({
id: generateId(), url: $(url), title: $(title), description: $(''),
tags: $(tags), isFavorite: $(false), folderId: $(parentFolderId), createdAt: Date.now(),
});
}
} else if (node.nodeName === 'DL') {
Array.from(node.childNodes).forEach(child => processNode(child, parentFolderId));
}
}
const mainDL = doc.querySelector('dl');
if (mainDL) {
Array.from(mainDL.childNodes).forEach(node => processNode(node, 'root'));
}
if (importedFolders.length > 0) folders([...folders(), ...importedFolders]);
if (importedBookmarks.length > 0) bookmarks([...bookmarks(), ...importedBookmarks]);
alert(`Imported ${importedBookmarks.length} bookmarks and ${importedFolders.length} folders.`);
};
const handleImport = (event: Event) => {
const file = (event.target as HTMLInputElement).files?.[0];
if (file) {
const reader = new FileReader();
reader.onload = (e) => {
const content = e.target?.result as string;
if (file.name.endsWith('.json')) {
try {
const data = JSON.parse(content);
if (data.bookmarks && data.folders) {
const newBookmarks = data.bookmarks.map((bm: any) => ({
...bm, url: $(bm.url), title: $(bm.title), description: $(bm.description),
tags: $(bm.tags), isFavorite: $(bm.isFavorite), folderId: $(bm.folderId)
}));
const newFolders = data.folders.map((f: any) => ({
...f, name: $(f.name), parentId: $(f.parentId), isOpen: $(true)
}));
bookmarks([...bookmarks(), ...newBookmarks]);
folders([...folders(), ...newFolders]);
alert(`Imported ${newBookmarks.length} bookmarks and ${newFolders.length} folders from JSON.`);
} else alert('Invalid JSON format.');
} catch (err) { alert('Error parsing JSON file.'); console.error(err); }
} else if (file.name.endsWith('.html') || file.name.endsWith('.htm')) {
importBookmarksNetscape(content);
} else alert('Unsupported file format. Please use .json or .html (Netscape format).');
(event.target as HTMLInputElement).value = '';
};
reader.readAsText(file);
}
};
// --- COMPONENTS ---
// Define props for Icon component
type IconProps = {
path: string;
size?: string;
class?: string; // Voby uses 'class'
}
const Icon = ({ path, size = "1em", class: className = "" }: IconProps) => (
<svg viewBox="0 0 24 24" width={size} height={size} fill="currentColor" class={className}>
<path d={path}></path>
</svg>
);
const FolderIcon = () => <Icon path="M10 4H4c-1.11 0-2 .89-2 2v12c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V8c0-1.1-.9-2-2-2h-8l-2-2z" />;
const StarFilledIcon = () => <Icon path="M12 17.27L18.18 21l-1.64-7.03L22 9.24l-7.19-.61L12 2l-2.81 6.63L2 9.24l5.46 4.73L5.82 21z" />;
const StarOutlineIcon = () => <Icon path="M22 9.24l-7.19-.62L12 2 9.19 8.63 2 9.24l5.46 4.73L5.82 21 12 17.27 18.18 21l-1.63-7.03L22 9.24zM12 15.4l-3.76 2.27 1-4.28-3.32-2.88 4.38-.38L12 6.1l1.71 4.04 4.38.38-3.32 2.88 1 4.28L12 15.4z" />;
const EditIcon = (props: { size?: string }) => <Icon path="M3 17.25V21h3.75L17.81 9.94l-3.75-3.75L3 17.25zM20.71 7.04c.39-.39.39-1.02 0-1.41l-2.34-2.34c-.39-.39-1.02-.39-1.41 0l-1.83 1.83 3.75 3.75 1.83-1.83z" {...props} />;
const DeleteIcon = (props: { size?: string }) => <Icon path="M6 19c0 1.1.9 2 2 2h8c1.1 0 2-.9 2-2V7H6v12zM19 4h-3.5l-1-1h-5l-1 1H5v2h14V4z" {...props} />;
const AddIcon = (props: { size?: string }) => <Icon path="M19 13h-6v6h-2v-6H5v-2h6V5h2v6h6v2z" {...props} />; // Pass props down
const ChevronRightIcon = () => <Icon path="M10 6L8.59 7.41 13.17 12l-4.58 4.59L10 18l6-6z" />;
const ChevronDownIcon = () => <Icon path="M16.59 8.59L12 13.17 7.41 8.59 6 10l6 6 6-6z" />;
const HamburgerIcon = () => <Icon path="M3 18h18v-2H3v2zm0-5h18v-2H3v2zm0-7v2h18V6H3z" />;
const CloseIcon = () => <Icon path="M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z" />;
const Modal = ({ title, isOpen, onClose, children }: { title: FunctionMaybe<string>, isOpen: Observable<boolean>, onClose: () => void, children: JSX.Element }) => {
const resolvedTitle = useMemo(() => (typeof title === 'function' ? title() : title));
return (
<If when={isOpen}>
<div class="modal-overlay" onClick={onClose}>
<div class="modal-content" onClick={(e: Event) => e.stopPropagation()}>
<div class="modal-header">
<h2>{resolvedTitle}</h2>
<button onClick={onClose} class="icon-button"><CloseIcon /></button>
</div>
<div class="modal-body">
{children}
</div>
</div>
</div>
</If>
);
};
const BookmarkForm = () => {
const urlObs = $<string>('');
const titleObs = $<string>('');
const descriptionObs = $<string>('');
const tagsObs = $<string>('');
const isFavoriteObs = $<boolean>(false);
const folderIdObs = $<string | null>(null); // Corrected initialization
useEffect(() => {
const currentBm = editingBookmark();
const currentFolderForNew = currentFolderId();
untrack(() => {
if (currentBm) {
urlObs(currentBm.url());
titleObs(currentBm.title());
descriptionObs(currentBm.description());
tagsObs(currentBm.tags().join(', '));
isFavoriteObs(currentBm.isFavorite());
folderIdObs(currentBm.folderId());
} else {
urlObs('');
titleObs('');
descriptionObs('');
tagsObs('');
isFavoriteObs(false);
folderIdObs(currentFolderForNew);
}
});
});
const handleSubmit = (e: Event) => {
e.preventDefault();
if (!titleObs() || !urlObs()) {
alert('Title and URL are required.');
return;
}
addOrUpdateBookmark({
id: editingBookmark()?.id,
url: urlObs(),
title: titleObs(),
description: descriptionObs(),
tags: tagsObs().split(',').map(t => t.trim()).filter(Boolean),
isFavorite: isFavoriteObs(),
folderId: folderIdObs()
});
};
return (
<form onSubmit={handleSubmit} class="form">
<div class="form-group">
<label for="bm-title">Title*</label>
<input id="bm-title" type="text" value={titleObs} onInput={e => titleObs(e.currentTarget.value)} required />
</div>
<div class="form-group">
<label for="bm-url">URL*</label>
<input id="bm-url" type="url" value={urlObs} onInput={e => urlObs(e.currentTarget.value)} required />
</div>
<div class="form-group">
<label for="bm-desc">Description</label>
<textarea id="bm-desc" value={descriptionObs} onInput={e => descriptionObs(e.currentTarget.value)}></textarea>
</div>
<div class="form-group">
<label for="bm-tags">Tags (comma-separated)</label>
<input id="bm-tags" type="text" value={tagsObs} onInput={e => tagsObs(e.currentTarget.value)} />
</div>
<div class="form-group">
<label for="bm-folder">Folder</label>
<select id="bm-folder" value={folderIdObs} onChange={e => folderIdObs(e.currentTarget.value || null)}>
<For values={folders}>
{(f) => <option value={f.id}>{() => f.name()}</option>}
</For>
</select>
</div>
<div class="form-group form-group-checkbox">
<input id="bm-fav" type="checkbox" checked={isFavoriteObs} onChange={e => isFavoriteObs(e.currentTarget.checked)} />
<label for="bm-fav">Favorite</label>
</div>
<button type="submit" class="button primary">{() => editingBookmark() ? 'Update' : 'Add'} Bookmark</button>
</form>
);
};
const FolderForm = () => {
const currentEditingFolderMemo = useMemo(() => editingFolder());
const nameInputObs = $<string>('');
const parentIdForFormMemo = useMemo(() => {
const edFolder = currentEditingFolderMemo();
return edFolder ? edFolder.parentId() : parentFolderForNew();
});
useEffect(() => {
const edFolder = editingFolder();
untrack(() => {
if (edFolder) {
nameInputObs(edFolder.name());
} else {
nameInputObs('');
}
});
});
const handleSubmit = (e: Event) => {
e.preventDefault();
const currentName = nameInputObs();
if (!currentName.trim()) {
alert('Folder name is required.');
return;
}
addOrUpdateFolder({
id: currentEditingFolderMemo()?.id,
name: currentName,
parentId: parentIdForFormMemo()
});
};
return (
<form onSubmit={handleSubmit} class="form">
<div class="form-group">
<label for="folder-name">Name*</label>
<input id="folder-name" type="text" value={nameInputObs} onInput={e => nameInputObs(e.currentTarget.value)} required />
</div>
<div class="form-group">
<label>Parent Folder</label>
<p>{() => folders().find(f => f.id === parentIdForFormMemo())?.name() || 'Root'}</p>
</div>
<button type="submit" class="button primary">{() => currentEditingFolderMemo() ? 'Update' : 'Add'} Folder</button>
</form>
);
};
const BookmarkItem = ({ bookmark }: { bookmark: Bookmark }) => {
return (
<div class="bookmark-item">
<div class="bookmark-item-main">
<button
class="icon-button favorite-button"
onClick={() => bookmark.isFavorite(!bookmark.isFavorite())}
title={() => bookmark.isFavorite() ? "Unfavorite" : "Favorite"}
>
{() => bookmark.isFavorite() ? <StarFilledIcon /> : <StarOutlineIcon />}
</button>
<a href={() => bookmark.url()} target="_blank" rel="noopener noreferrer" class="bookmark-title">
{() => bookmark.title()}
</a>
<span class="bookmark-url">{() => {
try {
return new URL(bookmark.url()).hostname;
} catch {
return bookmark.url();
}
}}</span>
</div>
<If when={() => !!bookmark.description() && bookmark.description().length > 0}>
<p class="bookmark-description">{() => bookmark.description()}</p>
</If>
<If when={() => bookmark.tags().length > 0}>
<div class="bookmark-tags">
<For values={() => bookmark.tags()}>
{(tag) => <span class="tag-pill" onClick={() => {
if (!selectedTags().includes(tag)) selectedTags([...selectedTags(), tag]);
}}>{tag}</span>}
</For>
</div>
</If>
<div class="bookmark-actions">
<button class="icon-button" onClick={() => { editingBookmark(bookmark); showAddBookmarkModal(true); }} title="Edit">
<EditIcon />
</button>
<button class="icon-button" onClick={() => { if (confirm('Are you sure?')) deleteBookmark(bookmark.id); }} title="Delete">
<DeleteIcon />
</button>
</div>
</div>
);
};
const FolderTreeItem = ({ folder, level }: { folder: Folder, level: number }) => {
const children = useMemo(() => getFolderChildren(folder.id));
const hasChildren = useMemo(() => children().folders.length > 0 || children().bookmarks.length > 0);
return (
<div class="folder-tree-item" style={{ paddingLeft: `${level * 15}px` }}>
<div
class={["folder-name-container", { 'active': () => currentFolderId() === folder.id }]}
onClick={() => currentFolderId(folder.id)}
>
<If when={hasChildren}>
<button class="icon-button chevron-button" onClick={(e) => { e.stopPropagation(); folder.isOpen(!folder.isOpen()); }}>
{() => folder.isOpen() ? <ChevronDownIcon /> : <ChevronRightIcon />}
</button>
</If>
<FolderIcon />
<span class="folder-name">{() => folder.name()}</span>
<div class="folder-actions">
<button class="icon-button" title="Add subfolder" onClick={(e) => {
e.stopPropagation();
editingFolder(null);
parentFolderForNew(folder.id);
showAddFolderModal(true);
}}><AddIcon size="0.8em"/></button>
<If when={() => folder.id !== 'root'}>
<>
<button class="icon-button" title="Edit folder" onClick={(e) => {
e.stopPropagation();
editingFolder(folder);
showAddFolderModal(true);
}}><EditIcon size="0.8em"/></button>
<button class="icon-button" title="Delete folder" onClick={(e) => {
e.stopPropagation();
if (confirm(`Delete "${folder.name()}" and move contents?`)) deleteFolder(folder.id);
}}><DeleteIcon size="0.8em"/></button>
</>
</If>
</div>
</div>
<If when={() => folder.isOpen() && hasChildren()}>
<div class="folder-children">
<For values={() => children().folders.sort((a,b) => a.name().localeCompare(b.name()))}>
{(childFolder) => <FolderTreeItem folder={childFolder} level={level + 1} />}
</For>
</div>
</If>
</div>
);
};
const Sidebar = () => {
const sortedRootFolders = useMemo<Folder[]>(() => {
const roots = folders().filter(f => f.parentId() === null);
const rootFolderInstance = roots.find(f => f.id === 'root');
const otherRoots = roots.filter(f => f.id !== 'root').sort((a,b) => a.name().localeCompare(b.name()));
return rootFolderInstance ? [rootFolderInstance, ...otherRoots] : otherRoots;
});
return (
<aside class={["sidebar", { 'open': isSidebarOpen }]}>
<div class="sidebar-header">
<h3>Folders</h3>
<button class="button" onClick={() => {
editingFolder(null);
parentFolderForNew('root');
showAddFolderModal(true);
}}>
<AddIcon size="1em" /> New Folder
</button>
</div>
<div class="folder-tree">
<For values={sortedRootFolders}>
{(folder) => <FolderTreeItem folder={folder} level={0} />}
</For>
</div>
<div class="sidebar-section">
<h3>Tags</h3>
<div class="tag-filter-list">
<For values={allTags}>
{(tag) => (
<span
class={["tag-pill interactive", { 'active': () => selectedTags().includes(tag) }]}
onClick={() => {
const current = selectedTags();
if (current.includes(tag)) {
selectedTags(current.filter(t => t !== tag));
} else {
selectedTags([...current, tag]);
}
}}
>
{tag}
</span>
)}
</For>
<If when={() => selectedTags().length > 0}>
<button class="button text-button" onClick={() => selectedTags([])}>Clear Tags</button>
</If>
</div>
</div>
<div class="sidebar-section">
<h3>Filters</h3>
<div class="form-group-checkbox">
<input id="fav-filter" type="checkbox" checked={showFavoritesOnly} onChange={e => showFavoritesOnly(e.currentTarget.checked)} />
<label for="fav-filter">Show Favorites Only</label>
</div>
</div>
<div class="sidebar-section">
<h3>Actions</h3>
<button class="button" onClick={exportBookmarks}>Export All (JSON)</button>
<label for="import-file" class="button" style="margin-top: 0.5rem; display: inline-block; cursor:pointer;">Import Bookmarks</label>
<input type="file" id="import-file" accept=".json,.html,.htm" onChange={handleImport} style={{display: 'none'}}/>
</div>
</aside>
);
};
const Header = () => {
return (
<header class="app-header">
<div class="header-left">
<button class="icon-button hamburger-button" onClick={() => isSidebarOpen(!isSidebarOpen())}>
<HamburgerIcon />
</button>
<h1>Voby Bookmarks</h1>
</div>
<div class="search-bar">
<input
type="search"
placeholder="Search bookmarks..."
value={searchTerm}
onInput={e => searchTerm(e.currentTarget.value)}
/>
</div>
<div class="header-right">
<select value={currentTheme} onChange={e => currentTheme(e.currentTarget.value as Theme)} class="theme-selector">
<option value="system">System</option>
<option value="light">Light</option>
<option value="dark">Dark</option>
</select>
<button class="button primary" onClick={() => { editingBookmark(null); showAddBookmarkModal(true); }}>
<AddIcon /> Add Bookmark
</button>
</div>
</header>
);
};
const App = () => {
const currentFolderName = useMemo<string>(() => {
const folder = folders().find(f => f.id === currentFolderId());
return folder ? folder.name() : 'All Bookmarks';
});
return (
<div class="app-container">
<Header />
<div class="main-layout">
<Sidebar />
<main class="main-content">
<h2>{currentFolderName} ({() => filteredAndSortedBookmarks().length})</h2>
<If
when={() => filteredAndSortedBookmarks().length > 0}
fallback={<p class="empty-state">No bookmarks found. Try adjusting filters or adding new ones!</p>}
>
<div class="bookmarks-list">
<For values={filteredAndSortedBookmarks}>
{(bookmark) => <BookmarkItem bookmark={bookmark} />}
</For>
</div>
</If>
</main>
</div>
<Modal title={() => editingBookmark() ? "Edit Bookmark" : "Add Bookmark"} isOpen={showAddBookmarkModal} onClose={() => { showAddBookmarkModal(false); editingBookmark(null); }}>
<BookmarkForm />
</Modal>
<Modal title={() => editingFolder() ? "Edit Folder" : "Add Folder"} isOpen={showAddFolderModal} onClose={() => { showAddFolderModal(false); editingFolder(null); }}>
<FolderForm />
</Modal>
<style>{STYLES}</style>
</div>
);
};
// --- STYLES ---
// Styles remain the same, so I'll omit them here for brevity but they should be included
// in your actual file.
const STYLES = `
:root {
--bg-primary: #ffffff;
--bg-secondary: #f0f0f0;
--bg-tertiary: #e0e0e0;
--text-primary: #212121;
--text-secondary: #757575;
--accent-primary: #007bff;
--accent-primary-hover: #0056b3;
--accent-secondary: #6c757d;
--border-color: #ced4da;
--danger-color: #dc3545;
--favorite-color: #ffc107;
--shadow-color: rgba(0,0,0,0.1);
--font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
}
.dark {
--bg-primary: #1e1e1e;
--bg-secondary: #2a2a2a;
--bg-tertiary: #363636;
--text-primary: #e0e0e0;
--text-secondary: #aaaaaa;
--accent-primary: #0088ff;
--accent-primary-hover: #0066cc;
--border-color: #444444;
--favorite-color: #f0b300;
--shadow-color: rgba(255,255,255,0.05);
}
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: var(--font-family);
background-color: var(--bg-primary);
color: var(--text-primary);
line-height: 1.6;
transition: background-color 0.2s, color 0.2s;
}
h1, h2, h3 { margin-bottom: 0.5em; }
a { color: var(--accent-primary); text-decoration: none; }
a:hover { text-decoration: underline; }
.app-container { display: flex; flex-direction: column; height: 100vh; }
.app-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.75rem 1rem;
background-color: var(--bg-secondary);
border-bottom: 1px solid var(--border-color);
flex-shrink: 0;
}
.header-left, .header-right { display: flex; align-items: center; gap: 0.75rem; }
.app-header h1 { font-size: 1.5rem; margin-bottom: 0; }
.search-bar { flex-grow: 1; margin: 0 1rem; }
.search-bar input {
width: 100%;
padding: 0.5rem 0.75rem;
border: 1px solid var(--border-color);
border-radius: 4px;
background-color: var(--bg-primary);
color: var(--text-primary);
}
.theme-selector {
padding: 0.4rem 0.6rem;
border: 1px solid var(--border-color);
border-radius: 4px;
background-color: var(--bg-primary);
color: var(--text-primary);
}
.main-layout { display: flex; flex-grow: 1; overflow: hidden; }
.sidebar {
width: 280px;
background-color: var(--bg-secondary);
padding: 1rem;
overflow-y: auto;
border-right: 1px solid var(--border-color);
flex-shrink: 0;
transition: width 0.3s ease, margin-left 0.3s ease, padding 0.3s ease;
}
.sidebar.open { width: 280px; padding: 1rem; margin-left: 0;}
.sidebar:not(.open) { width: 0; padding: 0; border-right: none; margin-left: -280px; overflow: hidden; }
.sidebar-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 1rem; }
.sidebar-header h3 { margin-bottom: 0; }
.sidebar-section { margin-bottom: 1.5rem; }
.sidebar-section h3 { font-size: 1rem; margin-bottom: 0.5rem; color: var(--text-secondary); }
.folder-tree-item { margin-bottom: 0.25rem; }
.folder-name-container {
display: flex;
align-items: center;
padding: 0.3rem 0.5rem;
border-radius: 4px;
cursor: pointer;
transition: background-color 0.15s;
}
.folder-name-container:hover { background-color: var(--bg-tertiary); }
.folder-name-container.active { background-color: var(--accent-primary); color: white; }
.folder-name-container.active .icon-button, .folder-name-container.active .folder-name { color: white !important; }
.folder-name-container.active .folder-actions .icon-button { color: white !important; }
.folder-name { margin-left: 0.5rem; flex-grow: 1; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.folder-actions { display: flex; margin-left: auto; opacity: 0; transition: opacity 0.2s; }
.folder-name-container:hover .folder-actions { opacity: 1; }
.folder-name-container.active .folder-actions { opacity: 1; }
.folder-actions .icon-button { padding: 0.2rem; }
.tag-filter-list { display: flex; flex-wrap: wrap; gap: 0.5rem; }
.tag-pill {
background-color: var(--bg-tertiary);
color: var(--text-secondary);
padding: 0.2em 0.6em;
border-radius: 1em;
font-size: 0.85em;
cursor: default;
}
.tag-pill.interactive { cursor: pointer; }
.tag-pill.interactive:hover { background-color: var(--accent-secondary); color: white; }
.tag-pill.interactive.active { background-color: var(--accent-primary); color: white; }
.main-content {
flex-grow: 1;
padding: 1.5rem;
overflow-y: auto;
}
.main-content h2 { border-bottom: 1px solid var(--border-color); padding-bottom: 0.5rem; }
.bookmarks-list { display: grid; gap: 1rem; grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); }
.bookmark-item {
background-color: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: 6px;
padding: 1rem;
display: flex;
flex-direction: column;
box-shadow: 0 2px 4px var(--shadow-color);
}
.bookmark-item-main { display: flex; align-items: center; gap: 0.5rem; margin-bottom: 0.5rem; }
.bookmark-title { font-weight: bold; font-size: 1.1em; color: var(--text-primary); }
.bookmark-url { font-size: 0.85em; color: var(--text-secondary); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.bookmark-description { font-size: 0.9em; color: var(--text-secondary); margin-bottom: 0.5rem; word-break: break-word; }
.bookmark-tags { display: flex; flex-wrap: wrap; gap: 0.5rem; margin-bottom: 0.75rem; }
.bookmark-actions { margin-top: auto; display: flex; gap: 0.5rem; justify-content: flex-end; }
.button {
padding: 0.5rem 1rem;
border: 1px solid var(--border-color);
border-radius: 4px;
background-color: var(--bg-tertiary);
color: var(--text-primary);
cursor: pointer;
transition: background-color 0.2s, border-color 0.2s;
display: inline-flex;
align-items: center;
gap: 0.3rem;
}
.button:hover { background-color: var(--border-color); }
.button.primary { background-color: var(--accent-primary); color: white; border-color: var(--accent-primary); }
.button.primary:hover { background-color: var(--accent-primary-hover); border-color: var(--accent-primary-hover); }
.button.text-button { background: none; border: none; padding: 0.2rem; color: var(--accent-primary); }
.button.text-button:hover { text-decoration: underline; }
.icon-button {
background: none;
border: none;
color: var(--text-secondary);
cursor: pointer;
padding: 0.3rem;
border-radius: 50%;
display: inline-flex;
align-items: center;
justify-content: center;
}
.icon-button:hover { background-color: var(--bg-tertiary); color: var(--text-primary); }
.icon-button.favorite-button svg { color: var(--favorite-color); }
.icon-button.chevron-button { margin-right: 0.3rem; }
.modal-overlay {
position: fixed;
top: 0; left: 0; right: 0; bottom: 0;
background-color: rgba(0,0,0,0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
.modal-content {
background-color: var(--bg-primary);
padding: 1.5rem;
border-radius: 8px;
min-width: 320px;
max-width: 500px;
width: 90%;
box-shadow: 0 5px 15px rgba(0,0,0,0.3);
}
.modal-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 1rem; }
.modal-header h2 { margin-bottom: 0; }
.form .form-group { margin-bottom: 1rem; }
.form label { display: block; margin-bottom: 0.3rem; font-weight: bold; font-size: 0.9em; }
.form input[type="text"],
.form input[type="url"],
.form input[type="search"],
.form textarea,
.form select {
width: 100%;
padding: 0.6rem 0.8rem;
border: 1px solid var(--border-color);
border-radius: 4px;
background-color: var(--bg-primary);
color: var(--text-primary);
font-size: 1em;
}
.form textarea { min-height: 80px; resize: vertical; }
.form .form-group-checkbox { display: flex; align-items: center; }
.form .form-group-checkbox input { width: auto; margin-right: 0.5rem; }
.empty-state { text-align: center; color: var(--text-secondary); padding: 2rem; }
.hamburger-button { display: none; }
@media (max-width: 768px) {
.sidebar {
position: fixed;
left: 0;
top: var(--header-height, 60px);
height: calc(100vh - var(--header-height, 60px));
z-index: 900;
}
.main-content { padding: 1rem; }
.bookmarks-list { grid-template-columns: 1fr; }
.app-header { --header-height: 60px; }
.app-header h1 { font-size: 1.2rem; }
.search-bar { margin: 0 0.5rem; }
.header-right .button.primary span { display: none; }
.header-right .button.primary svg { margin-right: 0; }
.hamburger-button { display: inline-flex; }
}
`;
// --- RENDER ---
render(<App />, document.getElementById('app'));
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment