Content calendar

An editorial content calendar, ready to use: Posts and Campaigns as two linked tables. Posts have stage, channels, author, publish date, cover and brief. Views for the plan, the pipeline as a Kanban, the publishing calendar, a wall of covers and a pitch form. (requires @svgrid/enterprise)

A live, editable Svelte 5 data grid example from the SvGrid gallery (Base). See the SvGrid documentation for the full API.

About this example

A content calendar built with SvGrid Base, a table database component for Svelte 5: a Posts table and a Campaigns table as tabs, linked both ways. Posts have a stage, channels, an author, a publish date, a cover and a brief; each campaign lists its posts and adds up their words. Views for the content plan, the pipeline as a Kanban, the publishing calendar, a wall of covers and a pitch form.

An editorial team's content calendar as a ready-to-use base with two tables. Posts have a stage, channels, an author, a publish date, a cover, a brief and the campaign they belong to; Campaigns list their posts and add up the words written for them.

The Posts views: the planning grid (colored by stage, in publishing order), the pipeline as a Kanban, the publishing calendar, a wall of covers, and the form a writer uses to pitch an idea.

Everything is <SvBase>: drag a card to move a post along, pick a day on the calendar to schedule one, Space on a row to open it with its comments.

Imports, features and API used

Imports: @svgrid/enterprise/base, ../shared/gallery-art, ../shared/base-people

Columns: name (Campaign), goal (Goal), owner (Owner), starts (Starts), ends (Ends), posts (Posts), words (Words), words

Frequently asked questions

Is this a template or a component?

A component: <SvBase> with your rows and columns. The demo is about 150 lines, most of it sample data. The views, the field editor, the record view and the form come with the component.

Can editors move a post along the pipeline?

Yes. In the Pipeline view, dragging a card to another stage changes the Stage field through the grid's edit path, so undo and onCellValueChange work the same as an edit in the grid.

How does a post get onto the calendar?

Its Publish date places it. Dragging it to another day changes the date; picking an empty day adds a post on it and opens it to fill in.

Which license does it need?

SvGrid Base is part of the Suite edition of @svgrid/enterprise. Without a key it runs with a watermark, so it can be evaluated as is.

Related documentation

Related articles

Source code (511-base-content-calendar.svelte)

<script lang="ts">
  /**
   * 511. SvGrid Base: content calendar
   * ----------------------------------
   * An editorial team's content calendar as a ready-to-use base with two
   * tables. Posts have a stage, channels, an author, a publish date, a cover,
   * a brief and the campaign they belong to; Campaigns list their posts and
   * add up the words written for them.
   *
   * The Posts views: the planning grid (colored by stage, in publishing
   * order), the pipeline as a Kanban, the publishing calendar, a wall of
   * covers, and the form a writer uses to pitch an idea.
   *
   * Everything is <SvBase>: drag a card to move a post along, pick a day on
   * the calendar to schedule one, Space on a row to open it with its comments.
   */
  import { SvBase, enableBase, type Attachment, type BaseTable } from '@svgrid/enterprise/base'
  import { editorialArt } from '../shared/gallery-art'
  import { usePeople } from '../shared/base-people'

  enableBase()
  usePeople()

  type Post = {
    id: number
    title: string
    stage: string
    channels: string[]
    author: string
    publish: string
    cover: Attachment[]
    words: number
    brief: string
    link: string
    campaign: string[]
  }

  const STAGES = [
    { value: 'Idea', color: '#94a3b8' },
    { value: 'Drafting', color: '#2d7ff9' },
    { value: 'In review', color: '#fcb400' },
    { value: 'Scheduled', color: '#8b46ff' },
    { value: 'Published', color: '#20c933' },
  ]
  const CHANNELS = [
    { value: 'Blog', color: '#2d7ff9' },
    { value: 'Newsletter', color: '#ff6f2c' },
    { value: 'LinkedIn', color: '#18bfff' },
    { value: 'YouTube', color: '#f82b60' },
    { value: 'Podcast', color: '#8b46ff' },
  ]
  const TITLES = [
    'Why we rebuilt our onboarding',
    'Ten keyboard shortcuts power users love',
    'Customer story: a logistics team at scale',
    'The state of remote teams in 2026',
    'Behind the scenes: our design review',
    'A field guide to quarterly planning',
    'Podcast: shipping weekly without burnout',
    'How to write a product brief people read',
    'Year in review: what our customers built',
    'The case for fewer meetings',
    'Five templates for running a launch',
    'Interview: the people behind support',
    'Our new pricing, explained',
    'What we learned from 1,000 user calls',
    'Newsletter: October roundup',
    'Video: building a dashboard in ten minutes',
    'Hiring for curiosity',
    'A short history of the spreadsheet',
  ]
  const day = (n: number) => new Date(Date.UTC(2026, 9, 1 + n)).toISOString().slice(0, 10)
  let nextId = TITLES.length + 1
  const CAMPAIGNS = [
    ['Fall launch', 'Sign-ups from the new onboarding', 'u1', 0, 20],
    ['Customer stories', 'Three case studies in sales decks', 'u5', 5, 34],
    ['Hiring push', 'Twelve engineering applicants a week', 'u6', 10, 30],
  ] as const
  const posts: Post[] = TITLES.map((title, i) => ({
    id: i + 1,
    title,
    stage: STAGES[[4, 4, 3, 3, 2, 2, 1, 1, 1, 0, 0, 3, 2, 4, 3, 1, 0, 2][i]!]!.value,
    channels: [CHANNELS[i % 5]!.value, ...(i % 3 === 0 ? [CHANNELS[(i + 2) % 5]!.value] : [])],
    author: ['u1', 'u3', 'u5', 'u6', 'u2', 'u8'][i % 6]!,
    publish: day((i * 5) % 34),
    cover: [{ id: `cover-${i}`, name: `cover-${i + 1}.svg`, type: 'image/svg+xml', url: editorialArt(i + 1) }],
    words: 600 + ((i * 377) % 1900),
    brief: 'Audience: team leads. Angle: one concrete practice and the numbers behind it.',
    link: i % 4 === 0 ? `https://example.com/blog/post-${i + 1}` : '',
    campaign: [`k${(i % 3) + 1}`],
  }))
  const campaigns = CAMPAIGNS.map(([name, goal, owner, from, to], i) => ({
    id: `k${i + 1}`,
    name,
    goal,
    owner,
    starts: day(from),
    ends: day(to),
    posts: posts.filter((p) => p.campaign[0] === `k${i + 1}`).map((p) => String(p.id)),
  }))

  const postColumns: any[] = [
    { field: 'title', header: 'Title', width: 300, fieldType: 'text' },
    { field: 'stage', header: 'Stage', width: 130, fieldType: 'singleSelect', fieldProps: { options: STAGES } },
    { field: 'channels', header: 'Channels', width: 200, fieldType: 'multiSelect', fieldProps: { options: CHANNELS } },
    { field: 'author', header: 'Author', width: 160, fieldType: 'user' },
    { field: 'campaign', header: 'Campaign', width: 170, fieldType: 'link', fieldProps: { table: 'content-campaigns', multiple: false, inverse: 'posts' } },
    { field: 'publish', header: 'Publish date', width: 160, fieldType: 'date', fieldProps: { dateFormat: 'friendly' } },
    { field: 'cover', header: 'Cover', width: 120, fieldType: 'attachment' },
    { field: 'words', header: 'Words', width: 100, fieldType: 'number', fieldProps: { format: 'integer', summary: 'sum' } },
    { field: 'brief', header: 'Brief', width: 260, fieldType: 'longText' },
    { field: 'link', header: 'Link', width: 220, fieldType: 'url' },
  ]

  let tables = $state.raw<BaseTable[]>([
    {
      id: 'content-posts',
      name: 'Posts',
      primaryField: 'title',
      data: posts,
      columns: postColumns,
      views: [
        { id: 'plan', name: 'Content plan', type: 'grid', color: { mode: 'field', field: 'stage' }, state: { sorting: [{ id: 'publish', desc: false }] } },
        { id: 'pipeline', name: 'Pipeline', type: 'kanban', stackBy: 'stage' },
        { id: 'calendar', name: 'Publishing calendar', type: 'calendar', dateField: 'publish', color: { mode: 'field', field: 'stage' } },
        { id: 'covers', name: 'Covers', type: 'gallery', coverField: 'cover' },
        { id: 'pitch', name: 'Pitch an idea', type: 'form', formFields: ['title', 'channels', 'author', 'campaign', 'brief'], formDescription: 'Tell the editors what you want to write. They pick it up in Monday planning.' },
      ],
      props: { rowAdder: { label: 'Add post', defaults: () => ({ id: nextId++, title: '', stage: 'Idea', channels: [], cover: [] }) } },
    },
    {
      id: 'content-campaigns',
      name: 'Campaigns',
      primaryField: 'name',
      data: campaigns,
      columns: [
        { field: 'name', header: 'Campaign', width: 200, fieldType: 'text' },
        { field: 'goal', header: 'Goal', width: 280, fieldType: 'text' },
        { field: 'owner', header: 'Owner', width: 160, fieldType: 'user' },
        { field: 'starts', header: 'Starts', width: 160, fieldType: 'date', fieldProps: { dateFormat: 'friendly' } },
        { field: 'ends', header: 'Ends', width: 160, fieldType: 'date', fieldProps: { dateFormat: 'friendly' } },
        { field: 'posts', header: 'Posts', width: 320, fieldType: 'link', fieldProps: { table: 'content-posts', multiple: true, inverse: 'campaign' } },
        { field: 'words', header: 'Words', width: 110, fieldType: 'rollup', fieldProps: { linkField: 'posts', field: 'words', aggregation: 'SUM' } },
      ],
      views: [
        { id: 'campaigns', name: 'All campaigns', type: 'grid' },
        { id: 'when', name: 'Campaign calendar', type: 'calendar', dateField: 'starts', endField: 'ends' },
      ],
      props: { rowAdder: { label: 'Add campaign' } },
    },
  ])
</script>

<section class="base-demo">
  <SvBase
    bind:tables
    editable
    sortable
    showRowNumbers
    expandRows
    pinSplitter
    summary
    rowNumberWidth={64}
    showRowSelection={false}
    rowHeight={44}
    containerHeight="100%"
    selectionBar={['selectAll', 'editFields', 'deleteRows']}
    columnAdder
    fieldMenu
  />
</section>

<style>
  .base-demo { display: flex; flex-direction: column; flex: 1; min-height: 480px; height: 100%; }
</style>

View this example on GitHub

More Base examples

  • Project tracker - A project tracker, ready to use: Tasks and Milestones as two linked tables. Tasks have status, tags, assignee, dates, estimate, progress, budget and files; each milestone lists its tasks with their progress and budget. Views for all tasks, the board by status, due dates on a calendar, a gallery and a request form. Automations close finished tasks, start work from a button and flag overdue ones.
  • Sales CRM - A sales CRM, ready to use: Deals, Accounts and Contacts as three linked tables. Link a deal to an account and the account lists it. Views for all deals, the pipeline as a Kanban, the closing calendar, open deals by owner and a new-lead form.
  • Product roadmap - A product roadmap, ready to use: Features and customer Feedback as two linked tables. Features have quarter, status, teams, lead, impact, effort and votes. Views for the roadmap grouped by quarter, a status board, a timeline, the work in progress now and a request form.
  • Hiring tracker - A hiring tracker, ready to use: Candidates and Roles as two linked tables. Candidates have photo, role, stage, recruiter, rating, interview date and resume; each role lists its candidates. Views for the pipeline as a Kanban, all candidates, a gallery of faces, interviews and an application form.
  • Inventory - An inventory, ready to use: Items and Suppliers as two linked tables. Items have photos, category, location, quantity, unit cost and a stock value formula; each supplier lists its items and units in stock. Views for stock by category, photos, items by location, a reorder list and an add-item form.