Creating a package
Create and edit a package — its author-controlled fields, the slugified key, and where a new package sorts.
Packages are created and edited from the admin panel's Packages page. There's no viewer path — every
procedure in packages/api/src/routers/packages.ts is an adminProcedure.
The fields you control
A package has four author-controlled fields, the same on create and edit:
- Name (required) — e.g. "Kids & Family".
- Description (optional) — a short line describing the lineup.
- Icon and tint — chosen together in the
IconTintField(apps/web/src/features/icons/icon-tint-field.tsx). The icon is stored as a namespaced string like"lucide:Sparkles"; the tint is stored as an accent-palette key (see Icon & tint).
Everything else on the row — the key, sortIndex, and the two provenance flags — is derived by the
server, not entered by hand.
Creating
UI: apps/web/src/routes/_auth/packages/new.tsx, reached from the New package button on the
list page. Submitting calls trpc.packages.create. On the server (packages.ts → create):
- The
nameis slugified and de-duplicated into a uniquekey.slugifylowercases, replaces runs of non-alphanumerics with-, and falls back to"package"for an all-symbol name;uniqueKeythen appends-2,-3, … on collision. So two packages named the same still get distinct, stable slugs (e.g.kids-familyandkids-family-2). sortIndexis set tomax(sortIndex) + 1— a new package sorts to the end of the list.tintis coerced throughtoAccentKey(packages/api/src/services/accents.ts), which folds any legacy or hand-entered value to a valid accent key (defaultslate) so the stored value is always paintable. An empty tint staysnull.
The key is what the preset generator upserts against and what the "Regenerate channels" action keys on
— it stays stable across edits and regens even as the name changes.
Editing
UI: apps/web/src/routes/_auth/packages/$packageId.tsx → trpc.packages.update. The edit form
("Edit package" frame) changes the same four fields. update does not re-slug the key — renaming a
package keeps its original slug and id, so nothing that references it breaks.
The detail page also carries the actions covered elsewhere: the read-only channel list with a per-channel enable switch (see Assigning channels), a Regenerate channels button that appears only for preset packages (see Provenance), and Delete.
Deleting
Delete prompts "Delete this package? Its channels stay but become unassigned." — matching the schema's
onDelete: SetNull on Channel.packageId. remove simply deletes the channelPackage row; Postgres
nulls the packageId on every channel that pointed at it, so those channels survive as
ungrouped channels.
Listing, searching, sorting
The list page (apps/web/src/routes/_auth/packages/index.tsx) drives packages.list entirely through
URL params, so a filtered view is linkable:
- Search (
q) — case-insensitivecontainsover name and description. - Filter → Type (
gen) — provenance filter: Auto (preset) / AI-generated / Manual (see Provenance). - Sort (
sort+dir) — by Order (sortIndex, the default), Name, or Channels (the channel count). Each row shows the package's icon tile, name, provenance badge, description, and channel count.
Packages
How Airwave groups channels into packages — the unit of organization in the admin, the filter lenses on the TV guide, and the thing a viewer is granted access to.
Assigning channels
Channels join a package from the channel form, not the package page — and inherit its icon and tint via resolveTile.
