feat(banners): instance announcements — admin CRUD, top bar, CLI (#39)

banners table (title/body/level/active/dismissible/schedule window).
GET /banners/active for signed-in users; admin CRUD under /admin/banners.
BannerBar renders above the header with per-banner dismissal
(localStorage); admin console gains an Announcements section;
`containr banners` manages them and `auth status` prints active ones.

Generated with [Devin](https://devin.ai)

Co-authored-by: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
Tomáš DvořákandDevin authored and GitHub committed 2026-10-09 10:10:18 +02:00
1 parent 954a1d1f80
commit 9408464f12
21 files changed
+1301 -3

No files matched your search

@@ -41,7 +41,7 @@ import {
import { signOutAuthSession } from '@/lib/auth-client';
import { isDemoSearch } from '@/lib/demo-mode';
import { useAuthSession } from '@/lib/use-auth-session';
import { useToast } from '@/shared/components';
import { BannerBar, useToast } from '@/shared/components';
type NavItem = { label: string; href: string; icon: typeof FolderKanban; hint: string };
type NavSection = { title: string; items: NavItem[] };
@@ -387,6 +387,7 @@ export function PlatformShell() {
</aside>
<div className="relative z-10 flex min-h-screen min-w-0 flex-1 flex-col overflow-hidden">
<BannerBar />
<header
className="hidden h-[46px] shrink-0 items-center border-b px-5 md:flex"
style={{
@@ -5,6 +5,7 @@ import {
CheckCircle2,
Clock,
FolderKanban,
Megaphone,
Pencil,
ShieldCheck,
Trash2,
@@ -12,14 +13,19 @@ import {
XCircle,
} from 'lucide-react';
import {
createBanner,
deleteBanner,
deleteProject,
getAdminOverview,
listAdminBanners,
listAdminUsers,
listProjects,
setProjectApproval,
setUserAdmin,
updateBanner,
updateProject,
type AdminUser,
type Banner,
type ProjectEntity,
} from '@/lib/api-client';
import { useAuthSession } from '@/lib/use-auth-session';
@@ -50,16 +56,50 @@ export function AdminPage() {
const overviewQuery = useQuery({ queryKey: ['admin-overview'], queryFn: getAdminOverview, enabled: !isDemoMode });
const usersQuery = useQuery({ queryKey: ['admin-users'], queryFn: listAdminUsers, enabled: !isDemoMode });
const projectsQuery = useQuery({ queryKey: ['admin-projects'], queryFn: () => listProjects({ limit: 100 }), enabled: !isDemoMode });
const bannersQuery = useQuery({ queryKey: ['admin-banners'], queryFn: listAdminBanners, enabled: !isDemoMode });
const [editingProject, setEditingProject] = useState<ProjectEntity | null>(null);
const [editForm, setEditForm] = useState({ name: '', description: '' });
const [bannerForm, setBannerForm] = useState({ title: '', body: '', level: 'info' as Banner['level'] });
const invalidate = () => {
queryClient.invalidateQueries({ queryKey: ['admin-overview'] });
queryClient.invalidateQueries({ queryKey: ['admin-users'] });
queryClient.invalidateQueries({ queryKey: ['admin-projects'] });
queryClient.invalidateQueries({ queryKey: ['admin-banners'] });
queryClient.invalidateQueries({ queryKey: ['active-banners'] });
queryClient.invalidateQueries({ queryKey: ['projects'] });
};
const bannerCreateMutation = useMutation({
mutationFn: () =>
createBanner({
title: bannerForm.title.trim(),
body: bannerForm.body.trim(),
level: bannerForm.level,
}),
onSuccess: () => {
showToast('success', 'Banner published');
setBannerForm({ title: '', body: '', level: 'info' });
invalidate();
},
onError: (error) => showToast('error', 'Create failed', error instanceof Error ? error.message : undefined),
});
const bannerToggleMutation = useMutation({
mutationFn: ({ id, active }: { id: string; active: boolean }) => updateBanner(id, { active }),
onSuccess: () => invalidate(),
onError: (error) => showToast('error', 'Update failed', error instanceof Error ? error.message : undefined),
});
const bannerDeleteMutation = useMutation({
mutationFn: (id: string) => deleteBanner(id),
onSuccess: () => {
showToast('success', 'Banner deleted');
invalidate();
},
onError: (error) => showToast('error', 'Delete failed', error instanceof Error ? error.message : undefined),
});
const approvalMutation = useMutation({
mutationFn: ({ id, approved }: { id: string; approved: boolean }) => setProjectApproval(id, approved),
onSuccess: (_, vars) => {
@@ -126,6 +166,7 @@ export function AdminPage() {
const pending = overview?.pending_projects ?? [];
const users = usersQuery.data ?? [];
const allProjects = projectsQuery.data ?? [];
const banners = bannersQuery.data ?? [];
return (
<div className="mx-auto max-w-6xl px-5 py-8">
@@ -258,6 +299,85 @@ export function AdminPage() {
</div>
</section>
<section className="mt-8">
<div className="mb-3 flex items-center gap-2">
<Megaphone size={15} className="text-[var(--accent-primary)]" />
<h2 className="text-[15px] font-semibold text-[var(--text-primary)]">Announcements</h2>
</div>
<div className="overflow-hidden rounded-[var(--radius-lg)] border border-[var(--border-subtle)] bg-[var(--surface-card)]">
<div className="flex flex-wrap items-center gap-2 border-b border-[var(--border-subtle)] px-4 py-3">
<input
value={bannerForm.title}
onChange={(e) => setBannerForm((f) => ({ ...f, title: e.target.value }))}
placeholder="Title"
className="h-9 min-w-48 flex-1 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] px-3 text-sm focus:border-[var(--accent-primary)]"
/>
<input
value={bannerForm.body}
onChange={(e) => setBannerForm((f) => ({ ...f, body: e.target.value }))}
placeholder="Body (optional)"
className="h-9 min-w-48 flex-[2] rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] px-3 text-sm focus:border-[var(--accent-primary)]"
/>
<select
value={bannerForm.level}
onChange={(e) => setBannerForm((f) => ({ ...f, level: e.target.value as Banner['level'] }))}
className="h-9 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] px-2 text-sm"
>
<option value="info">info</option>
<option value="success">success</option>
<option value="warning">warning</option>
<option value="error">error</option>
</select>
<button
type="button"
onClick={() => bannerCreateMutation.mutate()}
disabled={bannerCreateMutation.isPending || bannerForm.title.trim().length === 0}
className="rounded-[var(--radius-md)] bg-[var(--accent-primary)] px-4 py-2 text-[12px] font-semibold text-[var(--accent-on)] disabled:opacity-50"
>
{bannerCreateMutation.isPending ? 'Publishing…' : 'Publish'}
</button>
</div>
{banners.map((banner) => (
<div
key={banner.id}
className="flex items-center gap-4 border-b border-[var(--border-subtle)] px-4 py-3 last:border-b-0"
>
<div className="min-w-0 flex-1">
<p className="truncate text-[13.5px] font-medium text-[var(--text-primary)]">{banner.title}</p>
<p className="truncate text-[11.5px] text-[var(--text-tertiary)]">
{banner.level}
{banner.body ? ` · ${banner.body}` : ''}
</p>
</div>
<button
type="button"
onClick={() => bannerToggleMutation.mutate({ id: banner.id, active: !banner.active })}
disabled={bannerToggleMutation.isPending}
className="rounded-[var(--radius-md)] border border-[var(--border-default)] px-3 py-1.5 text-[12px] font-medium text-[var(--text-secondary)] hover:text-[var(--text-primary)] disabled:opacity-50"
>
{banner.active ? 'Deactivate' : 'Activate'}
</button>
<button
type="button"
onClick={() => {
if (window.confirm(`Delete banner "${banner.title}"?`)) {
bannerDeleteMutation.mutate(banner.id);
}
}}
disabled={bannerDeleteMutation.isPending}
className="inline-flex items-center gap-1 rounded-[var(--radius-md)] border border-[var(--error)] px-2.5 py-1.5 text-[12px] font-medium text-[var(--error)] hover:bg-[var(--error-soft)] disabled:opacity-50"
title="Delete banner"
>
<Trash2 size={12} />
</button>
</div>
))}
{banners.length === 0 ? (
<p className="px-4 py-6 text-center text-[13px] text-[var(--text-tertiary)]">No announcements.</p>
) : null}
</div>
</section>
<section className="mt-8 pb-10">
<div className="mb-3 flex items-center gap-2">
<Users size={15} className="text-[var(--accent-tertiary)]" />
+206
View File
@@ -3809,6 +3809,212 @@ export interface paths {
patch?: never;
trace?: never;
};
"/banners/active": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/**
* Active announcement banners
* @description Banners currently active (enabled and within their optional schedule window).
*/
get: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Active banners */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": {
banners?: {
id?: string;
title?: string;
body?: string;
/** @enum {string} */
level?: "info" | "success" | "warning" | "error";
active?: boolean;
dismissible?: boolean;
/** Format: date-time */
starts_at?: string | null;
/** Format: date-time */
ends_at?: string | null;
/** Format: date-time */
created_at?: string;
}[];
};
};
};
};
};
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/admin/banners": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/** List all banners (admin) */
get: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description All banners */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": {
banners?: Record<string, never>[];
};
};
};
};
};
put?: never;
/** Create a banner (admin) */
post: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody: {
content: {
"application/json": {
title: string;
body?: string;
/**
* @default info
* @enum {string}
*/
level?: "info" | "success" | "warning" | "error";
/** @default true */
active?: boolean;
/** @default true */
dismissible?: boolean;
/** Format: date-time */
starts_at?: string;
/** Format: date-time */
ends_at?: string;
};
};
};
responses: {
/** @description Created banner */
201: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": Record<string, never>;
};
};
};
};
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/admin/banners/{id}": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
post?: never;
/** Delete a banner (admin) */
delete: {
parameters: {
query?: never;
header?: never;
path: {
id: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Deleted */
204: {
headers: {
[name: string]: unknown;
};
content?: never;
};
};
};
options?: never;
head?: never;
/** Update a banner (admin) */
patch: {
parameters: {
query?: never;
header?: never;
path: {
id: string;
};
cookie?: never;
};
requestBody: {
content: {
"application/json": {
title?: string;
body?: string;
/** @enum {string} */
level?: "info" | "success" | "warning" | "error";
active?: boolean;
dismissible?: boolean;
/** Format: date-time */
starts_at?: string;
/** Format: date-time */
ends_at?: string;
};
};
};
responses: {
/** @description Updated banner */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": Record<string, never>;
};
};
};
};
trace?: never;
};
"/audit-logs": {
parameters: {
query?: never;
+44
View File
@@ -1866,6 +1866,50 @@ export async function getOperations(): Promise<OperationsView> {
return requestJson<OperationsView>('/operations');
}
export type Banner = {
id: string;
title: string;
body: string;
level: 'info' | 'success' | 'warning' | 'error';
active: boolean;
dismissible: boolean;
starts_at?: string | null;
ends_at?: string | null;
created_at: string;
};
export type BannerInput = {
title?: string;
body?: string;
level?: Banner['level'];
active?: boolean;
dismissible?: boolean;
starts_at?: string;
ends_at?: string;
};
export async function listActiveBanners(): Promise<Banner[]> {
const payload = await requestJson<{ banners?: Banner[] }>('/banners/active');
return payload.banners ?? [];
}
export async function listAdminBanners(): Promise<Banner[]> {
const payload = await requestJson<{ banners?: Banner[] }>('/admin/banners');
return payload.banners ?? [];
}
export async function createBanner(input: BannerInput): Promise<Banner> {
return requestJson<Banner>('/admin/banners', { method: 'POST', body: JSON.stringify(input) });
}
export async function updateBanner(id: string, input: BannerInput): Promise<Banner> {
return requestJson<Banner>(`/admin/banners/${id}`, { method: 'PATCH', body: JSON.stringify(input) });
}
export async function deleteBanner(id: string): Promise<void> {
await requestJson(`/admin/banners/${id}`, { method: 'DELETE' });
}
export function serviceStatusClass(status: ServiceStatus): string {
switch (status) {
case 'running':
@@ -0,0 +1,85 @@
import { useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import { AlertTriangle, CheckCircle2, Info, Megaphone, X, XCircle } from 'lucide-react';
import { listActiveBanners, type Banner } from '@/lib/api-client';
import { useDemoMode } from '@/lib/demo-mode';
const DISMISSED_KEY = 'containr.dismissed-banners';
function dismissedIds(): Set<string> {
try {
return new Set(JSON.parse(localStorage.getItem(DISMISSED_KEY) ?? '[]'));
} catch {
return new Set();
}
}
const levelStyle: Record<Banner['level'], { bg: string; fg: string; icon: typeof Info }> = {
info: { bg: 'var(--accent-primary-soft)', fg: 'var(--accent-primary)', icon: Info },
success: { bg: 'var(--success-soft)', fg: 'var(--success)', icon: CheckCircle2 },
warning: { bg: 'var(--warning-soft)', fg: 'var(--warning)', icon: AlertTriangle },
error: { bg: 'var(--error-soft)', fg: 'var(--error)', icon: XCircle },
};
export function BannerBar() {
const isDemoMode = useDemoMode();
const [dismissed, setDismissed] = useState<Set<string>>(dismissedIds);
const bannersQuery = useQuery({
queryKey: ['active-banners'],
queryFn: listActiveBanners,
enabled: !isDemoMode,
refetchInterval: 60_000,
staleTime: 30_000,
});
const banners = (bannersQuery.data ?? []).filter((b) => !dismissed.has(b.id));
if (banners.length === 0) return null;
const dismiss = (id: string) => {
setDismissed((prev) => {
const next = new Set(prev);
next.add(id);
localStorage.setItem(DISMISSED_KEY, JSON.stringify([...next]));
return next;
});
};
return (
<div className="shrink-0">
{banners.map((banner) => {
const style = levelStyle[banner.level] ?? levelStyle.info;
const Icon = banner.level === 'info' ? Megaphone : style.icon;
return (
<div
key={banner.id}
className="flex items-center gap-3 border-b border-[var(--border-subtle)] px-5 py-2"
style={{ background: style.bg }}
role="status"
>
<Icon size={14} className="shrink-0" style={{ color: style.fg }} />
<div className="min-w-0 flex-1">
<span className="text-[12.5px] font-semibold" style={{ color: style.fg }}>
{banner.title}
</span>
{banner.body ? (
<span className="ml-2 text-[12px] text-[var(--text-secondary)]">{banner.body}</span>
) : null}
</div>
{banner.dismissible ? (
<button
type="button"
onClick={() => dismiss(banner.id)}
className="shrink-0 rounded p-0.5 hover:bg-black/10"
style={{ color: style.fg }}
aria-label="Dismiss announcement"
>
<X size={14} />
</button>
) : null}
</div>
);
})}
</div>
);
}
@@ -1,3 +1,4 @@
export * from './BannerBar';
export * from './DemoGate';
export * from './ErrorBoundary';
export * from './LoadingSpinner';