diff --git a/.github/workflows/README.md b/.github/workflows/README.md index fe74f65..7bfa79c 100644 --- a/.github/workflows/README.md +++ b/.github/workflows/README.md @@ -8,6 +8,11 @@ Compose validation, and the deployment script test suite selection, the transactional Nginx switch and its restore-on-failure paths, and the preflight checks. +`ci.yml`'s `swagger-docs` job regenerates `server/docs` with the pinned `swag` +version from `server/Dockerfile` and fails on any diff. The image build already +regenerates the spec, so the binary is always current; this guards the +*committed* copy, which is what `pages.yml` publishes. + `publish.yml` runs only from a successful `CI` workflow run on `main` that was triggered by a trusted push. It validates `github.event.workflow_run.head_sha` as a 40-character hexadecimal SHA and @@ -27,3 +32,10 @@ deployment. `slot: auto` targets whichever slot is currently inactive; `blue` or `green` names one explicitly. To recover an *older* image SHA instead, run `deploy.yml` manually with that SHA — rollback only moves traffic between the two slots that are already up. + +`pages.yml` publishes the committed Swagger spec as a static Swagger UI site on +GitHub Pages (). It is independent of +the CI -> Publish -> Deploy chain: it holds no `packages` permission, touches no +slot, and never runs on the self-hosted runner. Swagger UI's assets are vendored +into the artifact at build time from a pinned `swagger-ui-dist`, so the published +page loads nothing from a third-party CDN at runtime. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c54be76..03c10d8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -56,6 +56,36 @@ jobs: - name: Build run: npm run build + # The Docker build regenerates the Swagger docs from the handler annotations, + # so the binary always serves a current spec -- but server/docs is also + # committed, and the Pages site publishes that committed copy. Nothing forced + # the two to agree, so annotations could change without a follow-up + # `swag init` and the published reference would drift from the real API with + # no signal. Regenerate here and fail on any diff. + swagger-docs: + runs-on: ubuntu-latest + defaults: + run: + working-directory: server + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-go@v5 + with: + go-version-file: server/go.mod + cache-dependency-path: server/go.sum + # Pinned to the same version server/Dockerfile installs. A different + # version would report drift the real build never produces. + - name: Install swag + run: go install github.com/swaggo/swag/cmd/swag@v1.16.6 + - name: Regenerate docs + run: swag init --parseDependency --parseInternal + - name: Committed docs are current + run: | + if ! git diff --exit-code -- docs; then + echo "::error file=server/docs/swagger.json::server/docs is stale. Run 'swag init --parseDependency --parseInternal' in server/ and commit the result." + exit 1 + fi + docker-builds: runs-on: ubuntu-latest steps: diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml new file mode 100644 index 0000000..fbffec4 --- /dev/null +++ b/.github/workflows/pages.yml @@ -0,0 +1,66 @@ +name: Publish API Docs + +# Publishes the committed Swagger spec (server/docs/swagger.json) as a static +# Swagger UI site on GitHub Pages, so the API reference is readable without +# running the server or reaching the VPN-only deployment. It publishes the spec +# that is checked in — the same one the binary embeds and serves at /swagger/ — +# rather than regenerating it, so the page always matches the deployed API +# surface. Refresh it by running `swag init` and committing the result. + +on: + push: + branches: + - main + paths: + - server/docs/swagger.json + - docs/api/** + - .github/workflows/pages.yml + workflow_dispatch: + +# Pages deployments are not slot-switched like the app: one site, last write +# wins. Serialise them, but let a newer commit supersede a queued older one. +concurrency: + group: pages + cancel-in-progress: true + +permissions: + contents: read + +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Assemble site + env: + # Pinned: an unpinned CDN or range would let the docs page change + # under us without a commit. + SWAGGER_UI_VERSION: 5.32.14 + run: | + set -euo pipefail + mkdir -p site + cp docs/api/index.html site/ + cp server/docs/swagger.json site/ + npm pack "swagger-ui-dist@${SWAGGER_UI_VERSION}" + tar -xzf "swagger-ui-dist-${SWAGGER_UI_VERSION}.tgz" + for asset in swagger-ui.css swagger-ui-bundle.js swagger-ui-standalone-preset.js; do + cp "package/${asset}" site/ + done + + - uses: actions/upload-pages-artifact@v3 + with: + path: site + + deploy: + needs: build + runs-on: ubuntu-latest + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + permissions: + pages: write + id-token: write + steps: + - id: deployment + uses: actions/deploy-pages@v4 diff --git a/README.md b/README.md index 43420c7..0321f03 100644 --- a/README.md +++ b/README.md @@ -248,7 +248,14 @@ Generated files: ## API Docs (Swagger) -Swagger UI is available at `https://localhost:8080/swagger/index.html` when the server is running. +A read-only copy of the API reference is published to GitHub Pages at + — no server, no VPN. It renders the +`server/docs/swagger.json` committed on `main`, so it is only as current as the last +`swag init` that was committed, and "Try it out" is disabled (the spec's host is the +local dev server). + +The live, interactive Swagger UI is available at `https://localhost:8080/swagger/index.html` +when the server is running. The docs are generated automatically during the Docker build — no manual step needed. diff --git a/docs/api/index.html b/docs/api/index.html new file mode 100644 index 0000000..bd0e444 --- /dev/null +++ b/docs/api/index.html @@ -0,0 +1,41 @@ + + + + + + Triangle CMS API + + + + + +
+ + + + + diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 3e90e91..91bcdff 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -37,7 +37,7 @@ const AUTH_ROUTES = ["/login"] function RouteFallback() { return (
-

Loading…

+

Loading...

) } @@ -57,9 +57,8 @@ function AppShell({ children }: { children: React.ReactNode }) { function ComingSoon({ page }: { page: string }) { return (
-
🚧

{page}

-

This page is coming soon.

+

This screen is not available yet.

) } @@ -70,7 +69,7 @@ function AdminOnlyRoute({ children }: { children: React.ReactNode }) { if (isLoading) { return (
-

Loading…

+

Loading...

) } @@ -90,7 +89,7 @@ export default function App() { if (auth.isLoading) { return (
-

Loading…

+

Loading...

) } @@ -108,7 +107,7 @@ export default function App() { if (auth.hasPendingAuthFlow) { return (
-

Finalizing sign-in…

+

Finalizing sign-in...

) } @@ -125,7 +124,7 @@ export default function App() { } /> } /> } /> - } /> + } /> } /> } /> } /> diff --git a/frontend/src/auth/adminOnlyNotice.tsx b/frontend/src/auth/adminOnlyNotice.tsx index 4ff1bc6..6ee1b75 100644 --- a/frontend/src/auth/adminOnlyNotice.tsx +++ b/frontend/src/auth/adminOnlyNotice.tsx @@ -53,8 +53,7 @@ export function AdminOnlyNoticeProvider({ children }: { children: React.ReactNod Admin only

- This change needs an admin account, so it was not saved. Ask a web admin to make it - for you. + This action requires an admin account. Nothing was saved.

diff --git a/frontend/src/components/FooterMenuEditor.tsx b/frontend/src/components/FooterMenuEditor.tsx index 31c80aa..eb52f78 100644 --- a/frontend/src/components/FooterMenuEditor.tsx +++ b/frontend/src/components/FooterMenuEditor.tsx @@ -39,6 +39,15 @@ const inputClass = "w-full px-2.5 py-1.5 rounded-md border border-border bg-background text-sm focus:outline-none focus:ring-2 focus:ring-primary/40" const iconButtonClass = "p-1 rounded-md hover:bg-muted disabled:opacity-30 disabled:hover:bg-transparent" +async function readErrorMessage(res: Response, fallback: string) { + try { + const body = await res.json() as { error?: string } + return body.error?.trim() || fallback + } catch { + return fallback + } +} + function normalizeEntry(raw: unknown): FooterEntry { const entry = (raw ?? {}) as Partial const kind: FooterEntryKind = @@ -82,7 +91,7 @@ export default function FooterMenuEditor() { async function loadFooter() { try { const res = await apiFetch("/v1/settings/footer") - if (!res.ok) throw new Error(`Failed to load footer settings (${res.status})`) + if (!res.ok) throw new Error(await readErrorMessage(res, `Could not load footer settings (${res.status})`)) const body = (await res.json()) as { columns?: unknown } const loaded = normalizeColumns(body.columns) if (!cancelled) { @@ -90,7 +99,7 @@ export default function FooterMenuEditor() { setSaved(JSON.stringify(loaded)) } } catch (err) { - if (!cancelled) setMessage(err instanceof Error ? err.message : "Failed to load footer settings") + if (!cancelled) setMessage(err instanceof Error ? err.message : "Could not load footer settings.") } finally { if (!cancelled) setLoading(false) } @@ -145,7 +154,7 @@ export default function FooterMenuEditor() { headers: { "Content-Type": "application/json" }, body: JSON.stringify({ columns }), }) - if (!res.ok) throw new Error(`Failed to save footer settings (${res.status})`) + if (!res.ok) throw new Error(await readErrorMessage(res, `Could not save footer settings (${res.status})`)) // The server drops unlabelled entries and empty columns, so render what // it stored rather than the draft. const body = (await res.json()) as { columns?: unknown } @@ -154,7 +163,7 @@ export default function FooterMenuEditor() { setSaved(JSON.stringify(stored)) setMessage("Saved") } catch (err) { - setMessage(err instanceof Error ? err.message : "Failed to save footer settings") + setMessage(err instanceof Error ? err.message : "Could not save footer settings.") } finally { setSaving(false) } @@ -164,23 +173,22 @@ export default function FooterMenuEditor() { return ( {loading ? ( -

Loading…

+

Loading...

) : columns.length === 0 ? (

- The footer menu is empty. Add a column to build it, or save as-is to restore the built-in - default footer. + The footer menu is empty. Add a column, or save to restore the default footer.

{dirty && !saving && Unsaved changes} {message && {message}} diff --git a/frontend/src/components/MediaPicker.tsx b/frontend/src/components/MediaPicker.tsx index 01a4eb8..d3e4608 100644 --- a/frontend/src/components/MediaPicker.tsx +++ b/frontend/src/components/MediaPicker.tsx @@ -107,7 +107,7 @@ function MediaPicker({ onSelect, onClose, title = "Insert image", onUseUrl, init const response = await apiFetch(`/v1/media/gallery?${params.toString()}`, { signal: controller.signal, }) - if (!response.ok) throw new Error(await errorMessage(response, `Request failed (${response.status})`)) + if (!response.ok) throw new Error(await errorMessage(response, `Could not load media (${response.status})`)) const payload = (await response.json()) as GalleryResponse if (controller.signal.aborted) return const page = payload.media ?? [] @@ -228,7 +228,7 @@ function MediaPicker({ onSelect, onClose, title = "Insert image", onUseUrl, init autoFocus className="w-full rounded-lg border border-border bg-background py-2 pl-9 pr-4 text-sm text-foreground placeholder:text-muted-foreground focus:border-primary focus:outline-none focus:ring-2 focus:ring-primary/40" onChange={(e) => setSearchInput(e.target.value)} - placeholder="Search by file name, alt text, or caption..." + placeholder="Search file name, alt text, or caption" type="search" value={searchInput} /> @@ -316,7 +316,7 @@ function MediaPicker({ onSelect, onClose, title = "Insert image", onUseUrl, init aria-label="Image URL" className="flex-1 rounded-lg border border-border bg-background px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground focus:border-primary focus:outline-none focus:ring-2 focus:ring-primary/40" onChange={(e) => setUrlInput(e.target.value)} - placeholder="Or paste image URL" + placeholder="Paste image URL" type="url" value={urlInput} /> diff --git a/frontend/src/pages/AuthCallback.tsx b/frontend/src/pages/AuthCallback.tsx index 85441e6..3bf9d28 100644 --- a/frontend/src/pages/AuthCallback.tsx +++ b/frontend/src/pages/AuthCallback.tsx @@ -27,7 +27,7 @@ export default function AuthCallback() { return (
-

Signing you in…

+

Signing you in...

) } diff --git a/frontend/src/pages/DashboardPage.tsx b/frontend/src/pages/DashboardPage.tsx index d2387a0..8b3c5d5 100644 --- a/frontend/src/pages/DashboardPage.tsx +++ b/frontend/src/pages/DashboardPage.tsx @@ -284,12 +284,10 @@ export default function DashboardPage() { {/* Page header */}
-

- COMMAND CENTER -

+

Dashboard

{getHour()}, {displayName}.

- Here's what's happening at The Triangle today. + Recent CMS activity and quick drafting tools.

@@ -313,7 +311,7 @@ export default function DashboardPage() {
{[ { - label: "Total Articles", + label: "Articles", value: stats.totalArticles != null ? stats.totalArticles.toLocaleString() : "—", icon: FileText, color: "text-primary", @@ -366,7 +364,7 @@ export default function DashboardPage() { }, { label: "API Status", - value: apiHealth === "checking" ? "…" : apiHealth === "ok" ? "Healthy" : "Error", + value: apiHealth === "checking" ? "..." : apiHealth === "ok" ? "Healthy" : "Error", icon: Server, color: apiHealth === "ok" ? "text-success" : apiHealth === "error" ? "text-destructive" : "text-muted-foreground", bg: apiHealth === "ok" ? "bg-success/10" : apiHealth === "error" ? "bg-destructive/10" : "bg-muted", @@ -414,10 +412,10 @@ export default function DashboardPage() {
- All Recent Articles + Recent Articles
@@ -488,7 +486,7 @@ export default function DashboardPage() { />
- +