Menu

Change language

GitHub repo

Bookmark Manager Grid

blockproductivity

Raindrop 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

$npx uipkge-ng@latest add bookmark-manager-grid
Manifest:https://uipkge.dev/r/angular/bookmark-manager-grid.jsonInstalls to:src/app/components/blocks/bookmark-manager-grid/

Variants

Loading interactive previews…

Schema

Type aliases exported from this item's source. Use these to shape the data you pass in.

BookmarkItem
interface BookmarkItem {
  id: string
  title: string
  url: string
  domain: string
  faviconUrl: string
  previewImage: string
  excerpt: string
  collection: BookmarkCollection
  tags: string[]
  isFavorite: boolean
  savedAt: string
}
CollectionMeta
interface 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">&ldquo;{{ searchQuery() }}&rdquo;</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

Raw manifest:https://uipkge.dev/r/angular/bookmark-manager-grid.json