Content Calendar Matrix
blockmediaEditorial 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
$pnpm dlx uipkge-ng@latest add content-calendar-matrix$npx uipkge-ng@latest add content-calendar-matrix$yarn dlx uipkge-ng@latest add content-calendar-matrix$bunx uipkge-ng@latest add content-calendar-matrixManifest:
https://uipkge.dev/r/angular/content-calendar-matrix.jsonInstalls to:src/app/components/blocks/content-calendar-matrix/Variants
Loading interactive previews…
Loading interactive previews…
Schema
Type aliases exported from this item's source. Use these to shape the data you pass in.
ContentIteminterface 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
}npm dependencies
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 & 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
export { UiContentCalendarMatrixComponent, type ContentChannel, type ContentStatus, type ContentItem, } from './content-calendar-matrix.component'
Raw manifest:https://uipkge.dev/r/angular/content-calendar-matrix.json