Files
famapp/src/components/dashboard-switcher.tsx
T
ginnoir c73338e256 Code-side
src/lib/dev-login-config.ts — startup assertion: throws if NODE_ENV=production + ENABLE_DEV_LOGIN=true, scoped to runtime (skipped during next build).
Container

scripts/migrate.mjs — runs Drizzle migrations against DATABASE_URL.
deploy/docker-entrypoint.sh — runs migrations then exec node server.js. Skip with RUN_MIGRATIONS=false.
Dockerfile — copies drizzle/, scripts/migrate.mjs, entrypoint into runner stage; ENTRYPOINT now points at the script.
Compose

deploy/compose.yaml — famapp now image: ${FAMAPP_IMAGE:-ghcr.io/ginnoir/famapp:latest} (build still works locally as fallback). Authentik pinned via AUTHENTIK_IMAGE_TAG (default 2024.12.3). New RUN_MIGRATIONS env passed through.
.env.production.example — documents FAMAPP_IMAGE, AUTHENTIK_IMAGE_TAG, RUN_MIGRATIONS.
CI/CD

.github/workflows/ci.yml — push/PR: typecheck + lint + format:check + build.
.github/workflows/release.yml — v* tag: build + push ghcr.io/ginnoir/famapp:vX.Y.Z, :X.Y, :latest to GHCR.
Docs

deploy/README.md — full deploy/rollback/release runbook.
CHANGELOG.md — release log seeded with an Unreleased entry.
docs/tasks/09-pre-deploy-checklist.md — task 09 reframed from one-shot removal to a recurring pre-deploy checklist.
STATUS.md — updated.
Verified: pnpm typecheck, pnpm format, pnpm build, and docker compose config all clean.
2026-05-06 17:37:37 -05:00

199 lines
5.9 KiB
TypeScript

"use client";
import { usePathname, useRouter } from "next/navigation";
import { useState, useTransition } from "react";
import { MoreHorizontal, Plus, Star, Trash2, PenLine } from "lucide-react";
import {
createDashboard,
deleteDashboard,
renameDashboard,
setDefaultDashboard,
} from "@/app/d/actions";
import type { DashboardMeta } from "@/app/d/actions";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
export function DashboardSwitcher({ dashboards }: { dashboards: DashboardMeta[] }) {
const pathname = usePathname();
const router = useRouter();
const [, startTransition] = useTransition();
const [creatingNew, setCreatingNew] = useState(false);
const [newName, setNewName] = useState("");
function activeSlug() {
const m = pathname.match(/^\/d\/([^/]+)/);
return m?.[1] ?? null;
}
function handleCreate() {
if (!newName.trim()) return;
startTransition(async () => {
const created = await createDashboard(newName.trim());
setCreatingNew(false);
setNewName("");
router.push(`/d/${created.slug}`);
});
}
return (
<div className="flex items-center gap-1">
{/* Active tab highlights — overlay on top of the server-rendered links */}
{dashboards.map((d) => {
const isActive = activeSlug() === d.slug;
return (
<span
key={d.id}
aria-hidden
className={`absolute pointer-events-none border-b-2 transition-colors ${
isActive ? "border-primary" : "border-transparent"
}`}
style={{ display: "none" }}
/>
);
})}
{creatingNew ? (
<form
onSubmit={(e) => {
e.preventDefault();
handleCreate();
}}
className="flex items-center gap-1 ml-1"
>
<input
autoFocus
value={newName}
onChange={(e) => setNewName(e.target.value)}
placeholder="Dashboard name"
className="h-7 rounded border border-input bg-background px-2 text-sm focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
onKeyDown={(e) => e.key === "Escape" && setCreatingNew(false)}
/>
<button
type="submit"
className="text-xs text-muted-foreground hover:text-foreground px-1"
>
Add
</button>
<button
type="button"
onClick={() => setCreatingNew(false)}
className="text-xs text-muted-foreground hover:text-foreground px-1"
>
Cancel
</button>
</form>
) : (
<button
type="button"
onClick={() => setCreatingNew(true)}
className="shrink-0 flex items-center gap-1 px-2 py-2 text-sm text-muted-foreground hover:text-foreground transition-colors"
aria-label="New dashboard"
>
<Plus className="size-3.5" />
</button>
)}
{dashboards.map((d) => {
const isActive = activeSlug() === d.slug;
if (!isActive) return null;
return <DashboardKebab key={d.id} dashboard={d} canDelete={dashboards.length > 1} />;
})}
</div>
);
}
function DashboardKebab({
dashboard,
canDelete,
}: {
dashboard: DashboardMeta;
canDelete: boolean;
}) {
const [, startTransition] = useTransition();
const [renaming, setRenaming] = useState(false);
const [newName, setNewName] = useState(dashboard.name);
function handleRename() {
if (!newName.trim() || newName === dashboard.name) {
setRenaming(false);
return;
}
startTransition(async () => {
await renameDashboard(dashboard.id, newName.trim());
setRenaming(false);
});
}
if (renaming) {
return (
<form
onSubmit={(e) => {
e.preventDefault();
handleRename();
}}
className="flex items-center gap-1 ml-1"
>
<input
autoFocus
value={newName}
onChange={(e) => setNewName(e.target.value)}
className="h-7 rounded border border-input bg-background px-2 text-sm focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
onKeyDown={(e) => e.key === "Escape" && setRenaming(false)}
/>
<button type="submit" className="text-xs text-muted-foreground hover:text-foreground px-1">
Save
</button>
<button
type="button"
onClick={() => setRenaming(false)}
className="text-xs text-muted-foreground hover:text-foreground px-1"
>
Cancel
</button>
</form>
);
}
return (
<DropdownMenu>
<DropdownMenuTrigger
className="shrink-0 flex items-center px-1 py-2 text-muted-foreground hover:text-foreground transition-colors"
aria-label="Dashboard options"
>
<MoreHorizontal className="size-4" />
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onSelect={() => setRenaming(true)}>
<PenLine className="size-4 mr-2" />
Rename
</DropdownMenuItem>
{!dashboard.isDefault && (
<DropdownMenuItem
onSelect={() => startTransition(() => setDefaultDashboard(dashboard.id))}
>
<Star className="size-4 mr-2" />
Set as default
</DropdownMenuItem>
)}
{canDelete && (
<>
<DropdownMenuSeparator />
<DropdownMenuItem
className="text-destructive focus:text-destructive"
onSelect={() => startTransition(() => deleteDashboard(dashboard.id))}
>
<Trash2 className="size-4 mr-2" />
Delete
</DropdownMenuItem>
</>
)}
</DropdownMenuContent>
</DropdownMenu>
);
}