Last active
May 7, 2025 17:52
-
-
Save sibbng/0921d924e918104eb4f580dad75c826b to your computer and use it in GitHub Desktop.
voby bookmark app
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| { | |
| "imports": { | |
| "voby": "https://esm.sh/voby" | |
| } | |
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| @theme { | |
| --font-sans: 'Inter', sans-serif; | |
| } | |
| @custom-variant dark (&:where(.dark, .dark *)); | |
| @layer { | |
| body { | |
| @apply bg-neutral-400; | |
| } | |
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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