Bookmark Manager Grid
blockproductivityRaindrop style visual web bookmarks and engineering resources manager with collection folders, tag cloud filtering, real-time search, favorite toggles, copy link, and add bookmark modal.
Installation
$pnpm dlx uipkge-ng@latest add bookmark-manager-grid$npx uipkge-ng@latest add bookmark-manager-grid$yarn dlx uipkge-ng@latest add bookmark-manager-grid$bunx uipkge-ng@latest add bookmark-manager-gridManifest:
https://uipkge.dev/r/angular/bookmark-manager-grid.jsonInstalls to:src/app/components/blocks/bookmark-manager-grid/Variants
Loading interactive previews…
Loading interactive previews…
Schema
Type aliases exported from this item's source. Use these to shape the data you pass in.
BookmarkIteminterface BookmarkItem {
id: string
title: string
url: string
domain: string
faviconUrl: string
previewImage: string
excerpt: string
collection: BookmarkCollection
tags: string[]
isFavorite: boolean
savedAt: string
}CollectionMetainterface CollectionMeta {
id: string
name: string
count: number
iconName: 'bookmark' | 'palette' | 'zap' | 'type' | 'bot' | 'shield' | 'star'
}Files installed (2)
src/app/components/blocks/bookmark-manager-grid/bookmark-manager-grid.component.ts41.8 kB
// Raindrop style visual web bookmarks and engineering resources manager with collection // folders, tag cloud filtering, real-time search, favorite toggles, copy link, and an add // bookmark modal. Port of the React block 1:1. import { ChangeDetectionStrategy, Component, DestroyRef, Input, computed, inject, signal } from '@angular/core' import { BookOpen, Bookmark, Bot, Check, CheckCircle2, Copy, ExternalLink, Globe, Layers, LucideAngularModule, Palette, Plus, Search, ShieldCheck, Star, Tag, Type, X, Zap, } from 'lucide-angular' import { cn } from '@/lib/utils' import { UiBadgeComponent } from '@/ui/badge/badge.component' import { UiButtonComponent } from '@/ui/button/button.component' import { UiCardComponent, UiCardContentComponent } from '@/ui/card/card.component' import { UiDialogComponent, UiDialogContentComponent, UiDialogDescriptionComponent, UiDialogFooterComponent, UiDialogHeaderComponent, UiDialogTitleComponent, } from '@/ui/dialog/dialog.component' import { UiInputComponent } from '@/ui/input/input.component' import { UiSelectComponent, UiSelectContentComponent, UiSelectItemComponent, UiSelectTriggerComponent, UiSelectValueComponent, } from '@/ui/select/select.component' import { UiSeparatorComponent } from '@/ui/separator/separator.component' export type BookmarkCollection = 'design-systems' | 'web-perf' | 'typography' | 'ai-tools' | 'security' export interface BookmarkItem { id: string title: string url: string domain: string faviconUrl: string previewImage: string excerpt: string collection: BookmarkCollection tags: string[] isFavorite: boolean savedAt: string } export interface CollectionMeta { id: string name: string count: number iconName: 'bookmark' | 'palette' | 'zap' | 'type' | 'bot' | 'shield' | 'star' } const INITIAL_BOOKMARKS: BookmarkItem[] = [ { id: 'bm-01', title: 'OKLCH Color Picker & Converter', url: 'https://oklch.com', domain: 'oklch.com', faviconUrl: 'https://images.unsplash.com/photo-1550684848-fac1c5b4e853?w=64&auto=format&fit=crop&q=80', previewImage: 'https://images.unsplash.com/photo-1541701494587-cb58502866ab?w=800&auto=format&fit=crop&q=80', excerpt: 'Interactive color space playground with gamut-mapping, APCA contrast ratios, and direct CSS/Tailwind OKLCH code generation.', collection: 'design-systems', tags: ['#oklch', '#tailwind', '#design-systems'], isFavorite: true, savedAt: 'Aug 18, 2026', }, { id: 'bm-02', title: 'Reka UI Headless Vue Primitives', url: 'https://reka-ui.com', domain: 'reka-ui.com', faviconUrl: 'https://images.unsplash.com/photo-1618005182384-a83a8bd57fbe?w=64&auto=format&fit=crop&q=80', previewImage: 'https://images.unsplash.com/photo-1507238691740-187a5b1d37b8?w=800&auto=format&fit=crop&q=80', excerpt: 'Accessible, completely unstyled UI component primitives for Vue 3 with WAI-ARIA compliant keyboard focus and composite slots.', collection: 'design-systems', tags: ['#reka-ui', '#vue', '#design-systems'], isFavorite: true, savedAt: 'Aug 15, 2026', }, { id: 'bm-03', title: 'Web Vitals Performance Playbook', url: 'https://web.dev/vitals', domain: 'web.dev', faviconUrl: 'https://images.unsplash.com/photo-1551288049-bebda4e38f71?w=64&auto=format&fit=crop&q=80', previewImage: 'https://images.unsplash.com/photo-1460925895917-afdab827c52f?w=800&auto=format&fit=crop&q=80', excerpt: 'Diagnostic field guides for optimizing Interaction to Next Paint (INP), Largest Contentful Paint (LCP), and layout stability.', collection: 'web-perf', tags: ['#web-vitals', '#web-perf', '#astro'], isFavorite: false, savedAt: 'Aug 12, 2026', }, { id: 'bm-04', title: 'Typewolf Font Trends & Pairings', url: 'https://typewolf.com', domain: 'typewolf.com', faviconUrl: 'https://images.unsplash.com/photo-1455390582262-044cdead277a?w=64&auto=format&fit=crop&q=80', previewImage: 'https://images.unsplash.com/photo-1516962215378-7fa2e137ae93?w=800&auto=format&fit=crop&q=80', excerpt: 'The definitive guide to web typography, font pairings, lookbooks, variable font loading strategies, and typographic hierarchy.', collection: 'typography', tags: ['#typography', '#design-systems', '#react'], isFavorite: true, savedAt: 'Aug 09, 2026', }, { id: 'bm-05', title: 'Vercel AI SDK 3.0 Documentation', url: 'https://sdk.vercel.ai/docs', domain: 'sdk.vercel.ai', faviconUrl: 'https://images.unsplash.com/photo-1618005182384-a83a8bd57fbe?w=64&auto=format&fit=crop&q=80', previewImage: 'https://images.unsplash.com/photo-1677442136019-21780ecad995?w=800&auto=format&fit=crop&q=80', excerpt: 'Universal TypeScript library for streaming chat completions, structured JSON generation, tool calling, and multi-model routing.', collection: 'ai-tools', tags: ['#ai-prompt', '#react', '#astro'], isFavorite: false, savedAt: 'Aug 06, 2026', }, { id: 'bm-06', title: 'OWASP API Security Top 10 Guide', url: 'https://owasp.org/API-Security', domain: 'owasp.org', faviconUrl: 'https://images.unsplash.com/photo-1563986768609-322da13575f3?w=64&auto=format&fit=crop&q=80', previewImage: 'https://images.unsplash.com/photo-1526374965328-7f61d4dc18c5?w=800&auto=format&fit=crop&q=80', excerpt: 'Crucial security vectors for modern APIs including broken object authorization, rate limiting flaws, and JWT key hygiene.', collection: 'security', tags: ['#security', '#web-perf'], isFavorite: false, savedAt: 'Aug 03, 2026', }, ] const COLLECTIONS: CollectionMeta[] = [ { id: 'all', name: 'All Bookmarks', count: 142, iconName: 'bookmark' }, { id: 'design-systems', name: 'Design Systems', count: 28, iconName: 'palette' }, { id: 'web-perf', name: 'Web Performance', count: 19, iconName: 'zap' }, { id: 'typography', name: 'Typography & Fonts', count: 14, iconName: 'type' }, { id: 'ai-tools', name: 'AI & LLM Tools', count: 35, iconName: 'bot' }, { id: 'security', name: 'Security & Auth', count: 22, iconName: 'shield' }, { id: 'favorites', name: 'Favorites ★', count: 18, iconName: 'star' }, ] const TAG_CLOUD = [ '#reka-ui', '#tailwind', '#oklch', '#astro', '#react', '#vue', '#typography', '#web-vitals', '#security', '#ai-prompt', ] const COLLECTION_IDS: BookmarkCollection[] = ['design-systems', 'web-perf', 'typography', 'ai-tools', 'security'] @Component({ changeDetection: ChangeDetectionStrategy.Eager, selector: 'ui-bookmark-manager-grid, [ui-bookmark-manager-grid]', standalone: true, // React renders the root <div> itself: the host stays out of layout and `class` goes to the root. host: { '[attr.class]': '"contents"' }, imports: [ LucideAngularModule, UiBadgeComponent, UiButtonComponent, UiCardComponent, UiCardContentComponent, UiDialogComponent, UiDialogContentComponent, UiDialogDescriptionComponent, UiDialogFooterComponent, UiDialogHeaderComponent, UiDialogTitleComponent, UiInputComponent, UiSelectComponent, UiSelectContentComponent, UiSelectItemComponent, UiSelectTriggerComponent, UiSelectValueComponent, UiSeparatorComponent, ], template: ` <div data-slot="bookmark-manager-grid" [class]="rootClass"> <!-- Top Floating Toast Notification --> @if (notificationToast()) { <div role="status" aria-live="polite" class="bg-foreground text-background animate-in fade-in slide-in-from-top-2 fixed top-6 right-6 z-50 flex items-center gap-2.5 rounded-lg px-4 py-2.5 text-xs font-medium shadow-lg transition-colors" > <lucide-icon [img]="CheckCircle2" class="text-primary size-4 shrink-0" /> <span>{{ notificationToast() }}</span> </div> } <!-- Header Section --> <div class="bg-card text-card-foreground border-border/80 flex flex-col gap-5 rounded-2xl border p-5 shadow-xs sm:flex-row sm:items-center sm:justify-between lg:p-6" > <div class="space-y-1.5"> <div class="flex flex-wrap items-center gap-2.5"> <div class="bg-primary/10 text-primary grid size-9 place-items-center rounded-xl shadow-xs"> <lucide-icon [img]="Bookmark" class="size-5" /> </div> <h1 class="text-xl font-bold tracking-tight sm:text-2xl">Bookmarks & Engineering Resources</h1> <span ui-badge variant="secondary" class="font-mono text-xs">v2.1</span> </div> <p class="text-muted-foreground flex flex-wrap items-center gap-2 text-xs"> <lucide-icon [img]="Layers" class="size-3.5" /> <span class="text-foreground font-medium">142 bookmarks · 8 collections</span> <span class="text-border hidden sm:inline">|</span> <span class="text-muted-foreground hidden sm:inline">Raindrop Sync Active</span> <span class="bg-success inline-block size-1.5 rounded-full"></span> </p> </div> <div class="flex flex-col gap-3 sm:flex-row sm:items-center"> <!-- Search Input --> <div class="relative w-full sm:w-64 lg:w-72"> <lucide-icon [img]="Search" class="text-muted-foreground pointer-events-none absolute top-1/2 left-3 size-4 -translate-y-1/2" /> <ui-input [value]="searchQuery()" (valueChange)="searchQuery.set($event)" type="search" placeholder="Search bookmarks, domains, tags…" class="h-9 pr-8 pl-9 text-xs" /> @if (searchQuery()) { <button type="button" class="text-muted-foreground hover:text-foreground absolute top-1/2 right-2.5 min-h-6 -translate-y-1/2 text-xs" aria-label="Clear search" (click)="searchQuery.set('')" > <lucide-icon [img]="X" class="size-3.5" /> </button> } </div> <!-- Add Bookmark Button --> <button ui-button size="sm" class="shrink-0 font-medium active:scale-[0.98]" (click)="isAddModalOpen.set(true)" > <lucide-icon [img]="Plus" class="size-4" /> Add Bookmark </button> </div> </div> <!-- Mobile Horizontal Collections Bar (Visible on mobile/tablet) --> <div class="bg-card text-card-foreground border-border/80 flex items-center gap-1.5 overflow-x-auto rounded-xl border p-2 shadow-xs lg:hidden" > @for (col of collections; track col.id) { <button type="button" [class]="mobileCollectionClass(col.id)" (click)="selectedCollection.set(col.id)"> <span>{{ col.name }}</span> <span [class]="mobileCollectionCountClass(col.id)">{{ col.count }}</span> </button> } </div> <!-- 2-Column Main Layout (Left: Sidebar w-60; Right: Grid) --> <div class="flex flex-col gap-6 lg:flex-row lg:items-start"> <!-- Left Collections Sidebar --> <aside class="hidden w-60 shrink-0 space-y-6 lg:block" aria-label="Collections and Tags"> <!-- Collections List Card --> <div class="bg-card text-card-foreground border-border/80 space-y-3 rounded-2xl border p-4 shadow-xs"> <div class="flex items-center justify-between px-1"> <h2 class="text-foreground text-xs font-semibold tracking-wider uppercase">Collections</h2> <span class="text-muted-foreground font-mono text-xs tabular-nums">8 folders</span> </div> <nav class="space-y-1" aria-label="Collection folders"> @for (col of collections; track col.id) { <button type="button" [class]="sidebarCollectionClass(col.id)" (click)="selectedCollection.set(col.id)"> <div class="flex items-center gap-2.5 truncate"> @switch (col.iconName) { @case ('bookmark') { <lucide-icon [img]="Bookmark" class="size-4 shrink-0" /> } @case ('palette') { <lucide-icon [img]="Palette" class="text-chart-1 size-4 shrink-0" /> } @case ('zap') { <lucide-icon [img]="Zap" class="text-warning size-4 shrink-0" /> } @case ('type') { <lucide-icon [img]="Type" class="text-info size-4 shrink-0" /> } @case ('bot') { <lucide-icon [img]="Bot" class="text-success size-4 shrink-0" /> } @case ('shield') { <lucide-icon [img]="ShieldCheck" class="text-destructive size-4 shrink-0" /> } @case ('star') { <lucide-icon [img]="Star" class="fill-warning text-warning size-4 shrink-0" /> } } <span class="truncate">{{ col.name }}</span> </div> <span [class]="sidebarCollectionCountClass(col.id)">{{ col.count }}</span> </button> } </nav> </div> <!-- Tags Cloud Card --> <div class="bg-card text-card-foreground border-border/80 space-y-3 rounded-2xl border p-4 shadow-xs"> <div class="flex items-center justify-between px-1"> <h2 class="text-foreground flex items-center gap-1.5 text-xs font-semibold tracking-wider uppercase"> <lucide-icon [img]="Tag" class="text-primary size-3.5" /> Tag Cloud </h2> @if (selectedTag()) { <button type="button" class="text-primary min-h-6 text-xs transition-transform duration-150 hover:underline active:scale-95" (click)="selectedTag.set(null)" > Reset </button> } </div> <div class="flex flex-wrap gap-1.5"> @for (tag of tagCloud; track tag) { <button type="button" [class]="tagCloudClass(tag)" (click)="toggleTag(tag)"> {{ tag }} </button> } </div> </div> <!-- Quick Storage Vault Info Box --> <div class="bg-muted/40 border-border/70 text-card-foreground space-y-2 rounded-2xl border p-4 text-xs"> <div class="flex items-center gap-2"> <div class="bg-primary/10 text-primary grid size-6 place-items-center rounded-md"> <lucide-icon [img]="Globe" class="size-3.5" /> </div> <span class="text-foreground font-semibold">Raindrop Cloud Vault</span> </div> <p class="text-muted-foreground text-xs leading-relaxed"> Encrypted cross-device bookmark vault with automated favicon caching and full-text metadata indexing. </p> </div> </aside> <!-- Right Bookmarks Main Area --> <main class="min-w-0 flex-1 space-y-4"> <!-- Result Bar & Filter Indicators --> <div class="bg-card text-card-foreground border-border/80 flex flex-wrap items-center justify-between gap-3 rounded-xl border px-4 py-3 shadow-xs" > <div class="flex flex-wrap items-center gap-2 text-xs"> <span class="text-muted-foreground">Collection:</span> <span ui-badge variant="outline" class="border-primary/40 bg-primary/5 text-primary text-xs font-medium"> {{ activeCollectionMeta().name }} </span> @if (selectedTag(); as tag) { <span class="text-muted-foreground ml-1">Tag:</span> <span ui-badge variant="secondary" class="text-xs font-medium"> {{ tag }} <button type="button" class="text-muted-foreground hover:text-foreground ml-1 inline-flex items-center" aria-label="Remove tag filter" (click)="selectedTag.set(null)" > <lucide-icon [img]="X" class="size-3" /> </button> </span> } @if (searchQuery()) { <span class="text-muted-foreground ml-1">Query:</span> <span class="text-foreground font-mono font-medium">“{{ searchQuery() }}”</span> } @if (selectedCollection() !== 'all' || selectedTag() || searchQuery()) { <button type="button" class="text-muted-foreground hover:text-foreground ml-2 min-h-6 text-xs underline underline-offset-2" (click)="resetFilters()" > Clear filters </button> } </div> <div class="text-muted-foreground text-xs tabular-nums"> Showing <span class="text-foreground font-semibold">{{ filteredBookmarks().length }}</span> of {{ bookmarks().length }} items </div> </div> <!-- Bookmarks Grid (2 or 3-column responsive) --> @if (filteredBookmarks().length > 0) { <div class="grid grid-cols-1 gap-4 sm:gap-5 md:grid-cols-2 xl:grid-cols-3"> @for (item of filteredBookmarks(); track item.id) { <div ui-card class="group border-border/80 bg-card hover:border-border relative flex flex-col overflow-hidden rounded-xl border p-0 shadow-xs transition-colors duration-200 hover:shadow-md" > <!-- Thumbnail Visual Preview --> <div class="bg-muted/40 border-border/60 relative aspect-[16/10] w-full overflow-hidden border-b"> <img [src]="item.previewImage" [alt]="item.title" loading="lazy" class="size-full object-cover transition-transform duration-300 group-hover:scale-105" /> <!-- Gradient overlay --> <div class="absolute inset-0 bg-gradient-to-t from-black/60 via-transparent to-black/20 opacity-40 transition-opacity group-hover:opacity-60" ></div> <!-- Domain Badge Top Left --> <div class="absolute top-2.5 left-2.5 flex items-center gap-1.5"> <div class="bg-background text-foreground border-border/80 flex items-center gap-1.5 rounded-md border px-2 py-0.5 text-xs font-medium shadow-xs" > <lucide-icon [img]="Globe" class="text-muted-foreground size-3" /> <span class="font-mono text-xs">{{ item.domain }}</span> </div> </div> <!-- Favorite Toggle Star Button Top Right --> <button type="button" [class]="favoriteButtonClass(item.isFavorite)" [title]="item.isFavorite ? 'Remove from favorites' : 'Add to favorites'" [attr.aria-label]="item.isFavorite ? 'Remove from favorites' : 'Add to favorites'" (click)="toggleFavorite(item, $event)" > <lucide-icon [img]="Star" [class]="favoriteIconClass(item.isFavorite)" /> </button> </div> <!-- Card Content Body --> <div ui-card-content class="flex flex-1 flex-col justify-between gap-3 p-4"> <div class="space-y-2"> <div class="flex items-start justify-between gap-2"> <a [href]="item.url" target="_blank" rel="noreferrer noopener" class="group-hover:text-primary line-clamp-1 min-h-6 py-0.5 text-sm font-semibold tracking-tight transition-colors hover:underline" > {{ item.title }} </a> </div> <p class="text-muted-foreground line-clamp-2 text-xs leading-relaxed">{{ item.excerpt }}</p> </div> <!-- Tag Pills --> <div class="flex flex-wrap items-center gap-1.5"> @for (t of item.tags; track t) { <button type="button" [class]="tagPillClass(t)" (click)="toggleTag(t)"> {{ t }} </button> } </div> <!-- Card Footer Actions Row --> <div class="border-border/60 flex items-center justify-between border-t pt-3 text-xs"> <span class="text-muted-foreground font-mono text-xs tabular-nums">{{ item.savedAt }}</span> <div class="flex items-center gap-1.5"> <button ui-button variant="outline" size="icon-xs" class="size-7 transition-transform duration-150 active:scale-95" [title]="copiedId() === item.id ? 'Copied URL!' : 'Copy URL'" [attr.aria-label]="copiedId() === item.id ? 'Copied URL' : 'Copy URL'" (click)="copyUrl(item, $event)" > @if (copiedId() === item.id) { <lucide-icon [img]="Check" class="text-primary size-3.5" /> } @else { <lucide-icon [img]="Copy" class="size-3.5" /> } </button> <a ui-button variant="secondary" size="sm" class="h-7 gap-1 px-2.5 text-xs font-medium active:scale-[0.98]" [href]="item.url" target="_blank" rel="noreferrer noopener" > <span>Open</span> <lucide-icon [img]="ExternalLink" class="size-3" /> </a> </div> </div> </div> </div> } </div> } @else { <!-- Empty State --> <div class="bg-card text-card-foreground border-border/80 flex flex-col items-center justify-center rounded-2xl border p-12 text-center shadow-xs" > <div class="bg-muted/70 text-muted-foreground mb-3 grid size-12 place-items-center rounded-xl"> <lucide-icon [img]="BookOpen" class="size-6" /> </div> <h2 class="text-base font-semibold">No bookmarks found</h2> <p class="text-muted-foreground mt-1 max-w-sm text-xs"> No engineering resources match your active collection, tag filter, or search query. Try clearing filters or add a new bookmark. </p> <div class="mt-4 flex items-center gap-2"> <button ui-button variant="outline" size="sm" class="text-xs" (click)="resetFilters()"> Clear All Filters </button> <button ui-button size="sm" class="text-xs" (click)="isAddModalOpen.set(true)"> <lucide-icon [img]="Plus" class="size-3.5" /> Add Bookmark </button> </div> </div> } </main> </div> <!-- Add Bookmark Modal Dialog --> <ui-dialog [open]="isAddModalOpen()" (openChange)="isAddModalOpen.set($event)"> <ui-dialog-content class="sm:max-w-md"> <div ui-dialog-header class="text-left"> <div class="flex items-center gap-2"> <div class="bg-primary/10 text-primary grid size-7 place-items-center rounded-lg"> <lucide-icon [img]="Bookmark" class="size-4" /> </div> <h2 ui-dialog-title class="text-base font-bold">Add New Bookmark</h2> </div> <p ui-dialog-description class="text-xs"> Save a web resource or engineering tool with automated metadata fetching. </p> </div> <form class="space-y-4 pt-1" (submit)="handleSaveBookmark($event)"> <!-- URL Input with Auto-Fetch Button --> <div class="space-y-1.5"> <label class="text-foreground text-xs font-medium">Website URL</label> <div class="flex items-center gap-2"> <ui-input [value]="formUrl()" (valueChange)="formUrl.set($event)" type="url" placeholder="https://astro.build or https://linear.app" class="h-9 text-xs" /> <button ui-button type="button" variant="outline" size="sm" class="h-9 shrink-0 gap-1 text-xs font-medium" title="Fetch metadata from URL" (click)="autoFetchMetadata()" > <lucide-icon [img]="Globe" class="text-primary size-3.5" /> <span>Fetch</span> </button> </div> </div> <!-- Quick Samples Preset Bar --> <div class="flex flex-wrap items-center gap-1.5 text-xs"> <span class="text-muted-foreground text-xs">Try sample:</span> <button type="button" class="border-border bg-muted/60 hover:bg-muted text-muted-foreground hover:text-foreground min-h-6 rounded-md border px-2 py-0.5 text-xs transition-colors" (click)="setPresetUrl('https://astro.build')" > Astro SSG </button> <button type="button" class="border-border bg-muted/60 hover:bg-muted text-muted-foreground hover:text-foreground min-h-6 rounded-md border px-2 py-0.5 text-xs transition-colors" (click)="setPresetUrl('https://linear.app/method')" > Linear Method </button> <button type="button" class="border-border bg-muted/60 hover:bg-muted text-muted-foreground hover:text-foreground min-h-6 rounded-md border px-2 py-0.5 text-xs transition-colors" (click)="setPresetUrl('https://github.com/features')" > GitHub Guides </button> </div> <ui-separator class="bg-border/60" /> <!-- Title Input --> <div class="space-y-1.5"> <label class="text-foreground text-xs font-medium">Bookmark Title</label> <ui-input [value]="formTitle()" (valueChange)="formTitle.set($event)" type="text" placeholder="e.g. Astro 4.0 Server Islands & SSG Docs" class="h-9 text-xs" /> </div> <!-- Excerpt Description --> <div class="space-y-1.5"> <label class="text-foreground text-xs font-medium">Excerpt & Notes</label> <ui-input [value]="formExcerpt()" (valueChange)="formExcerpt.set($event)" type="text" placeholder="Brief description or takeaway notes…" class="h-9 text-xs" /> </div> <!-- Collection Selector --> <div class="space-y-1.5"> <label class="text-foreground text-xs font-medium">Collection Folder</label> <ui-select [value]="formCollection()" (valueChange)="setFormCollection($event)"> <button ui-select-trigger class="h-9 text-xs"> <ui-select-value placeholder="Select collection" /> </button> <ui-select-content> <ui-select-item value="design-systems">Design Systems</ui-select-item> <ui-select-item value="web-perf">Web Performance</ui-select-item> <ui-select-item value="typography">Typography & Fonts</ui-select-item> <ui-select-item value="ai-tools">AI & LLM Tools</ui-select-item> <ui-select-item value="security">Security & Auth</ui-select-item> </ui-select-content> </ui-select> </div> <!-- Tags Input --> <div class="space-y-1.5"> <label class="text-foreground text-xs font-medium">Tags (comma separated)</label> <ui-input [value]="formTags()" (valueChange)="formTags.set($event)" type="text" placeholder="#astro, #vue, #web-perf" class="h-9 text-xs" /> </div> <!-- Dialog Footer Buttons --> <div ui-dialog-footer class="gap-2 pt-2 sm:gap-0"> <button ui-button type="button" variant="outline" size="sm" class="text-xs" (click)="isAddModalOpen.set(false)" > Cancel </button> <button ui-button type="submit" size="sm" class="text-xs font-medium"> <lucide-icon [img]="Plus" class="size-3.5" /> Save Bookmark </button> </div> </form> </ui-dialog-content> </ui-dialog> </div> `, }) export class UiBookmarkManagerGridComponent { protected readonly BookOpen = BookOpen protected readonly Bookmark = Bookmark protected readonly Bot = Bot protected readonly Check = Check protected readonly CheckCircle2 = CheckCircle2 protected readonly Copy = Copy protected readonly ExternalLink = ExternalLink protected readonly Globe = Globe protected readonly Layers = Layers protected readonly Palette = Palette protected readonly Plus = Plus protected readonly Search = Search protected readonly ShieldCheck = ShieldCheck protected readonly Star = Star protected readonly Tag = Tag protected readonly Type = Type protected readonly X = X protected readonly Zap = Zap protected readonly collections = COLLECTIONS protected readonly tagCloud = TAG_CLOUD @Input() set initialBookmarks(v: BookmarkItem[] | undefined) { if (v !== undefined) this.bookmarks.set(v.map((item) => ({ ...item, tags: [...item.tags] }))) } @Input('class') className?: string readonly bookmarks = signal<BookmarkItem[]>(INITIAL_BOOKMARKS.map((b) => ({ ...b, tags: [...b.tags] }))) readonly searchQuery = signal('') readonly selectedCollection = signal('all') readonly selectedTag = signal<string | null>(null) // Add Bookmark Modal state readonly isAddModalOpen = signal(false) readonly formUrl = signal('') readonly formTitle = signal('') readonly formDomain = signal('') readonly formExcerpt = signal('') readonly formCollection = signal<BookmarkCollection>('design-systems') readonly formTags = signal('') readonly formPreview = signal('') // Toast Notification readonly notificationToast = signal<string | null>(null) private toastTimer: ReturnType<typeof setTimeout> | undefined // Copy URL feedback readonly copiedId = signal<string | null>(null) private copyTimer: ReturnType<typeof setTimeout> | undefined constructor() { inject(DestroyRef).onDestroy(() => { clearTimeout(this.toastTimer) clearTimeout(this.copyTimer) }) } readonly filteredBookmarks = computed(() => { const collection = this.selectedCollection() const tag = this.selectedTag() const q = this.searchQuery().trim().toLowerCase() return this.bookmarks().filter((item) => { if (collection === 'favorites') { if (!item.isFavorite) return false } else if (collection !== 'all') { if (item.collection !== collection) return false } if (tag && !item.tags.includes(tag)) return false if (q) { const titleMatch = item.title.toLowerCase().includes(q) const domainMatch = item.domain.toLowerCase().includes(q) const excerptMatch = item.excerpt.toLowerCase().includes(q) const urlMatch = item.url.toLowerCase().includes(q) const tagMatch = item.tags.some((t) => t.toLowerCase().includes(q)) if (!titleMatch && !domainMatch && !excerptMatch && !urlMatch && !tagMatch) return false } return true }) }) readonly activeCollectionMeta = computed( () => COLLECTIONS.find((c) => c.id === this.selectedCollection()) ?? COLLECTIONS[0]!, ) get rootClass(): string { return cn('w-full space-y-6', this.className) } mobileCollectionClass(id: string): string { return cn( 'focus-visible:ring-ring flex items-center gap-1.5 rounded-lg px-3 py-1.5 text-xs font-medium whitespace-nowrap transition-colors focus-visible:ring-2 focus-visible:outline-none', this.selectedCollection() === id ? 'bg-primary text-primary-foreground shadow-xs' : 'bg-muted/60 text-muted-foreground hover:bg-muted hover:text-foreground', ) } mobileCollectionCountClass(id: string): string { return cn( 'py-0.2 rounded-full px-1.5 font-mono text-xs tabular-nums', this.selectedCollection() === id ? 'bg-primary-foreground/20 text-primary-foreground' : 'bg-background/80 text-muted-foreground', ) } sidebarCollectionClass(id: string): string { return cn( 'focus-visible:ring-ring flex w-full items-center justify-between rounded-lg px-3 py-2 text-xs font-medium transition-[color,background-color,box-shadow,transform] duration-150 focus-visible:ring-2 focus-visible:outline-none active:scale-[0.98]', this.selectedCollection() === id ? 'bg-primary/10 text-primary border-primary/20 border font-semibold shadow-xs' : 'text-muted-foreground hover:bg-muted/70 hover:text-foreground border border-transparent', ) } sidebarCollectionCountClass(id: string): string { return cn( 'rounded-md px-1.5 py-0.5 font-mono text-xs tabular-nums', this.selectedCollection() === id ? 'bg-primary/20 text-primary font-bold' : 'bg-muted/60 text-muted-foreground', ) } tagCloudClass(tag: string): string { return cn( 'focus-visible:ring-ring min-h-6 rounded-md border px-2 py-0.5 text-xs font-medium transition-[colors,transform] duration-150 focus-visible:ring-2 focus-visible:outline-none active:scale-95', this.selectedTag() === tag ? 'border-primary bg-primary/10 text-primary font-semibold shadow-xs' : 'border-border bg-background text-muted-foreground hover:border-foreground/30 hover:text-foreground', ) } tagPillClass(tag: string): string { return cn( 'min-h-6 rounded-md px-2 py-0.5 text-xs font-medium transition-[colors,transform] duration-150 active:scale-95', this.selectedTag() === tag ? 'bg-primary text-primary-foreground font-semibold' : 'bg-muted/70 text-muted-foreground hover:bg-muted hover:text-foreground', ) } favoriteButtonClass(isFavorite: boolean): string { return cn( 'bg-background border-border/80 focus-visible:ring-ring absolute top-2.5 right-2.5 grid size-7 place-items-center rounded-full border shadow-xs transition-transform duration-150 hover:scale-105 focus-visible:ring-2 focus-visible:outline-none active:scale-90', isFavorite ? 'text-warning' : 'text-muted-foreground hover:text-warning', ) } favoriteIconClass(isFavorite: boolean): string { return cn('size-4', isFavorite ? 'fill-warning text-warning' : '') } showNotification(msg: string): void { this.notificationToast.set(msg) clearTimeout(this.toastTimer) this.toastTimer = setTimeout(() => this.notificationToast.set(null), 2800) } async copyUrl(item: BookmarkItem, event?: MouseEvent): Promise<void> { event?.stopPropagation() try { await navigator.clipboard?.writeText(item.url) } catch { // Clipboard fallback } this.copiedId.set(item.id) this.showNotification(`Copied link to clipboard: ${item.domain}`) clearTimeout(this.copyTimer) this.copyTimer = setTimeout(() => this.copiedId.set(null), 2000) } toggleFavorite(item: BookmarkItem, event?: MouseEvent): void { event?.stopPropagation() this.bookmarks.update((prev) => prev.map((bm) => { if (bm.id !== item.id) return bm const nextFav = !bm.isFavorite this.showNotification(nextFav ? `Starred "${bm.title}"` : `Removed "${bm.title}" from favorites`) return { ...bm, isFavorite: nextFav } }), ) } toggleTag(tag: string): void { this.selectedTag.update((prev) => (prev === tag ? null : tag)) } resetFilters(): void { this.searchQuery.set('') this.selectedCollection.set('all') this.selectedTag.set(null) } setFormCollection(value: string): void { if ((COLLECTION_IDS as string[]).includes(value)) this.formCollection.set(value as BookmarkCollection) } setPresetUrl(url: string): void { this.formUrl.set(url) this.autoFetchMetadata() } autoFetchMetadata(): void { const url = this.formUrl().trim() if (!url) { this.showNotification('Please enter a URL first') return } let extractedDomain = '' try { const parsed = new URL(url.startsWith('http') ? url : `https://${url}`) extractedDomain = parsed.hostname.replace(/^www\./, '') } catch { extractedDomain = 'developer.mozilla.org' } this.formDomain.set(extractedDomain) if (url.includes('github') || extractedDomain.includes('github')) { this.formTitle.set('GitHub Engineering Architecture Guides') this.formExcerpt.set( 'In-depth architectural post-mortems, system scaling principles, and resilient CI/CD pipelines.', ) this.formCollection.set('web-perf') this.formTags.set('#web-perf, #security') this.formPreview.set( 'https://images.unsplash.com/photo-1618401471353-b98afee0b2eb?w=800&auto=format&fit=crop&q=80', ) } else if (url.includes('astro') || extractedDomain.includes('astro')) { this.formTitle.set('Astro 4.0 Server Islands & SSG Docs') this.formExcerpt.set( 'Zero-JS by default frontend framework combining island architecture with fast static HTML builds.', ) this.formCollection.set('design-systems') this.formTags.set('#astro, #react, #vue') this.formPreview.set('https://images.unsplash.com/photo-1555066931-4365d14bab8c?w=800&auto=format&fit=crop&q=80') } else if (url.includes('linear') || extractedDomain.includes('linear')) { this.formTitle.set('Linear Method — Principles for Modern Software') this.formExcerpt.set( 'Practices and philosophies for high-craft product execution, issue triage, and keyboard-first UI.', ) this.formCollection.set('design-systems') this.formTags.set('#design-systems, #tailwind') this.formPreview.set('https://images.unsplash.com/photo-1551288049-bebda4e38f71?w=800&auto=format&fit=crop&q=80') } else { if (!this.formTitle()) this.formTitle.set(`${extractedDomain.toUpperCase()} — Resource Guide`) if (!this.formExcerpt()) this.formExcerpt.set('Curated documentation and developer tooling reference for modern engineering workflows.') if (!this.formTags()) this.formTags.set('#oklch, #tailwind') if (!this.formPreview()) this.formPreview.set( 'https://images.unsplash.com/photo-1526374965328-7f61d4dc18c5?w=800&auto=format&fit=crop&q=80', ) } this.showNotification(`Fetched metadata from ${extractedDomain}`) } handleSaveBookmark(event?: Event): void { event?.preventDefault() if (!this.formUrl().trim()) { this.showNotification('Please enter a valid URL') return } const finalTitle = this.formTitle().trim() || this.formDomain().trim() || 'Untitled Bookmark' let cleanDomain = this.formDomain().trim() if (!cleanDomain) { try { const raw = this.formUrl() const parsed = new URL(raw.startsWith('http') ? raw : `https://${raw}`) cleanDomain = parsed.hostname.replace(/^www\./, '') } catch { cleanDomain = 'web.dev' } } const rawTags = this.formTags() const tagList = rawTags ? rawTags .split(',') .map((t) => t.trim()) .filter(Boolean) .map((t) => (t.startsWith('#') ? t : `#${t}`)) : ['#design-systems'] const rawUrl = this.formUrl() const newBookmark: BookmarkItem = { id: `bm-${Date.now()}`, title: finalTitle, url: rawUrl.startsWith('http') ? rawUrl : `https://${rawUrl}`, domain: cleanDomain, faviconUrl: 'https://images.unsplash.com/photo-1618005182384-a83a8bd57fbe?w=64&auto=format&fit=crop&q=80', previewImage: this.formPreview() || 'https://images.unsplash.com/photo-1541701494587-cb58502866ab?w=800&auto=format&fit=crop&q=80', excerpt: this.formExcerpt().trim() || 'Developer documentation, components, and reference guides.', collection: this.formCollection(), tags: tagList, isFavorite: false, savedAt: 'Just now', } this.bookmarks.update((prev) => [newBookmark, ...prev]) this.isAddModalOpen.set(false) this.formUrl.set('') this.formTitle.set('') this.formDomain.set('') this.formExcerpt.set('') this.formTags.set('') this.formPreview.set('') this.showNotification(`Saved "${finalTitle}" to bookmarks!`) } }src/app/components/blocks/bookmark-manager-grid/index.ts0.2 kB
export { UiBookmarkManagerGridComponent, type BookmarkCollection, type BookmarkItem, type CollectionMeta, } from './bookmark-manager-grid.component'
Raw manifest:https://uipkge.dev/r/angular/bookmark-manager-grid.json