Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions .github/workflows/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 (<https://drexeltriangle.github.io/triangle-cms/>). 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.
30 changes: 30 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
66 changes: 66 additions & 0 deletions .github/workflows/pages.yml
Original file line number Diff line number Diff line change
@@ -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
9 changes: 8 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
<https://drexeltriangle.github.io/triangle-cms/> — 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.

Expand Down
41 changes: 41 additions & 0 deletions docs/api/index.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Triangle CMS API</title>
<!-- Assets are vendored next to this file by .github/workflows/pages.yml,
so the published page loads nothing from a third-party CDN. -->
<link rel="stylesheet" href="./swagger-ui.css" />
<style>
body { margin: 0; background: #fafafa; }
/* The spec's `host` is the local dev server, so "Try it out" would fire
requests at the reader's own machine. Hide it: this page is reference
documentation, not a live client. */
.swagger-ui .try-out { display: none; }
/* Nothing is submittable, so the Authorize dialog has no effect. The
per-operation padlocks stay, so readers still see what needs a token. */
.swagger-ui .auth-wrapper { display: none; }
</style>
</head>
<body>
<div id="swagger-ui"></div>
<script src="./swagger-ui-bundle.js" crossorigin="anonymous"></script>
<script src="./swagger-ui-standalone-preset.js" crossorigin="anonymous"></script>
<script>
window.onload = function () {
window.ui = SwaggerUIBundle({
url: './swagger.json',
dom_id: '#swagger-ui',
deepLinking: true,
supportedSubmitMethods: [],
docExpansion: 'none',
defaultModelsExpandDepth: 0,
presets: [SwaggerUIBundle.presets.apis, SwaggerUIStandalonePreset],
plugins: [SwaggerUIBundle.plugins.DownloadUrl],
layout: 'BaseLayout',
});
};
</script>
</body>
</html>
13 changes: 6 additions & 7 deletions frontend/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ const AUTH_ROUTES = ["/login"]
function RouteFallback() {
return (
<div className="flex items-center justify-center h-full">
<p className="text-sm text-muted-foreground">Loading</p>
<p className="text-sm text-muted-foreground">Loading...</p>
</div>
)
}
Expand All @@ -57,9 +57,8 @@ function AppShell({ children }: { children: React.ReactNode }) {
function ComingSoon({ page }: { page: string }) {
return (
<div className="flex flex-col items-center justify-center h-full gap-3 text-muted-foreground">
<div className="w-12 h-12 rounded-xl bg-muted flex items-center justify-center text-2xl">🚧</div>
<p className="font-semibold text-foreground">{page}</p>
<p className="text-sm">This page is coming soon.</p>
<p className="text-sm">This screen is not available yet.</p>
</div>
)
}
Expand All @@ -70,7 +69,7 @@ function AdminOnlyRoute({ children }: { children: React.ReactNode }) {
if (isLoading) {
return (
<div className="min-h-screen flex items-center justify-center">
<p className="text-sm text-muted-foreground">Loading</p>
<p className="text-sm text-muted-foreground">Loading...</p>
</div>
)
}
Expand All @@ -90,7 +89,7 @@ export default function App() {
if (auth.isLoading) {
return (
<div className="min-h-screen flex items-center justify-center">
<p className="text-sm text-muted-foreground">Loading</p>
<p className="text-sm text-muted-foreground">Loading...</p>
</div>
)
}
Expand All @@ -108,7 +107,7 @@ export default function App() {
if (auth.hasPendingAuthFlow) {
return (
<div className="min-h-screen flex items-center justify-center">
<p className="text-sm text-muted-foreground">Finalizing sign-in</p>
<p className="text-sm text-muted-foreground">Finalizing sign-in...</p>
</div>
)
}
Expand All @@ -125,7 +124,7 @@ export default function App() {
<Route path="/developing-stories" element={<DevelopingStoriesView />} />
<Route path="/developing-stories/:slug/edit" element={<EditArticleView />} />
<Route path="/articles/new" element={<EditArticleView />} />
<Route path="/developing-stories/new" element={<ComingSoon page="New Developing Story" />} />
<Route path="/developing-stories/new" element={<ComingSoon page="New developing story" />} />
<Route path="/newsletter" element={<NewsletterView />} />
<Route path="/media" element={<MediaView />} />
<Route path="/poll" element={<PollView />} />
Expand Down
3 changes: 1 addition & 2 deletions frontend/src/auth/adminOnlyNotice.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -53,8 +53,7 @@ export function AdminOnlyNoticeProvider({ children }: { children: React.ReactNod
Admin only
</h2>
<p className="text-sm text-muted-foreground">
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.
</p>
</div>
</div>
Expand Down
32 changes: 20 additions & 12 deletions frontend/src/components/FooterMenuEditor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<FooterEntry>
const kind: FooterEntryKind =
Expand Down Expand Up @@ -82,15 +91,15 @@ 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) {
setColumns(loaded)
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)
}
Expand Down Expand Up @@ -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 }
Expand All @@ -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)
}
Expand All @@ -164,23 +173,22 @@ export default function FooterMenuEditor() {

return (
<SettingsSection
title="Footer Menu"
title="Footer"
storageKey="footer"
dirty={dirty}
summary={
loading
? "Loading"
? "Loading..."
: `${columns.length} column${columns.length === 1 ? "" : "s"}`
}
description="Link columns shown in the public site footer, laid out left to right as they appear on the site. Headings are the bold entries; a spacer starts a new group inside the same column. Saving an empty menu restores the built-in default."
description="Links shown in the public site footer. Columns appear left to right; headings are bold entries; spacers split groups inside a column."
>
{loading ? (
<p className="text-sm text-muted-foreground">Loading</p>
<p className="text-sm text-muted-foreground">Loading...</p>
) : columns.length === 0 ? (
<div className="rounded-lg border border-dashed border-border p-8 flex flex-col items-center gap-3 text-center">
<p className="text-sm text-muted-foreground max-w-sm">
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.
</p>
<button
type="button"
Expand Down Expand Up @@ -335,7 +343,7 @@ export default function FooterMenuEditor() {
aria-label="Link target"
value={entry.href}
onChange={(e) => updateEntry(columnIndex, entryIndex, { href: e.target.value })}
placeholder="/section or https://"
placeholder="/section or https://..."
className={`${inputClass} text-muted-foreground`}
/>
</>
Expand Down Expand Up @@ -378,7 +386,7 @@ export default function FooterMenuEditor() {
disabled={saving || loading || !dirty}
className="inline-flex items-center gap-2 px-4 py-2 rounded-lg bg-primary text-primary-foreground text-sm font-medium hover:bg-primary/90 disabled:opacity-60"
>
Save Footer
Save footer
</button>
{dirty && !saving && <span className="text-sm text-muted-foreground">Unsaved changes</span>}
{message && <span className="text-sm text-muted-foreground">{message}</span>}
Expand Down
6 changes: 3 additions & 3 deletions frontend/src/components/MediaPicker.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 ?? []
Expand Down Expand Up @@ -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}
/>
Expand Down Expand Up @@ -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}
/>
Expand Down
2 changes: 1 addition & 1 deletion frontend/src/pages/AuthCallback.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ export default function AuthCallback() {

return (
<div className="min-h-screen flex items-center justify-center">
<p className="text-sm text-muted-foreground">Signing you in</p>
<p className="text-sm text-muted-foreground">Signing you in...</p>
</div>
)
}
Loading
Loading