Menu

Change language

GitHub repo

Content Calendar Matrix

blockmedia

Editorial publishing calendar and content matrix with pacing KPI summary cards, channel filter tabs, month picker, publication status pipeline, author avatars, SEO keywords, and contextual action menus.

Installation

$npx uipkge-ng@latest add content-calendar-matrix
Manifest:https://uipkge.dev/r/angular/content-calendar-matrix.jsonInstalls to:src/app/components/blocks/content-calendar-matrix/

Variants

Loading interactive previews…

Schema

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

ContentItem
interface ContentItem {
  id: string
  title: string
  channel: 'blog' | 'newsletter' | 'youtube' | 'social'
  channelLabel: string
  publishDate: string
  publishTime: string
  author: { name: string; role: string; avatar: string }
  status: ContentStatus
  statusLabel: string
  keywords: string[]
  format: string
}

Files installed (2)

  • src/app/components/blocks/content-calendar-matrix/content-calendar-matrix.component.ts23 kB
    // Editorial publishing calendar and content matrix with pacing KPI summary cards,
    // channel filter tabs, month picker, publication status pipeline, author avatars, SEO
    // keywords, and contextual action menus. Port of the React block 1:1.
    import { ChangeDetectionStrategy, Component, Input, computed, signal } from '@angular/core'
    import {
      Calendar,
      CalendarCheck,
      CalendarDays,
      CheckCircle2,
      Clock,
      FileText,
      Layers,
      LucideAngularModule,
      Mail,
      MoreHorizontal,
      PenLine,
      Pencil,
      Plus,
      Search,
      Share2,
      Trash2,
      UserCheck,
      Video,
    } from 'lucide-angular'
    import { cn } from '@/lib/utils'
    import { UiAvatarComponent, UiAvatarFallbackComponent } from '@/ui/avatar/avatar.component'
    import { UiBadgeComponent } from '@/ui/badge/badge.component'
    import { UiButtonComponent } from '@/ui/button/button.component'
    import { UiCardComponent, UiCardContentComponent } from '@/ui/card/card.component'
    import {
      UiDropdownMenuComponent,
      UiDropdownMenuContentComponent,
      UiDropdownMenuItemComponent,
      UiDropdownMenuLabelComponent,
      UiDropdownMenuSeparatorComponent,
      UiDropdownMenuTriggerComponent,
    } from '@/ui/dropdown-menu/dropdown-menu.component'
    import { UiInputComponent } from '@/ui/input/input.component'
    import {
      UiSelectComponent,
      UiSelectContentComponent,
      UiSelectItemComponent,
      UiSelectTriggerComponent,
      UiSelectValueComponent,
    } from '@/ui/select/select.component'
    import {
      UiTableBodyComponent,
      UiTableCellComponent,
      UiTableComponent,
      UiTableHeadComponent,
      UiTableHeaderComponent,
      UiTableRowComponent,
    } from '@/ui/table/table.component'
    
    export type ContentChannel = 'all' | 'blog' | 'newsletter' | 'youtube' | 'social'
    export type ContentStatus = 'published' | 'scheduled' | 'in_review' | 'draft'
    
    export interface ContentItem {
      id: string
      title: string
      channel: 'blog' | 'newsletter' | 'youtube' | 'social'
      channelLabel: string
      publishDate: string
      publishTime: string
      author: { name: string; role: string; avatar: string }
      status: ContentStatus
      statusLabel: string
      keywords: string[]
      format: string
    }
    
    const contentItems: ContentItem[] = [
      {
        id: 'cnt-01',
        title: 'Deep Dive: Headless Reka UI Primitives in Vue 3.5',
        channel: 'blog',
        channelLabel: 'Blog',
        publishDate: 'Sep 04',
        publishTime: '10:00 AM EST',
        author: { name: 'Sarah Chen', role: 'Lead Developer Advocate', avatar: 'SC' },
        status: 'published',
        statusLabel: 'Published',
        keywords: ['#vue3', '#reka-ui', '#frontend'],
        format: 'Technical Guide',
      },
      {
        id: 'cnt-02',
        title: 'Building Zero-Runtime Design Systems with Tailwind CSS v4',
        channel: 'youtube',
        channelLabel: 'YouTube',
        publishDate: 'Sep 08',
        publishTime: '02:00 PM EST',
        author: { name: 'Marcus Vance', role: 'Senior UI Engineer', avatar: 'MV' },
        status: 'scheduled',
        statusLabel: 'Scheduled',
        keywords: ['#tailwindcss', '#design-systems', '#webdev'],
        format: 'Video Tutorial',
      },
      {
        id: 'cnt-03',
        title: 'Frontend Weekly #142: Subgrid Mastery & CSS Anchor Positioning',
        channel: 'newsletter',
        channelLabel: 'Newsletter',
        publishDate: 'Sep 12',
        publishTime: '09:00 AM EST',
        author: { name: 'Elena Rostova', role: 'Technical Editor', avatar: 'ER' },
        status: 'scheduled',
        statusLabel: 'Scheduled',
        keywords: ['#css', '#newsletter', '#subgrid'],
        format: 'Email Dispatch',
      },
      {
        id: 'cnt-04',
        title: 'Accessible Command Palettes & Keyboard Shortcuts Architecture',
        channel: 'blog',
        channelLabel: 'Blog',
        publishDate: 'Sep 16',
        publishTime: '11:30 AM EST',
        author: { name: 'David Kim', role: 'Accessibility Specialist', avatar: 'DK' },
        status: 'in_review',
        statusLabel: 'In Review',
        keywords: ['#a11y', '#cmdk', '#keyboard-nav'],
        format: 'Deep Dive',
      },
      {
        id: 'cnt-05',
        title: 'Product Drop: 12 New Enterprise Blocks for Nuxt 4 & React',
        channel: 'social',
        channelLabel: 'Social',
        publishDate: 'Sep 21',
        publishTime: '04:00 PM EST',
        author: { name: 'Aisha Patel', role: 'Product Marketing Lead', avatar: 'AP' },
        status: 'in_review',
        statusLabel: 'In Review',
        keywords: ['#changelog', '#uipkge', '#react', '#vue'],
        format: 'Social Campaign',
      },
      {
        id: 'cnt-06',
        title: 'Micro-Interactions & Spring Physics in Modern Web Applications',
        channel: 'youtube',
        channelLabel: 'YouTube',
        publishDate: 'Sep 26',
        publishTime: '01:15 PM EST',
        author: { name: 'Lucas Meyer', role: 'Motion Designer', avatar: 'LM' },
        status: 'draft',
        statusLabel: 'Draft',
        keywords: ['#animation', '#spring-physics', '#ui-motion'],
        format: 'Interactive Workshop',
      },
    ]
    
    const channelFilters: Array<{ value: ContentChannel; label: string }> = [
      { value: 'all', label: 'All Channels' },
      { value: 'blog', label: 'Blog' },
      { value: 'newsletter', label: 'Newsletter' },
      { value: 'youtube', label: 'YouTube' },
      { value: 'social', label: 'Social' },
    ]
    
    const channelBadge: Record<string, string> = {
      blog: 'bg-info/10 text-info border-info/20',
      newsletter: 'bg-chart-1/10 text-chart-1 border-chart-1/20',
      youtube: 'bg-destructive/10 text-destructive border-destructive/20',
      social: 'bg-info/10 text-info border-info/20',
    }
    
    const statusBadge: Record<ContentStatus, string> = {
      published: 'bg-success/10 text-success border-success/20',
      scheduled: 'bg-info/10 text-info border-info/20',
      in_review: 'bg-warning/10 text-warning border-warning/20',
      draft: 'bg-muted text-muted-foreground border-border',
    }
    
    const statusDot: Record<ContentStatus, string> = {
      published: 'bg-success',
      scheduled: 'bg-info',
      in_review: 'bg-warning',
      draft: 'bg-muted-foreground',
    }
    
    const statusLabel: Record<ContentStatus, string> = {
      published: 'Published',
      scheduled: 'Scheduled',
      in_review: 'In Review',
      draft: 'Draft',
    }
    
    const channelLabel: Record<string, string> = {
      blog: 'Blog',
      newsletter: 'Newsletter',
      youtube: 'YouTube',
      social: 'Social',
    }
    
    @Component({
      changeDetection: ChangeDetectionStrategy.Eager,
      selector: 'ui-content-calendar-matrix, [ui-content-calendar-matrix]',
      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,
        UiAvatarComponent,
        UiAvatarFallbackComponent,
        UiBadgeComponent,
        UiButtonComponent,
        UiCardComponent,
        UiCardContentComponent,
        UiDropdownMenuComponent,
        UiDropdownMenuContentComponent,
        UiDropdownMenuItemComponent,
        UiDropdownMenuLabelComponent,
        UiDropdownMenuSeparatorComponent,
        UiDropdownMenuTriggerComponent,
        UiInputComponent,
        UiSelectComponent,
        UiSelectContentComponent,
        UiSelectItemComponent,
        UiSelectTriggerComponent,
        UiSelectValueComponent,
        UiTableComponent,
        UiTableBodyComponent,
        UiTableCellComponent,
        UiTableHeadComponent,
        UiTableHeaderComponent,
        UiTableRowComponent,
      ],
      template: `
        <div data-slot="content-calendar-matrix" [class]="rootClass">
          <!-- Header Section -->
          <div class="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
            <div>
              <div class="flex items-center gap-2">
                <h2 class="text-2xl font-bold tracking-tight">{{ title }}</h2>
                <span ui-badge variant="outline" class="hidden sm:inline-flex">Editorial Desk</span>
              </div>
              <p class="text-muted-foreground mt-1 text-sm">{{ subtitle }}</p>
            </div>
    
            <div class="flex flex-wrap items-center gap-3">
              <!-- Month Selector -->
              <ui-select [value]="selectedMonth()" (valueChange)="selectedMonth.set($event)">
                <button ui-select-trigger class="w-[180px] shadow-xs">
                  <lucide-icon [img]="CalendarDays" class="text-muted-foreground mr-2 size-4" />
                  <ui-select-value placeholder="Select month" />
                </button>
                <ui-select-content>
                  <ui-select-item value="2026-08">August 2026</ui-select-item>
                  <ui-select-item value="2026-09">September 2026</ui-select-item>
                  <ui-select-item value="2026-10">October 2026</ui-select-item>
                  <ui-select-item value="2026-11">November 2026</ui-select-item>
                </ui-select-content>
              </ui-select>
    
              <!-- New Content Item Primary Button -->
              <button ui-button class="gap-2 shadow-xs">
                <lucide-icon [img]="Plus" class="size-4" />
                New Content Item
              </button>
            </div>
          </div>
    
          <!-- 4 Editorial Pacing KPI Cards -->
          <div class="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
            <div ui-card class="shadow-xs">
              <div ui-card-content class="space-y-3 p-5">
                <div class="flex items-center justify-between">
                  <p class="text-muted-foreground text-sm font-medium">Total Planned Content</p>
                  <div class="text-primary bg-primary/10 flex size-8 items-center justify-center rounded-md">
                    <lucide-icon [img]="Layers" class="size-4" />
                  </div>
                </div>
                <div>
                  <div class="flex items-baseline gap-1.5">
                    <span class="text-2xl font-bold tracking-tight tabular-nums">24</span>
                    <span class="text-muted-foreground text-xs font-medium">Pieces this month</span>
                  </div>
                  <p class="text-muted-foreground mt-1 text-xs">+6 pieces vs previous month</p>
                </div>
              </div>
            </div>
            <div ui-card class="shadow-xs">
              <div ui-card-content class="space-y-3 p-5">
                <div class="flex items-center justify-between">
                  <p class="text-muted-foreground text-sm font-medium">Scheduled for Publish</p>
                  <div class="text-info bg-info/10 flex size-8 items-center justify-center rounded-md">
                    <lucide-icon [img]="CalendarCheck" class="size-4" />
                  </div>
                </div>
                <div>
                  <div class="flex items-baseline gap-1.5">
                    <span class="text-2xl font-bold tracking-tight tabular-nums">8</span>
                    <span class="text-muted-foreground text-xs font-medium">items ready</span>
                  </div>
                  <p class="text-muted-foreground mt-1 text-xs">Next drop: Sep 08 · 02:00 PM EST</p>
                </div>
              </div>
            </div>
            <div ui-card class="shadow-xs">
              <div ui-card-content class="space-y-3 p-5">
                <div class="flex items-center justify-between">
                  <p class="text-muted-foreground text-sm font-medium">In Review / Drafting</p>
                  <div class="text-warning bg-warning/10 flex size-8 items-center justify-center rounded-md">
                    <lucide-icon [img]="PenLine" class="size-4" />
                  </div>
                </div>
                <div>
                  <div class="flex items-baseline gap-1.5">
                    <span class="text-2xl font-bold tracking-tight tabular-nums">12</span>
                    <span class="text-muted-foreground text-xs font-medium">in progress</span>
                  </div>
                  <p class="text-muted-foreground mt-1 text-xs">7 in editorial review · 5 in drafting</p>
                </div>
              </div>
            </div>
            <div ui-card class="shadow-xs">
              <div ui-card-content class="space-y-3 p-5">
                <div class="flex items-center justify-between">
                  <p class="text-muted-foreground text-sm font-medium">Published this Month</p>
                  <div class="text-success bg-success/10 flex size-8 items-center justify-center rounded-md">
                    <lucide-icon [img]="CheckCircle2" class="size-4" />
                  </div>
                </div>
                <div>
                  <div class="flex items-baseline gap-1.5">
                    <span class="text-2xl font-bold tracking-tight tabular-nums">4</span>
                    <span class="text-muted-foreground text-xs font-medium">live</span>
                  </div>
                  <p class="text-muted-foreground mt-1 text-xs">100% on-schedule publishing rate</p>
                </div>
              </div>
            </div>
          </div>
    
          <!-- Pipeline & Matrix Controls Bar -->
          <div class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
            <!-- Channel Filters -->
            <div class="flex flex-wrap items-center gap-1.5">
              @for (filter of channelFilters; track filter.value) {
                <button
                  ui-button
                  [variant]="selectedChannel() === filter.value ? 'default' : 'outline'"
                  size="sm"
                  class="h-8 gap-1.5 text-xs shadow-xs"
                  (click)="selectedChannel.set(filter.value)"
                >
                  <lucide-icon [img]="filterIcon(filter.value)" class="size-3.5" />
                  <span>{{ filter.label }}</span>
                </button>
              }
            </div>
    
            <!-- Keyword / Title Search Input -->
            <div class="relative w-full sm:w-64">
              <lucide-icon
                [img]="Search"
                class="text-muted-foreground pointer-events-none absolute top-1/2 left-2.5 size-4 -translate-y-1/2"
              />
              <ui-input
                [value]="searchQuery()"
                (valueChange)="searchQuery.set($event)"
                type="text"
                placeholder="Filter by keyword or title..."
                class="h-8 pl-8 text-xs shadow-xs"
              />
            </div>
          </div>
    
          <!-- Content Publishing Pipeline Table -->
          <div class="bg-card overflow-x-auto rounded-lg border shadow-xs">
            <table ui-table>
              <thead ui-table-header>
                <tr ui-table-row>
                  <th ui-table-head class="w-[180px]">Publish Date &amp; Time</th>
                  <th ui-table-head class="min-w-[280px]">Content Title</th>
                  <th ui-table-head class="w-[120px]">Channel</th>
                  <th ui-table-head class="min-w-[180px]">Author</th>
                  <th ui-table-head class="w-[130px]">Status</th>
                  <th ui-table-head class="min-w-[200px]">SEO / Keywords</th>
                  <th ui-table-head class="w-12 text-right">
                    <span class="sr-only">Actions</span>
                  </th>
                </tr>
              </thead>
              <tbody ui-table-body>
                @for (item of filteredItems(); track item.id) {
                  <tr ui-table-row class="hover:bg-muted/50 transition-colors">
                    <!-- Publish Date & Time -->
                    <td ui-table-cell class="align-middle">
                      <div class="flex items-center gap-1.5">
                        <lucide-icon [img]="Clock" class="text-muted-foreground size-3.5 shrink-0" />
                        <span class="text-xs font-medium tabular-nums"
                          >{{ item.publishDate }} · {{ item.publishTime }}</span
                        >
                      </div>
                    </td>
    
                    <!-- Content Title -->
                    <td ui-table-cell class="align-middle">
                      <div class="space-y-0.5">
                        <p class="text-foreground text-sm leading-snug font-semibold">{{ item.title }}</p>
                        <p class="text-muted-foreground text-xs font-normal">{{ item.format }}</p>
                      </div>
                    </td>
    
                    <!-- Channel Badge -->
                    <td ui-table-cell class="align-middle">
                      <span ui-badge variant="outline" [class]="channelBadgeClass(item.channel)">
                        <lucide-icon [img]="channelIcon(item.channel)" class="size-3" />
                        {{ channelName(item.channel) }}
                      </span>
                    </td>
    
                    <!-- Author (Avatar + Name) -->
                    <td ui-table-cell class="align-middle">
                      <div class="flex items-center gap-2.5">
                        <ui-avatar class="size-7">
                          <ui-avatar-fallback class="bg-primary/10 text-primary text-xs font-semibold">
                            {{ item.author.avatar }}
                          </ui-avatar-fallback>
                        </ui-avatar>
                        <div class="min-w-0">
                          <p class="text-foreground truncate text-xs font-medium">{{ item.author.name }}</p>
                          <p class="text-muted-foreground truncate text-xs">{{ item.author.role }}</p>
                        </div>
                      </div>
                    </td>
    
                    <!-- Status Badge -->
                    <td ui-table-cell class="align-middle">
                      <span ui-badge variant="outline" [class]="statusBadgeClass(item.status)">
                        <span [class]="statusDotClass(item.status) + ' size-1.5 rounded-full'"></span>
                        {{ statusName(item.status) }}
                      </span>
                    </td>
    
                    <!-- SEO / Target Keywords -->
                    <td ui-table-cell class="align-middle">
                      <div class="flex flex-wrap gap-1">
                        @for (keyword of item.keywords; track keyword) {
                          <span
                            class="bg-muted/60 border-border/50 text-muted-foreground inline-flex items-center rounded-md border px-1.5 py-0.5 font-mono text-xs"
                          >
                            {{ keyword }}
                          </span>
                        }
                      </div>
                    </td>
    
                    <!-- Actions Dropdown -->
                    <td ui-table-cell class="text-right align-middle">
                      <ui-dropdown-menu>
                        <button
                          ui-button
                          ui-dropdown-menu-trigger
                          variant="ghost"
                          size="icon-sm"
                          class="text-muted-foreground size-8"
                        >
                          <lucide-icon [img]="MoreHorizontal" class="size-4" />
                          <span class="sr-only">Open actions for {{ item.title }}</span>
                        </button>
                        <ui-dropdown-menu-content align="end" class="w-48">
                          <ui-dropdown-menu-label class="text-xs">Manage Content</ui-dropdown-menu-label>
                          <ui-dropdown-menu-separator />
                          <ui-dropdown-menu-item class="gap-2 text-xs">
                            <lucide-icon [img]="Pencil" class="mr-2 size-3.5" />
                            <span>Edit item</span>
                          </ui-dropdown-menu-item>
                          <ui-dropdown-menu-item class="gap-2 text-xs">
                            <lucide-icon [img]="Calendar" class="mr-2 size-3.5" />
                            <span>Reschedule</span>
                          </ui-dropdown-menu-item>
                          <ui-dropdown-menu-item class="gap-2 text-xs">
                            <lucide-icon [img]="UserCheck" class="mr-2 size-3.5" />
                            <span>Assign reviewer</span>
                          </ui-dropdown-menu-item>
                          <ui-dropdown-menu-separator />
                          <ui-dropdown-menu-item class="text-destructive focus:text-destructive gap-2 text-xs">
                            <lucide-icon [img]="Trash2" class="mr-2 size-3.5" />
                            <span>Delete item</span>
                          </ui-dropdown-menu-item>
                        </ui-dropdown-menu-content>
                      </ui-dropdown-menu>
                    </td>
                  </tr>
                }
    
                @if (filteredItems().length === 0) {
                  <tr ui-table-row>
                    <td ui-table-cell [attr.colspan]="7" class="text-muted-foreground h-32 text-center text-sm">
                      No content items found matching the selected channel filter or search query.
                    </td>
                  </tr>
                }
              </tbody>
            </table>
          </div>
    
          <!-- Publishing Matrix Footer -->
          <div class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
            <p class="text-muted-foreground text-xs">
              Showing <span class="text-foreground font-semibold tabular-nums">{{ filteredItems().length }}</span> of
              <span class="text-foreground font-semibold tabular-nums">{{ totalItems }}</span> scheduled content pieces for
              <span class="text-foreground font-medium">September 2026</span>
            </p>
            <div class="flex items-center gap-2">
              <button ui-button variant="outline" size="sm" class="h-8 text-xs shadow-xs" disabled>Previous</button>
              <button ui-button variant="outline" size="sm" class="h-8 text-xs shadow-xs">Next</button>
            </div>
          </div>
        </div>
      `,
    })
    export class UiContentCalendarMatrixComponent {
      protected readonly Calendar = Calendar
      protected readonly CalendarCheck = CalendarCheck
      protected readonly CalendarDays = CalendarDays
      protected readonly CheckCircle2 = CheckCircle2
      protected readonly Clock = Clock
      protected readonly FileText = FileText
      protected readonly Layers = Layers
      protected readonly Mail = Mail
      protected readonly MoreHorizontal = MoreHorizontal
      protected readonly PenLine = PenLine
      protected readonly Pencil = Pencil
      protected readonly Plus = Plus
      protected readonly Search = Search
      protected readonly Share2 = Share2
      protected readonly Trash2 = Trash2
      protected readonly UserCheck = UserCheck
      protected readonly Video = Video
    
      protected readonly channelFilters = channelFilters
      protected readonly totalItems = contentItems.length
    
      @Input() title = 'Editorial Content Calendar'
      @Input() subtitle = 'Schedule, organize, and track cross-channel editorial pipelines and publishing milestones.'
      @Input('class') className?: string
    
      readonly selectedMonth = signal('2026-09')
      readonly selectedChannel = signal<ContentChannel>('all')
      readonly searchQuery = signal('')
    
      readonly filteredItems = computed(() => {
        const query = this.searchQuery().trim().toLowerCase()
        const channel = this.selectedChannel()
        return contentItems.filter((item) => {
          const matchesChannel = channel === 'all' || item.channel === channel
          const matchesQuery =
            !query ||
            item.title.toLowerCase().includes(query) ||
            item.author.name.toLowerCase().includes(query) ||
            item.keywords.some((k) => k.toLowerCase().includes(query))
          return matchesChannel && matchesQuery
        })
      })
    
      get rootClass(): string {
        return cn('w-full space-y-6', this.className)
      }
    
      filterIcon(value: ContentChannel): unknown {
        switch (value) {
          case 'blog':
            return FileText
          case 'newsletter':
            return Mail
          case 'youtube':
            return Video
          case 'social':
            return Share2
          default:
            return Layers
        }
      }
    
      channelIcon(channel: string): unknown {
        switch (channel) {
          case 'blog':
            return FileText
          case 'newsletter':
            return Mail
          case 'youtube':
            return Video
          default:
            return Share2
        }
      }
    
      channelBadgeClass(channel: string): string {
        return cn('gap-1 text-xs font-medium', channelBadge[channel] ?? '')
      }
    
      channelName(channel: string): string {
        return channelLabel[channel] ?? channel
      }
    
      statusBadgeClass(status: ContentStatus): string {
        return cn('gap-1.5 text-xs font-medium', statusBadge[status])
      }
    
      statusDotClass(status: ContentStatus): string {
        return statusDot[status]
      }
    
      statusName(status: ContentStatus): string {
        return statusLabel[status]
      }
    }
    
  • src/app/components/blocks/content-calendar-matrix/index.ts0.2 kB

Raw manifest:https://uipkge.dev/r/angular/content-calendar-matrix.json