Status
@@ -220,7 +244,7 @@ ReleasePage.PublishStatus = (props: {
- {!release.scheduledAt && (
+ {!release.archivedAt && !release.scheduledAt && (
)}
- {release.scheduledAt ? (
-
-
-
-
-
- ) : (
+
+
+
+ ) : (
+
+
+
+
+
+ ))}
+ {!release.archivedAt && (
-
+
diff --git a/packages/root-cms/ui/pages/ReleasesPage/ReleasesPage.tsx b/packages/root-cms/ui/pages/ReleasesPage/ReleasesPage.tsx
index 332ea3e69..51ab18284 100644
--- a/packages/root-cms/ui/pages/ReleasesPage/ReleasesPage.tsx
+++ b/packages/root-cms/ui/pages/ReleasesPage/ReleasesPage.tsx
@@ -1,7 +1,7 @@
import './ReleasesPage.css';
-import {Button, Loader, Table} from '@mantine/core';
-import {useEffect, useState} from 'preact/hooks';
+import {Button, Loader, SegmentedControl, Table} from '@mantine/core';
+import {useEffect, useMemo, useState} from 'preact/hooks';
import {ConditionalTooltip} from '../../components/ConditionalTooltip/ConditionalTooltip.js';
import {Heading} from '../../components/Heading/Heading.js';
import {ReleaseStatusBadge} from '../../components/ReleaseStatusBadge/ReleaseStatusBadge.js';
@@ -11,6 +11,8 @@ import {Layout} from '../../layout/Layout.js';
import {testCanPublish} from '../../utils/permissions.js';
import {Release, listReleases} from '../../utils/release.js';
+type ReleaseListFilter = 'active' | 'unpublished' | 'published' | 'archived';
+
export function ReleasesPage() {
const {roles} = useProjectRoles();
const currentUserEmail = window.firebase.user.email || '';
@@ -51,6 +53,7 @@ export function ReleasesPage() {
ReleasesPage.ReleasesTable = () => {
const [loading, setLoading] = useState(true);
const [tableData, setTableData] = useState ([]);
+ const [filter, setFilter] = useState('active');
async function init() {
const releases = await listReleases();
@@ -58,6 +61,23 @@ ReleasesPage.ReleasesTable = () => {
setLoading(false);
}
+ const filteredReleases = useMemo(() => {
+ return tableData.filter((release) => {
+ const isArchived = Boolean(release.archivedAt);
+ const isPublished = Boolean(release.publishedAt);
+ if (filter === 'active') {
+ return !isArchived;
+ }
+ if (filter === 'unpublished') {
+ return !isArchived && !isPublished;
+ }
+ if (filter === 'published') {
+ return !isArchived && isPublished;
+ }
+ return isArchived;
+ });
+ }, [tableData, filter]);
+
useEffect(() => {
init();
}, []);
@@ -65,39 +85,57 @@ ReleasesPage.ReleasesTable = () => {
return (
{loading && }
- {tableData.length > 0 && (
-
-
-
- | id |
- description |
- content |
- status |
-
-
-
- {tableData.map((release) => (
-
- |
- {release.id}
- |
- {release.description || ''} |
-
- {(release.docIds || []).map((docId) => (
-
- ))}
- |
-
-
-
-
- |
-
- ))}
-
-
+ {!loading && (
+ <>
+ setFilter(value as ReleaseListFilter)}
+ data={[
+ {label: 'Active', value: 'active'},
+ {label: 'Unpublished', value: 'unpublished'},
+ {label: 'Published', value: 'published'},
+ {label: 'Archived', value: 'archived'},
+ ]}
+ mb="md"
+ />
+ {filteredReleases.length > 0 && (
+
+
+
+ | id |
+ description |
+ content |
+ status |
+
+
+
+ {filteredReleases.map((release) => (
+
+ |
+ {release.id}
+ |
+ {release.description || ''} |
+
+ {(release.docIds || []).map((docId) => (
+
+ ))}
+ |
+
+
+
+
+ |
+
+ ))}
+
+
+ )}
+ {filteredReleases.length === 0 && (
+ No releases found for this filter.
+ )}
+ >
)}
);
diff --git a/packages/root-cms/ui/utils/release.ts b/packages/root-cms/ui/utils/release.ts
index 441f53524..7a325f90e 100644
--- a/packages/root-cms/ui/utils/release.ts
+++ b/packages/root-cms/ui/utils/release.ts
@@ -28,6 +28,8 @@ export interface Release {
scheduledBy?: string;
publishedAt?: Timestamp;
publishedBy?: string;
+ archivedAt?: Timestamp;
+ archivedBy?: string;
}
const COLLECTION_ID = 'Releases';
@@ -95,11 +97,25 @@ export async function deleteRelease(id: string) {
logAction('release.delete', {metadata: {releaseId: id}});
}
+export async function archiveRelease(id: string) {
+ const projectId = window.__ROOT_CTX.rootConfig.projectId;
+ const db = window.firebase.db;
+ const docRef = doc(db, 'Projects', projectId, COLLECTION_ID, id);
+ await updateDoc(docRef, {
+ archivedAt: serverTimestamp(),
+ archivedBy: window.firebase.user.email,
+ });
+ logAction('release.archive', {metadata: {releaseId: id}});
+}
+
export async function publishRelease(id: string) {
const release = await getRelease(id);
if (!release) {
throw new Error(`release not found: ${id}`);
}
+ if (release.archivedAt) {
+ throw new Error(`release is archived: ${id}`);
+ }
const docIds = release.docIds || [];
const dataSourceIds = release.dataSourceIds || [];
if (docIds.length === 0 && dataSourceIds.length === 0) {
@@ -147,6 +163,10 @@ export async function scheduleRelease(
throw new Error(`release not found: ${id}`);
}
+ if (release.archivedAt) {
+ throw new Error(`release is archived: ${id}`);
+ }
+
if (typeof timestamp === 'number') {
timestamp = Timestamp.fromMillis(timestamp);
}
From bb36372fa4fd30c570a3f177d9edcca61fe3697d Mon Sep 17 00:00:00 2001
From: Steven Le
Date: Fri, 20 Feb 2026 15:07:31 -0800
Subject: [PATCH 2/3] chore: fix timestamps on releases page
---
docs/scripts/fix_release_timestamps.mjs | 45 +++++++++++++++++++
.../ReleaseStatusBadge/ReleaseStatusBadge.tsx | 16 ++++---
2 files changed, 56 insertions(+), 5 deletions(-)
create mode 100644 docs/scripts/fix_release_timestamps.mjs
diff --git a/docs/scripts/fix_release_timestamps.mjs b/docs/scripts/fix_release_timestamps.mjs
new file mode 100644
index 000000000..25269f96c
--- /dev/null
+++ b/docs/scripts/fix_release_timestamps.mjs
@@ -0,0 +1,45 @@
+import {loadRootConfig} from '@blinkk/root/node';
+import {RootCMSClient} from '@blinkk/root-cms';
+import {Timestamp} from 'firebase-admin/firestore';
+
+async function main() {
+ const rootConfig = await loadRootConfig(process.cwd());
+ const client = new RootCMSClient(rootConfig);
+ const db = client.db;
+ const releases = await db.collection('Projects/www/Releases').get();
+
+ for (const doc of releases.docs) {
+ const data = doc.data();
+ let changed = false;
+
+ // Check createdAt field
+ if (data.createdAt && !(data.createdAt instanceof Timestamp)) {
+ if (data.createdAt._seconds && data.createdAt._nanoseconds) {
+ data.createdAt = new Timestamp(
+ data.createdAt._seconds,
+ data.createdAt._nanoseconds
+ );
+ changed = true;
+ }
+ }
+
+ // Check publishedAt field
+ if (data.publishedAt && !(data.publishedAt instanceof Timestamp)) {
+ if (data.publishedAt._seconds && data.publishedAt._nanoseconds) {
+ data.publishedAt = new Timestamp(
+ data.publishedAt._seconds,
+ data.publishedAt._nanoseconds
+ );
+ changed = true;
+ }
+ }
+
+ if (changed) {
+ console.log(`Updating release ${doc.id}`);
+ await doc.ref.set(data);
+ }
+ }
+ console.log('Done');
+}
+
+main();
diff --git a/packages/root-cms/ui/components/ReleaseStatusBadge/ReleaseStatusBadge.tsx b/packages/root-cms/ui/components/ReleaseStatusBadge/ReleaseStatusBadge.tsx
index fc008cd79..9d04171dc 100644
--- a/packages/root-cms/ui/components/ReleaseStatusBadge/ReleaseStatusBadge.tsx
+++ b/packages/root-cms/ui/components/ReleaseStatusBadge/ReleaseStatusBadge.tsx
@@ -13,11 +13,13 @@ export interface ReleaseStatusBadgeProps {
export function ReleaseStatusBadge(props: ReleaseStatusBadgeProps) {
const release = props.release;
- if (release.archivedAt) {
+ if (testIsValidTimestamp(release.archivedAt)) {
return (
Archived
@@ -25,7 +27,7 @@ export function ReleaseStatusBadge(props: ReleaseStatusBadgeProps) {
);
}
- if (release.scheduledAt) {
+ if (testIsValidTimestamp(release.scheduledAt)) {
return (
);
}
- if (release.publishedAt) {
+ if (testIsValidTimestamp(release.publishedAt)) {
return (
Date: Fri, 20 Feb 2026 15:53:07 -0800
Subject: [PATCH 3/3] chore: move archive buttons to bottom of page
---
.changeset/ten-tables-rhyme.md | 5 +
.../ui/pages/ReleasePage/ReleasePage.css | 4 +
.../ui/pages/ReleasePage/ReleasePage.tsx | 126 ++++++++++++------
.../ui/pages/ReleasesPage/ReleasesPage.css | 6 +
.../ui/pages/ReleasesPage/ReleasesPage.tsx | 24 ++--
packages/root-cms/ui/utils/release.ts | 17 ++-
6 files changed, 127 insertions(+), 55 deletions(-)
create mode 100644 .changeset/ten-tables-rhyme.md
diff --git a/.changeset/ten-tables-rhyme.md b/.changeset/ten-tables-rhyme.md
new file mode 100644
index 000000000..b086404c9
--- /dev/null
+++ b/.changeset/ten-tables-rhyme.md
@@ -0,0 +1,5 @@
+---
+'@blinkk/root-cms': patch
+---
+
+feat: add release archiving and status filters
diff --git a/packages/root-cms/ui/pages/ReleasePage/ReleasePage.css b/packages/root-cms/ui/pages/ReleasePage/ReleasePage.css
index 481498e76..a058696e1 100644
--- a/packages/root-cms/ui/pages/ReleasePage/ReleasePage.css
+++ b/packages/root-cms/ui/pages/ReleasePage/ReleasePage.css
@@ -100,3 +100,7 @@
border-top: 1px solid var(--color-border);
padding: 4px 8px;
}
+
+.ReleasePage__footer {
+ margin-top: 40px;
+}
diff --git a/packages/root-cms/ui/pages/ReleasePage/ReleasePage.tsx b/packages/root-cms/ui/pages/ReleasePage/ReleasePage.tsx
index c4625479a..f3f2d5cc0 100644
--- a/packages/root-cms/ui/pages/ReleasePage/ReleasePage.tsx
+++ b/packages/root-cms/ui/pages/ReleasePage/ReleasePage.tsx
@@ -8,7 +8,12 @@ import {
} from '@mantine/core';
import {useModals} from '@mantine/modals';
import {showNotification, updateNotification} from '@mantine/notifications';
-import {IconArchive, IconSettings} from '@tabler/icons-preact';
+import {
+ IconArchive,
+ IconArchiveOff,
+ IconRestore,
+ IconSettings,
+} from '@tabler/icons-preact';
import {useEffect, useState} from 'preact/hooks';
import {ConditionalTooltip} from '../../components/ConditionalTooltip/ConditionalTooltip.js';
import {DocPreviewCard} from '../../components/DocPreviewCard/DocPreviewCard.js';
@@ -27,6 +32,7 @@ import {
getRelease,
publishRelease,
archiveRelease,
+ unarchiveRelease,
} from '../../utils/release.js';
import {timestamp} from '../../utils/time.js';
import './ReleasePage.css';
@@ -37,6 +43,12 @@ export function ReleasePage(props: {id: string}) {
const [updated, setUpdated] = useState(0);
const id = props.id;
+ const modals = useModals();
+ const modalTheme = useModalTheme();
+ const {roles} = useProjectRoles();
+ const currentUserEmail = window.firebase.user.email || '';
+ const canPublish = testCanPublish(roles, currentUserEmail);
+
async function init() {
setLoading(true);
await notifyErrors(async () => {
@@ -56,6 +68,48 @@ export function ReleasePage(props: {id: string}) {
init();
}
+ function onArchiveClicked() {
+ modals.openConfirmModal({
+ ...modalTheme,
+ title: `Archive release: ${id}`,
+ children: (
+
+ Are you sure you want to archive this release?
+
+ ),
+ labels: {confirm: 'Archive', cancel: 'Cancel'},
+ cancelProps: {size: 'xs'},
+ confirmProps: {color: 'dark', size: 'xs'},
+ closeOnConfirm: true,
+ onConfirm: async () => {
+ await notifyErrors(async () => {
+ await archiveRelease(id);
+ });
+ },
+ });
+ }
+
+ function onUnarchiveClicked() {
+ modals.openConfirmModal({
+ ...modalTheme,
+ title: `Unarchive release: ${id}`,
+ children: (
+
+ Are you sure you want to unarchive this release?
+
+ ),
+ labels: {confirm: 'Unarchive', cancel: 'Cancel'},
+ cancelProps: {size: 'xs'},
+ confirmProps: {color: 'dark', size: 'xs'},
+ closeOnConfirm: true,
+ onConfirm: async () => {
+ await notifyErrors(async () => {
+ await unarchiveRelease(id);
+ });
+ },
+ });
+ }
+
return (
@@ -104,6 +158,34 @@ export function ReleasePage(props: {id: string}) {
key={`data-sources-${updated}`}
/>
)}
+ {release && canPublish && (
+
+ {release.archivedAt ? (
+ }
+ onClick={() => onUnarchiveClicked()}
+ disabled={!canPublish}
+ >
+ Unarchive Release
+
+ ) : (
+ }
+ onClick={() => onArchiveClicked()}
+ disabled={!canPublish}
+ >
+ Archive Release
+
+ )}
+
+ )}
>
)}
@@ -204,29 +286,6 @@ ReleasePage.PublishStatus = (props: {
props.onAction('cancel-schedule');
}
- function onArchiveClicked() {
- modals.openConfirmModal({
- ...modalTheme,
- title: `Archive release: ${release.id}`,
- children: (
-
- Are you sure you want to archive this release? Archived releases are
- hidden from active lists.
-
- ),
- labels: {confirm: 'Archive', cancel: 'Cancel'},
- cancelProps: {size: 'xs'},
- confirmProps: {color: 'dark', size: 'xs'},
- closeOnConfirm: true,
- onConfirm: async () => {
- await notifyErrors(async () => {
- await archiveRelease(release.id);
- props.onAction('archive');
- });
- },
- });
- }
-
return (
Status
@@ -316,25 +375,6 @@ ReleasePage.PublishStatus = (props: {
))}
- {!release.archivedAt && (
-
-
- }
- onClick={() => onArchiveClicked()}
- disabled={!canPublish}
- >
- Archive
-
-
-
- )}
|
diff --git a/packages/root-cms/ui/pages/ReleasesPage/ReleasesPage.css b/packages/root-cms/ui/pages/ReleasesPage/ReleasesPage.css
index f85c6708c..c66432886 100644
--- a/packages/root-cms/ui/pages/ReleasesPage/ReleasesPage.css
+++ b/packages/root-cms/ui/pages/ReleasesPage/ReleasesPage.css
@@ -28,3 +28,9 @@
.ReleasesPage__ReleasesTable__publishStatus__icon {
color: #12b886;
}
+
+.ReleasesPage__ReleasesTable__filters {
+ display: flex;
+ justify-content: flex-end;
+ margin-bottom: 20px;
+}
diff --git a/packages/root-cms/ui/pages/ReleasesPage/ReleasesPage.tsx b/packages/root-cms/ui/pages/ReleasesPage/ReleasesPage.tsx
index 51ab18284..bd329957f 100644
--- a/packages/root-cms/ui/pages/ReleasesPage/ReleasesPage.tsx
+++ b/packages/root-cms/ui/pages/ReleasesPage/ReleasesPage.tsx
@@ -87,17 +87,19 @@ ReleasesPage.ReleasesTable = () => {
{loading &&
}
{!loading && (
<>
-
setFilter(value as ReleaseListFilter)}
- data={[
- {label: 'Active', value: 'active'},
- {label: 'Unpublished', value: 'unpublished'},
- {label: 'Published', value: 'published'},
- {label: 'Archived', value: 'archived'},
- ]}
- mb="md"
- />
+
+ setFilter(value)}
+ data={[
+ {label: 'Active', value: 'active'},
+ {label: 'Unpublished', value: 'unpublished'},
+ {label: 'Published', value: 'published'},
+ {label: 'Archived', value: 'archived'},
+ ]}
+ />
+
{filteredReleases.length > 0 && (
diff --git a/packages/root-cms/ui/utils/release.ts b/packages/root-cms/ui/utils/release.ts
index 7a325f90e..4bb8ca545 100644
--- a/packages/root-cms/ui/utils/release.ts
+++ b/packages/root-cms/ui/utils/release.ts
@@ -108,6 +108,17 @@ export async function archiveRelease(id: string) {
logAction('release.archive', {metadata: {releaseId: id}});
}
+export async function unarchiveRelease(id: string) {
+ const projectId = window.__ROOT_CTX.rootConfig.projectId;
+ const db = window.firebase.db;
+ const docRef = doc(db, 'Projects', projectId, COLLECTION_ID, id);
+ await updateDoc(docRef, {
+ archivedAt: deleteField(),
+ archivedBy: deleteField(),
+ });
+ logAction('release.unarchive', {metadata: {releaseId: id}});
+}
+
export async function publishRelease(id: string) {
const release = await getRelease(id);
if (!release) {
@@ -145,7 +156,11 @@ export async function publishRelease(id: string) {
publishMessage: release.description,
});
console.log(`published release: ${id}`);
- const metadata: Record = {releaseId: id, docIds, dataSourceIds};
+ const metadata: Record = {
+ releaseId: id,
+ docIds,
+ dataSourceIds,
+ };
if (release.description) {
metadata.publishMessage = release.description;
}