Menu

Change language

GitHub repo

Media Asset Gallery

blockmedia

Figma/Unsplash style Digital Asset Manager (DAM) with resolution tags, aspect ratio filters, tag chips, color palette inspector, multi-resolution download picker, and slide-over inspector drawer.

Installation

$npx uipkge-ng@latest add media-asset-gallery
Manifest:https://uipkge.dev/r/angular/media-asset-gallery.jsonInstalls to:src/app/components/blocks/media-asset-gallery/

Variants

Loading interactive previews…

Schema

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

AssetItem
interface AssetItem {
  id: string
  title: string
  filename: string
  category: 'all' | 'images' | 'vectors' | 'videos' | 'logos'
  type: 'image' | 'vector' | 'video' | 'logo'
  format: string
  aspectRatio: '16:9' | '1:1' | '9:16' | 'vector'
  aspectRatioLabel: string
  resolutionBadge: string
  dimensions: string
  fileSize: string
  uploadedBy: { name: string; role: string; avatarInitials: string }
  uploadDate: string
  license: string
  colorSpace: string
  palette: string[]
  tags: string[]
  previewUrl: string
  downloadOptions: { id: string; label: string; resolution: string; size: string; badge?: string }[]
}

Files installed (2)

  • src/app/components/blocks/media-asset-gallery/media-asset-gallery.component.ts37.3 kB
    // Figma/Unsplash style Digital Asset Manager (DAM) with resolution tags, aspect ratio
    // filters, tag chips, color palette inspector, multi-resolution download picker, and
    // slide-over inspector drawer. Port of the React block 1:1.
    import { ChangeDetectionStrategy, Component, Input, computed, signal } from '@angular/core'
    import {
      ArrowDownToLine,
      Check,
      CheckCircle2,
      Code2,
      Copy,
      ExternalLink,
      Eye,
      FileCode,
      HardDrive,
      Image as ImageIcon,
      Layers,
      LucideAngularModule,
      Palette,
      Search,
      Share2,
      Shapes,
      Tag,
      Upload,
      Video,
      X,
    } 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 { UiInputComponent } from '@/ui/input/input.component'
    import { UiSeparatorComponent } from '@/ui/separator/separator.component'
    import {
      UiSheetCloseComponent,
      UiSheetComponent,
      UiSheetContentComponent,
      UiSheetDescriptionComponent,
      UiSheetFooterComponent,
      UiSheetHeaderComponent,
      UiSheetTitleComponent,
    } from '@/ui/sheet/sheet.component'
    import { UiTabsComponent, UiTabsListComponent, UiTabsTriggerComponent } from '@/ui/tabs/tabs.component'
    
    export interface AssetItem {
      id: string
      title: string
      filename: string
      category: 'all' | 'images' | 'vectors' | 'videos' | 'logos'
      type: 'image' | 'vector' | 'video' | 'logo'
      format: string
      aspectRatio: '16:9' | '1:1' | '9:16' | 'vector'
      aspectRatioLabel: string
      resolutionBadge: string
      dimensions: string
      fileSize: string
      uploadedBy: { name: string; role: string; avatarInitials: string }
      uploadDate: string
      license: string
      colorSpace: string
      palette: string[]
      tags: string[]
      previewUrl: string
      downloadOptions: { id: string; label: string; resolution: string; size: string; badge?: string }[]
    }
    
    export const DEFAULT_ASSETS: AssetItem[] = [
      {
        id: 'dam-01',
        title: 'Hyperion Cloud UI Design System',
        filename: 'hyperion-cloud-design-system-4k.png',
        category: 'images',
        type: 'image',
        format: 'PNG',
        aspectRatio: '16:9',
        aspectRatioLabel: '16:9 Landscape',
        resolutionBadge: '4K',
        dimensions: '3840 × 2160',
        fileSize: '2.4 MB',
        uploadedBy: { name: 'Elena Vance', role: 'Brand Architecture Lead', avatarInitials: 'EV' },
        uploadDate: 'Aug 18, 2026',
        license: 'Enterprise Commercial',
        colorSpace: 'Display P3 · 300 DPI',
        palette: ['#0F172A', '#2563EB', '#38BDF8', '#10B981', '#F8FAFC'],
        tags: ['#marketing', '#product-ui'],
        previewUrl: 'https://images.unsplash.com/photo-1618005182384-a83a8bd57fbe?w=1200&auto=format&fit=crop&q=80',
        downloadOptions: [
          { id: 'opt-1', label: 'Original Master (4K)', resolution: '3840 × 2160', size: '2.4 MB', badge: 'Lossless' },
          { id: 'opt-2', label: 'Web Optimized (1080p)', resolution: '1920 × 1080', size: '640 KB', badge: 'Recommended' },
          { id: 'opt-3', label: 'Mobile Thumbnail', resolution: '640 × 360', size: '92 KB' },
        ],
      },
      {
        id: 'dam-02',
        title: 'NeoCorp Brand Identity & Vector Glyphs',
        filename: 'neocorp-brand-vector-glyphs.svg',
        category: 'vectors',
        type: 'vector',
        format: 'SVG',
        aspectRatio: 'vector',
        aspectRatioLabel: 'Raw Vector',
        resolutionBadge: 'Vector',
        dimensions: 'Vector Bezier',
        fileSize: '420 KB',
        uploadedBy: { name: 'Marcus Chen', role: 'Principal Iconographer', avatarInitials: 'MC' },
        uploadDate: 'Aug 15, 2026',
        license: 'Internal Brand Core',
        colorSpace: 'sRGB · Vector Paths',
        palette: ['#4F46E5', '#7C3AED', '#EC4899', '#312E81', '#FFFFFF'],
        tags: ['#brand-guidelines', '#marketing'],
        previewUrl: 'https://images.unsplash.com/photo-1634017839464-5c339ebe3cb4?w=1200&auto=format&fit=crop&q=80',
        downloadOptions: [
          { id: 'opt-1', label: 'Master SVG Vector', resolution: 'Scalable Bezier', size: '420 KB', badge: 'Source' },
          { id: 'opt-2', label: 'PNG Hi-Res Raster', resolution: '4096 × 4096', size: '1.1 MB' },
          { id: 'opt-3', label: 'PDF Vector Spec', resolution: 'Vector PDF', size: '850 KB' },
        ],
      },
      {
        id: 'dam-03',
        title: 'Solaris OS 2.0 Product Launch Teaser',
        filename: 'solaris-os-launch-teaser-h265.mp4',
        category: 'videos',
        type: 'video',
        format: 'MP4',
        aspectRatio: '16:9',
        aspectRatioLabel: '16:9 Landscape',
        resolutionBadge: '4K',
        dimensions: '3840 × 2160',
        fileSize: '18.6 MB',
        uploadedBy: { name: 'Sarah Jenkins', role: 'Motion Art Director', avatarInitials: 'SJ' },
        uploadDate: 'Aug 12, 2026',
        license: 'Global Broadcast Sync',
        colorSpace: 'Rec. 709 · 60 FPS ProRes',
        palette: ['#18181B', '#EA580C', '#F59E0B', '#06B6D4', '#FAFAFA'],
        tags: ['#marketing', '#product-ui'],
        previewUrl: 'https://images.unsplash.com/photo-1550745165-9bc0b252726f?w=1200&auto=format&fit=crop&q=80',
        downloadOptions: [
          {
            id: 'opt-1',
            label: 'Master 4K Video (ProRes)',
            resolution: '3840 × 2160 · 60fps',
            size: '18.6 MB',
            badge: 'Cinema',
          },
          { id: 'opt-2', label: 'Web Stream MP4 (1080p)', resolution: '1920 × 1080 · 30fps', size: '4.8 MB', badge: 'Web' },
          { id: 'opt-3', label: 'Poster Frame Keyframe', resolution: '3840 × 2160 PNG', size: '1.9 MB' },
        ],
      },
      {
        id: 'dam-04',
        title: 'Executive Leadership — Studio Headshot 01',
        filename: 'alex-morgan-executive-portrait.jpg',
        category: 'images',
        type: 'image',
        format: 'JPG',
        aspectRatio: '1:1',
        aspectRatioLabel: '1:1 Square',
        resolutionBadge: '2K',
        dimensions: '2048 × 2048',
        fileSize: '1.8 MB',
        uploadedBy: { name: 'David Kim', role: 'Staff Photographer', avatarInitials: 'DK' },
        uploadDate: 'Aug 10, 2026',
        license: 'Press Kit Editorial',
        colorSpace: 'Adobe RGB (1998) · 300 DPI',
        palette: ['#0F172A', '#334155', '#94A3B8', '#0EA5E9', '#F8FAFC'],
        tags: ['#headshots', '#brand-guidelines'],
        previewUrl: 'https://images.unsplash.com/photo-1534528741775-53994a69daeb?w=1200&auto=format&fit=crop&q=80',
        downloadOptions: [
          { id: 'opt-1', label: 'Full Resolution (2K RAW)', resolution: '2048 × 2048', size: '1.8 MB', badge: 'Print' },
          { id: 'opt-2', label: 'Square Avatar Web', resolution: '800 × 800', size: '210 KB', badge: 'Web' },
          { id: 'opt-3', label: 'Compressed WebP', resolution: '400 × 400', size: '45 KB' },
        ],
      },
      {
        id: 'dam-05',
        title: 'Mobile App Onboarding Carousel — Portrait',
        filename: 'mobile-onboarding-mockup-9-16.png',
        category: 'images',
        type: 'image',
        format: 'PNG',
        aspectRatio: '9:16',
        aspectRatioLabel: '9:16 Portrait',
        resolutionBadge: '1080p',
        dimensions: '1080 × 1920',
        fileSize: '1.2 MB',
        uploadedBy: { name: 'Aria Rodriguez', role: 'Mobile UX Architect', avatarInitials: 'AR' },
        uploadDate: 'Aug 08, 2026',
        license: 'App Store / Marketing',
        colorSpace: 'Display P3 · 460 PPI',
        palette: ['#09090B', '#7C3AED', '#C026D3', '#38BDF8', '#FFFFFF'],
        tags: ['#product-ui', '#marketing'],
        previewUrl: 'https://images.unsplash.com/photo-1512941937669-90a1b58e7e9c?w=1200&auto=format&fit=crop&q=80',
        downloadOptions: [
          { id: 'opt-1', label: 'High Density 9:16 PNG', resolution: '1080 × 1920', size: '1.2 MB', badge: 'Retina' },
          { id: 'opt-2', label: 'Standard Mobile Display', resolution: '720 × 1280', size: '420 KB' },
          { id: 'opt-3', label: 'Story Template JPEG', resolution: '1080 × 1920', size: '280 KB' },
        ],
      },
      {
        id: 'dam-06',
        title: 'UIPKGE Monogram & Wordmark Master Kit',
        filename: 'uipkge-monogram-symbol-master.svg',
        category: 'logos',
        type: 'logo',
        format: 'SVG',
        aspectRatio: '1:1',
        aspectRatioLabel: '1:1 Square',
        resolutionBadge: 'Vector',
        dimensions: '1024 × 1024',
        fileSize: '180 KB',
        uploadedBy: { name: 'Elena Vance', role: 'Brand Architecture Lead', avatarInitials: 'EV' },
        uploadDate: 'Aug 04, 2026',
        license: 'Master Trademark',
        colorSpace: 'Pantone Solid Coated · Vector',
        palette: ['#000000', '#059669', '#047857', '#34D399', '#FFFFFF'],
        tags: ['#brand-guidelines'],
        previewUrl: 'https://images.unsplash.com/photo-1620641788421-7a1c342ea42e?w=1200&auto=format&fit=crop&q=80',
        downloadOptions: [
          { id: 'opt-1', label: 'SVG Master Vector', resolution: 'Scalable Vector', size: '180 KB', badge: 'Primary' },
          { id: 'opt-2', label: 'Transparent PNG Kit', resolution: '2048 × 2048', size: '340 KB' },
          { id: 'opt-3', label: 'Favicon & App Icon Bundle', resolution: 'Multi-Res .ICO', size: '95 KB' },
        ],
      },
    ]
    
    const aspectFilterOptions = [
      { value: 'all', label: 'All Ratios' },
      { value: '16:9', label: '16:9 Landscape' },
      { value: '1:1', label: '1:1 Square' },
      { value: '9:16', label: '9:16 Portrait' },
      { value: 'vector', label: 'Raw Vector' },
    ]
    
    const availableTags = ['#marketing', '#product-ui', '#brand-guidelines', '#headshots']
    
    @Component({
      changeDetection: ChangeDetectionStrategy.Eager,
      selector: 'ui-media-asset-gallery, [ui-media-asset-gallery]',
      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,
        UiInputComponent,
        UiSeparatorComponent,
        UiSheetCloseComponent,
        UiSheetComponent,
        UiSheetContentComponent,
        UiSheetDescriptionComponent,
        UiSheetFooterComponent,
        UiSheetHeaderComponent,
        UiSheetTitleComponent,
        UiTabsComponent,
        UiTabsListComponent,
        UiTabsTriggerComponent,
      ],
      template: `
        <div data-slot="media-asset-gallery" [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">
              <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">
                  <lucide-icon [img]="Layers" class="size-5" />
                </div>
                <h1 class="text-xl font-bold tracking-tight sm:text-2xl">Digital Asset Management (DAM)</h1>
                <span ui-badge variant="secondary" class="font-mono text-xs"> v2.4 </span>
              </div>
              <p class="text-muted-foreground flex flex-wrap items-center gap-2 text-xs">
                <lucide-icon [img]="HardDrive" class="size-3.5" />
                <span class="font-medium">Total Assets:</span>
                <span class="text-foreground font-semibold tabular-nums">1,420 media files · 24.8 GB</span>
                <span class="text-border hidden sm:inline">|</span>
                <span class="text-muted-foreground hidden sm:inline">Cloud 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">
              <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 assets or 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>
    
              <button
                ui-button
                size="sm"
                class="shrink-0 font-medium"
                (click)="showNotification('Upload dialog ready: Drop media files to ingest.')"
              >
                <lucide-icon [img]="Upload" class="size-4" />
                Upload Media
              </button>
            </div>
          </div>
    
          <!-- Filter Toolbar -->
          <div class="bg-card text-card-foreground border-border/80 space-y-4 rounded-2xl border p-4 shadow-xs sm:p-5">
            <div class="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
              <ui-tabs [value]="selectedCategory()" (valueChange)="setCategory($event)" class="w-full lg:w-auto">
                <ui-tabs-list class="grid w-full grid-cols-2 gap-1 sm:grid-cols-5 lg:w-auto">
                  <button ui-tabs-trigger value="all" class="text-xs">All Assets</button>
                  <button ui-tabs-trigger value="images" class="text-xs">
                    <lucide-icon [img]="ImageIcon" class="mr-1.5 hidden size-3.5 sm:inline" />
                    Images
                  </button>
                  <button ui-tabs-trigger value="vectors" class="text-xs">
                    <lucide-icon [img]="FileCode" class="mr-1.5 hidden size-3.5 sm:inline" />
                    Vectors & Icons
                  </button>
                  <button ui-tabs-trigger value="videos" class="text-xs">
                    <lucide-icon [img]="Video" class="mr-1.5 hidden size-3.5 sm:inline" />
                    Videos
                  </button>
                  <button ui-tabs-trigger value="logos" class="text-xs">
                    <lucide-icon [img]="Shapes" class="mr-1.5 hidden size-3.5 sm:inline" />
                    Brand Logos
                  </button>
                </ui-tabs-list>
              </ui-tabs>
    
              <div class="flex flex-wrap items-center gap-1.5 overflow-x-auto pb-1 sm:pb-0">
                <span class="text-muted-foreground mr-1 text-xs font-medium">Ratio:</span>
                @for (opt of aspectOptions; track opt.value) {
                  <button type="button" [class]="ratioPillClass(opt.value)" (click)="selectedAspectRatio.set(opt.value)">
                    {{ opt.label }}
                  </button>
                }
              </div>
            </div>
    
            <div ui-separator class="bg-border/60"></div>
    
            <div class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
              <div class="flex flex-wrap items-center gap-2">
                <span class="text-muted-foreground flex items-center gap-1 text-xs font-medium">
                  <lucide-icon [img]="Tag" class="size-3.5" />
                  Tags:
                </span>
                @for (tag of tags; track tag) {
                  <button type="button" [class]="tagChipClass(tag)" (click)="toggleTag(tag)">{{ tag }}</button>
                }
                @if (isFiltered()) {
                  <button
                    type="button"
                    class="text-muted-foreground hover:text-foreground ml-1 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">{{ filteredAssets().length }}</span> of
                {{ assets.length }} assets
              </div>
            </div>
          </div>
    
          <!-- Asset Grid -->
          @if (filteredAssets().length > 0) {
            <div class="grid grid-cols-1 gap-5 md:grid-cols-2 lg:grid-cols-3">
              @for (asset of filteredAssets(); track asset.id) {
                <div
                  ui-card
                  class="border-border/80 bg-card hover:border-border group relative flex flex-col overflow-hidden rounded-xl border p-0 shadow-xs transition-colors duration-200 hover:shadow-md"
                  (click)="openInspector(asset)"
                >
                  <div class="bg-muted/40 relative aspect-video w-full cursor-pointer overflow-hidden">
                    <img
                      [src]="asset.previewUrl"
                      [alt]="asset.title"
                      loading="lazy"
                      class="size-full object-cover transition-transform duration-300 group-hover:scale-105"
                    />
                    <div
                      class="absolute inset-0 bg-gradient-to-t from-black/70 via-transparent to-black/30 opacity-0 transition-opacity duration-200 group-hover:opacity-100"
                    ></div>
                    <div class="absolute top-2.5 left-2.5 flex flex-wrap items-center gap-1.5">
                      <span
                        ui-badge
                        variant="secondary"
                        class="bg-background/90 text-foreground font-mono text-xs shadow-xs backdrop-blur-xs"
                      >
                        {{ asset.aspectRatio }}
                      </span>
                      <span
                        ui-badge
                        variant="outline"
                        class="bg-background/90 text-foreground font-mono text-xs shadow-xs backdrop-blur-xs"
                      >
                        {{ asset.resolutionBadge }}
                      </span>
                    </div>
                    <div class="absolute top-2.5 right-2.5">
                      <span ui-badge class="bg-primary/95 text-primary-foreground font-mono text-xs shadow-xs">
                        {{ asset.format }}
                      </span>
                    </div>
                    <div
                      class="absolute right-2.5 bottom-2.5 left-2.5 flex items-center justify-between opacity-0 transition-opacity duration-200 group-hover:opacity-100"
                    >
                      <div class="flex items-center gap-1 text-xs font-medium text-white drop-shadow-xs">
                        <lucide-icon [img]="Eye" class="size-3.5" />
                        <span>Inspect</span>
                      </div>
                      <div class="flex items-center gap-1.5">
                        <button
                          ui-button
                          variant="secondary"
                          size="icon-xs"
                          class="size-7 bg-white/90 text-zinc-900 shadow-xs hover:bg-white"
                          title="Copy asset link"
                          aria-label="Copy asset link"
                          (click)="copyAssetLink(asset, $event)"
                        >
                          <lucide-icon [img]="Copy" class="size-3.5" />
                        </button>
                        <button
                          ui-button
                          variant="default"
                          size="icon-xs"
                          class="size-7 shadow-xs"
                          title="Instant download"
                          aria-label="Instant download"
                          (click)="triggerInstantDownload(asset, $event)"
                        >
                          <lucide-icon [img]="ArrowDownToLine" class="size-3.5" />
                        </button>
                      </div>
                    </div>
                  </div>
    
                  <div ui-card-content class="flex flex-1 flex-col justify-between gap-3 p-4">
                    <div class="space-y-1.5">
                      <div class="flex items-start justify-between gap-2">
                        <h2
                          class="group-hover:text-primary line-clamp-1 text-sm font-semibold tracking-tight transition-colors"
                        >
                          {{ asset.title }}
                        </h2>
                      </div>
                      <p class="text-muted-foreground truncate font-mono text-xs">{{ asset.filename }}</p>
                    </div>
                    <div class="flex flex-wrap items-center gap-1.5">
                      @for (t of asset.tags; track t) {
                        <span class="bg-muted/70 text-muted-foreground rounded-md px-2 py-0.5 text-xs font-medium">
                          {{ t }}
                        </span>
                      }
                    </div>
                    <div class="border-border/60 flex items-center justify-between border-t pt-3 text-xs">
                      <span class="text-muted-foreground font-mono tabular-nums">{{ asset.dimensions }}</span>
                      <span class="text-foreground font-semibold tabular-nums"
                        >{{ asset.fileSize }} · {{ asset.format }}</span
                      >
                    </div>
                  </div>
                </div>
              }
            </div>
          } @else {
            <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]="Search" class="size-6" />
              </div>
              <h2 class="text-base font-semibold">No assets found</h2>
              <p class="text-muted-foreground mt-1 max-w-sm text-xs">
                No media assets match your active category, aspect ratio, or search query. Try clearing filters to view all
                assets.
              </p>
              <button ui-button variant="outline" size="sm" class="mt-2 text-xs" (click)="resetFilters()">
                Clear All Filters
              </button>
            </div>
          }
    
          <!-- Asset Detail Inspector Drawer (Sheet) -->
          <div ui-sheet [open]="isDrawerOpen()" (openChange)="isDrawerOpen.set($event)">
            <div
              ui-sheet-content
              class="border-border bg-card text-card-foreground w-full space-y-6 overflow-y-auto p-6 sm:max-w-md lg:max-w-lg"
            >
              @if (selectedAsset(); as asset) {
                <div ui-sheet-header class="space-y-1 text-left">
                  <div class="flex items-center gap-2">
                    <span ui-badge class="bg-primary/95 text-primary-foreground font-mono text-xs">{{ asset.format }}</span>
                    <span ui-badge variant="outline" class="font-mono text-xs">{{ asset.aspectRatioLabel }}</span>
                    <span ui-badge variant="secondary" class="font-mono text-xs">{{ asset.resolutionBadge }}</span>
                  </div>
                  <h2 ui-sheet-title class="text-lg font-bold tracking-tight">{{ asset.title }}</h2>
                  <p ui-sheet-description class="text-muted-foreground truncate font-mono text-xs">{{ asset.filename }}</p>
                </div>
    
                <div class="border-border/80 bg-muted/40 relative overflow-hidden rounded-xl border">
                  <img [src]="asset.previewUrl" [alt]="asset.title" class="aspect-video w-full object-cover" />
                  <div
                    class="bg-card/90 border-border/80 absolute right-2 bottom-2 flex items-center gap-1 rounded-lg border p-1 backdrop-blur-xs"
                  >
                    <button
                      ui-button
                      variant="ghost"
                      size="icon-xs"
                      class="size-6 text-xs"
                      title="Copy Direct URL"
                      (click)="copyAssetLink(asset)"
                    >
                      <lucide-icon [img]="Copy" class="size-3" />
                    </button>
                    <a
                      ui-button
                      variant="ghost"
                      size="icon-xs"
                      class="size-6 text-xs"
                      title="Open preview in new tab"
                      [href]="asset.previewUrl"
                      target="_blank"
                      rel="noreferrer"
                    >
                      <lucide-icon [img]="ExternalLink" class="size-3" />
                    </a>
                  </div>
                </div>
    
                <div class="space-y-2.5">
                  <div class="flex items-center justify-between text-xs">
                    <span class="text-foreground flex items-center gap-1.5 font-semibold">
                      <lucide-icon [img]="Palette" class="text-primary size-3.5" />
                      Color Palette Swatches
                    </span>
                    <span class="text-muted-foreground text-xs">Click swatch to copy HEX</span>
                  </div>
                  <div class="grid grid-cols-5 gap-2">
                    @for (color of asset.palette; track color) {
                      <button
                        type="button"
                        class="group/color border-border/60 hover:border-primary focus-visible:ring-ring flex flex-col items-center gap-1.5 rounded-lg border p-2 text-center transition-colors focus-visible:ring-2 focus-visible:outline-none"
                        (click)="copyHex(color)"
                      >
                        <span
                          class="size-7 rounded-md border border-black/10 shadow-inner transition-transform group-hover/color:scale-110"
                          [style.backgroundColor]="color"
                        ></span>
                        <span class="text-muted-foreground group-hover/color:text-foreground font-mono text-xs uppercase">
                          {{ color }}
                        </span>
                      </button>
                    }
                  </div>
                </div>
    
                <div ui-separator class="bg-border/60"></div>
    
                <div class="space-y-3">
                  <h3 class="text-foreground text-xs font-semibold tracking-wider uppercase">Asset Metadata</h3>
                  <div class="bg-muted/40 border-border/60 grid grid-cols-2 gap-3 rounded-xl border p-3.5 text-xs">
                    <div>
                      <span class="text-muted-foreground block text-xs">Dimensions</span>
                      <span class="text-foreground font-mono font-medium tabular-nums">{{ asset.dimensions }}</span>
                    </div>
                    <div>
                      <span class="text-muted-foreground block text-xs">File Size</span>
                      <span class="text-foreground font-semibold tabular-nums">{{ asset.fileSize }}</span>
                    </div>
                    <div>
                      <span class="text-muted-foreground block text-xs">Color Space</span>
                      <span class="text-foreground font-medium">{{ asset.colorSpace }}</span>
                    </div>
                    <div>
                      <span class="text-muted-foreground block text-xs">License</span>
                      <span class="text-foreground font-medium">{{ asset.license }}</span>
                    </div>
                    <div>
                      <span class="text-muted-foreground block text-xs">Uploaded By</span>
                      <div class="mt-0.5 flex items-center gap-1.5">
                        <span
                          class="bg-primary/10 text-primary grid size-4.5 place-items-center rounded-full text-xs font-semibold"
                        >
                          {{ asset.uploadedBy.avatarInitials }}
                        </span>
                        <span class="text-foreground font-medium">{{ asset.uploadedBy.name }}</span>
                      </div>
                    </div>
                    <div>
                      <span class="text-muted-foreground block text-xs">Upload Date</span>
                      <span class="text-foreground font-medium tabular-nums">{{ asset.uploadDate }}</span>
                    </div>
                  </div>
                </div>
    
                <div class="space-y-3">
                  <div class="flex items-center justify-between">
                    <h3 class="text-foreground text-xs font-semibold tracking-wider uppercase">Download Resolution</h3>
                    <span class="text-muted-foreground font-mono text-xs">Format: {{ asset.format }}</span>
                  </div>
                  <div class="space-y-2">
                    @for (opt of asset.downloadOptions; track opt.id) {
                      <button
                        type="button"
                        [class]="downloadOptionClass(opt.id)"
                        (click)="selectedResolutionOption.set(opt.id)"
                      >
                        <div class="space-y-0.5">
                          <div class="flex items-center gap-2">
                            <span class="text-foreground text-xs font-semibold">{{ opt.label }}</span>
                            @if (opt.badge) {
                              <span ui-badge variant="secondary" class="px-1.5 py-0 text-xs font-normal">
                                {{ opt.badge }}
                              </span>
                            }
                          </div>
                          <span class="text-muted-foreground font-mono text-xs tabular-nums">{{ opt.resolution }}</span>
                        </div>
                        <div class="flex items-center gap-3">
                          <span class="text-foreground text-xs font-semibold tabular-nums">{{ opt.size }}</span>
                          <span [class]="downloadRadioClass(opt.id)">
                            @if (selectedResolutionOption() === opt.id) {
                              <lucide-icon [img]="Check" class="size-2.5" />
                            }
                          </span>
                        </div>
                      </button>
                    }
                  </div>
                </div>
    
                <div class="space-y-2">
                  <div class="flex items-center justify-between text-xs">
                    <span class="text-foreground flex items-center gap-1.5 font-semibold">
                      <lucide-icon [img]="Code2" class="text-primary size-3.5" />
                      HTML CDN Embed Snippet
                    </span>
                    <button
                      type="button"
                      class="text-primary flex min-h-6 items-center gap-1 text-xs font-medium hover:underline"
                      (click)="copyEmbedSnippet(asset)"
                    >
                      <lucide-icon [img]="Copy" class="size-3" />
                      <span>{{ copiedEmbed() ? 'Copied!' : 'Copy Code' }}</span>
                    </button>
                  </div>
                  <div
                    class="bg-muted/60 border-border/80 text-muted-foreground overflow-x-auto rounded-xl border p-2.5 font-mono text-xs"
                  >
                    &lt;img src="{{ asset.previewUrl }}" alt="{{ asset.title }}" loading="lazy" /&gt;
                  </div>
                </div>
    
                <div
                  ui-sheet-footer
                  class="border-border/80 flex flex-col-reverse gap-2 border-t pt-4 sm:flex-row sm:justify-end"
                >
                  <div ui-sheet-close>
                    <button ui-button variant="outline" size="sm" class="text-xs font-medium">Close</button>
                  </div>
                  <button ui-button variant="outline" size="sm" class="text-xs font-medium" (click)="copyAssetLink(asset)">
                    <lucide-icon [img]="Share2" class="size-3.5" />
                    Share Link
                  </button>
                  <button
                    ui-button
                    aria-label="Download attachment"
                    size="sm"
                    class="text-xs font-medium"
                    (click)="triggerInstantDownload(asset)"
                  >
                    <lucide-icon [img]="ArrowDownToLine" class="size-3.5" />
                    Download Asset
                  </button>
                </div>
              }
            </div>
          </div>
        </div>
      `,
    })
    export class UiMediaAssetGalleryComponent {
      protected readonly ArrowDownToLine = ArrowDownToLine
      protected readonly Check = Check
      protected readonly CheckCircle2 = CheckCircle2
      protected readonly Code2 = Code2
      protected readonly Copy = Copy
      protected readonly ExternalLink = ExternalLink
      protected readonly Eye = Eye
      protected readonly FileCode = FileCode
      protected readonly HardDrive = HardDrive
      protected readonly ImageIcon = ImageIcon
      protected readonly Layers = Layers
      protected readonly Palette = Palette
      protected readonly Search = Search
      protected readonly Share2 = Share2
      protected readonly Shapes = Shapes
      protected readonly Tag = Tag
      protected readonly Upload = Upload
      protected readonly Video = Video
      protected readonly X = X
    
      protected readonly aspectOptions = aspectFilterOptions
      protected readonly tags = availableTags
    
      @Input() set initialAssets(v: AssetItem[] | undefined) {
        if (v) this.assetsSignal.set([...v])
      }
      @Input('class') className?: string
    
      readonly assetsSignal = signal<AssetItem[]>([...DEFAULT_ASSETS])
      get assets(): AssetItem[] {
        return this.assetsSignal()
      }
    
      readonly searchQuery = signal('')
      readonly selectedCategory = signal('all')
      readonly selectedAspectRatio = signal('all')
      readonly selectedTag = signal<string | null>(null)
    
      readonly selectedAsset = signal<AssetItem | null>(null)
      readonly isDrawerOpen = signal(false)
      readonly selectedResolutionOption = signal('opt-1')
    
      readonly notificationToast = signal<string | null>(null)
      private toastTimeout: ReturnType<typeof setTimeout> | undefined
    
      readonly copiedColor = signal<string | null>(null)
      private colorTimeout: ReturnType<typeof setTimeout> | undefined
    
      readonly copiedEmbed = signal(false)
      private embedTimeout: ReturnType<typeof setTimeout> | undefined
    
      readonly filteredAssets = computed(() => {
        return this.assetsSignal().filter((item) => {
          if (this.selectedCategory() !== 'all' && item.category !== this.selectedCategory()) return false
          if (this.selectedAspectRatio() !== 'all' && item.aspectRatio !== this.selectedAspectRatio()) return false
          if (this.selectedTag() && !item.tags.includes(this.selectedTag()!)) return false
          if (this.searchQuery().trim() !== '') {
            const q = this.searchQuery().toLowerCase().trim()
            const titleMatch = item.title.toLowerCase().includes(q)
            const fileMatch = item.filename.toLowerCase().includes(q)
            const tagMatch = item.tags.some((t) => t.toLowerCase().includes(q))
            const formatMatch = item.format.toLowerCase().includes(q)
            if (!titleMatch && !fileMatch && !tagMatch && !formatMatch) return false
          }
          return true
        })
      })
    
      readonly isFiltered = computed(
        () =>
          !!this.selectedTag() ||
          this.selectedAspectRatio() !== 'all' ||
          !!this.searchQuery() ||
          this.selectedCategory() !== 'all',
      )
    
      get rootClass(): string {
        return cn('w-full space-y-6', this.className)
      }
    
      setCategory(v: string): void {
        this.selectedCategory.set(v)
      }
    
      ratioPillClass(value: string): string {
        return cn(
          'focus-visible:ring-ring min-h-6 rounded-lg px-2.5 py-1 text-xs font-medium transition-colors focus-visible:ring-2 focus-visible:outline-none',
          this.selectedAspectRatio() === value
            ? 'bg-primary text-primary-foreground shadow-xs'
            : 'bg-muted/70 text-muted-foreground hover:bg-muted hover:text-foreground',
        )
      }
    
      tagChipClass(tag: string): string {
        return cn(
          'focus-visible:ring-ring min-h-6 rounded-md border px-2.5 py-0.5 text-xs font-medium transition-colors focus-visible:ring-2 focus-visible:outline-none',
          this.selectedTag() === tag
            ? 'border-primary bg-primary/10 text-primary font-semibold'
            : 'border-border bg-background text-muted-foreground hover:border-foreground/30 hover:text-foreground',
        )
      }
    
      downloadOptionClass(id: string): string {
        return cn(
          'focus-visible:ring-ring flex w-full items-center justify-between rounded-xl border p-3 text-left transition-[background-color,box-shadow] focus-visible:ring-2 focus-visible:outline-none',
          this.selectedResolutionOption() === id
            ? 'border-primary bg-primary/5 text-foreground shadow-xs'
            : 'border-border/70 bg-card hover:bg-muted/40 text-muted-foreground',
        )
      }
    
      downloadRadioClass(id: string): string {
        return cn(
          'flex size-4 items-center justify-center rounded-full border',
          this.selectedResolutionOption() === id ? 'border-primary bg-primary text-primary-foreground' : 'border-border',
        )
      }
    
      showNotification(msg: string): void {
        if (this.toastTimeout) clearTimeout(this.toastTimeout)
        this.notificationToast.set(msg)
        this.toastTimeout = setTimeout(() => this.notificationToast.set(null), 2800)
      }
    
      copyHex(color: string): void {
        try {
          navigator.clipboard?.writeText(color)
        } catch {
          // Clipboard fallback
        }
        this.copiedColor.set(color)
        this.showNotification(`Copied color hex ${color} to clipboard`)
        if (this.colorTimeout) clearTimeout(this.colorTimeout)
        this.colorTimeout = setTimeout(() => this.copiedColor.set(null), 2000)
      }
    
      copyEmbedSnippet(asset: AssetItem): void {
        const code = `<img src="${asset.previewUrl}" alt="${asset.title}" loading="lazy" />`
        try {
          navigator.clipboard?.writeText(code)
        } catch {
          // Clipboard fallback
        }
        this.copiedEmbed.set(true)
        this.showNotification('Copied HTML embed snippet to clipboard')
        if (this.embedTimeout) clearTimeout(this.embedTimeout)
        this.embedTimeout = setTimeout(() => this.copiedEmbed.set(false), 2200)
      }
    
      copyAssetLink(asset: AssetItem, event?: Event): void {
        event?.stopPropagation()
        try {
          navigator.clipboard?.writeText(`https://cdn.uipkge.dev/assets/${asset.filename}`)
        } catch {
          // Clipboard fallback
        }
        this.showNotification(`Link copied for "${asset.title}"`)
      }
    
      triggerInstantDownload(asset: AssetItem, event?: Event): void {
        event?.stopPropagation()
        this.showNotification(`Downloading ${asset.filename} (${asset.fileSize})...`)
      }
    
      openInspector(asset: AssetItem): void {
        this.selectedAsset.set(asset)
        this.selectedResolutionOption.set(asset.downloadOptions[0]?.id || 'opt-1')
        this.isDrawerOpen.set(true)
      }
    
      toggleTag(tag: string): void {
        this.selectedTag.set(this.selectedTag() === tag ? null : tag)
      }
    
      resetFilters(): void {
        this.selectedTag.set(null)
        this.selectedAspectRatio.set('all')
        this.searchQuery.set('')
        this.selectedCategory.set('all')
      }
    }
    
  • src/app/components/blocks/media-asset-gallery/index.ts0.1 kB

Raw manifest:https://uipkge.dev/r/angular/media-asset-gallery.json