Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
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
6 changes: 6 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -62,3 +62,9 @@ cli/gonext/gonext
.dev/
tmp/
*.local

# Personal Compose override. Ships as docker-compose.override.example.yml
# in the repo — copy it locally and tweak. Compose auto-loads
# docker-compose.override.yml without naming it on the CLI, so we must
# keep the live file out of git.
docker-compose.override.yml
14 changes: 14 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,20 @@ To stop everything (volumes preserved): `make down`. To wipe state and start ove

---

## Local dev setup

Before `make up`, copy the personal Compose override into place. It pins the admin build args, remaps Postgres to `5433` to dodge native-Postgres clashes, and aligns the `GONEXT_AUTH_*` secrets across `api` / `worker` / `migrate` so sessions and password hashes line up.

```bash
cp docker-compose.override.example.yml docker-compose.override.yml
# Optionally edit the auth secrets — defaults are dev-only and safe to keep
docker compose up -d
```

The live `docker-compose.override.yml` is gitignored so each contributor keeps their own. See [Port conflicts](#port-conflicts-the-docker-composeoverrideyml-pattern) below for the full rationale.

---

## Local development tips

A few things that bit us when we ran this for the first time — fix them up front and the stack just works.
Expand Down
17 changes: 17 additions & 0 deletions apps/admin/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -72,10 +72,15 @@ COPY apps/admin/ apps/admin/
COPY packages/ts/ packages/ts/

# Build-time configuration plumbing for Next.js public env vars.
# GONEXT_API_URL is consumed by next.config.ts's `rewrites()` at build
# time — must be the docker-internal API hostname (e.g. http://api:8080)
# so server-side proxy hops work after the bundle is baked.
ARG NEXT_PUBLIC_API_URL=""
ARG NEXT_PUBLIC_VERSION="dev"
ARG GONEXT_API_URL=""
ENV NEXT_PUBLIC_API_URL=${NEXT_PUBLIC_API_URL} \
NEXT_PUBLIC_VERSION=${NEXT_PUBLIC_VERSION} \
GONEXT_API_URL=${GONEXT_API_URL} \
NEXT_TELEMETRY_DISABLED=1 \
NODE_ENV=production

Expand Down Expand Up @@ -113,6 +118,18 @@ COPY --from=builder --chown=node:node /repo/node_modules ./node_modules
COPY --from=builder --chown=node:node /repo/apps/admin ./apps/admin
COPY --from=builder --chown=node:node /repo/packages/ts ./packages/ts

# Next.js `output: 'standalone'` ships a minimal node_modules tree at
# .next/standalone/, but DOES NOT copy .next/static or public/ into
# that tree (the docs explicitly say the operator must do this). When
# the entrypoint runs node .next/standalone/apps/admin/server.js, the
# server's static handler looks for chunks at
# .next/standalone/apps/admin/.next/static/* — which 404s without these
# COPYs and breaks every JS/CSS chunk load on every page.
COPY --from=builder --chown=node:node /repo/apps/admin/.next/static \
./apps/admin/.next/standalone/apps/admin/.next/static
COPY --from=builder --chown=node:node /repo/apps/admin/public \
./apps/admin/.next/standalone/apps/admin/public

USER node

EXPOSE 3000
Expand Down
17 changes: 17 additions & 0 deletions apps/admin/next.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,23 @@ const nextConfig: NextConfig = {
// typedRoutes moved out of `experimental` in Next.js 15; opt in at the
// top level so `<Link href>` values are checked at build time.
typedRoutes: true,
/**
* Proxy /api/* to the GoNext API service. When the bundle ships with
* NEXT_PUBLIC_API_URL="" (the docker-compose default) the browser
* uses same-origin fetches against /api/*. This rewrite hops those
* requests over to the docker-internal API hostname so dev / preview
* environments don't need a separate reverse proxy.
*
* rewrites() evaluates at build time, so GONEXT_API_URL must be set
* as a build-arg (see apps/admin/Dockerfile + docker-compose.override.yml).
*/
async rewrites() {
const apiUrl =
process.env.GONEXT_API_URL ||
process.env.NEXT_PUBLIC_API_URL ||
'http://localhost:8080';
return [{ source: '/api/:path*', destination: `${apiUrl}/api/:path*` }];
},
async headers() {
return [
{
Expand Down
25 changes: 21 additions & 4 deletions apps/admin/src/app/(authenticated)/_components/TopHeader.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,21 @@ import type { ReactElement } from 'react';
import Link from 'next/link';
import { Bell, ExternalLink } from 'lucide-react';

/**
* Resolve the public-site URL for the "View site" link. NEXT_PUBLIC_SITE_URL
* is the operator-set canonical front-end origin (production: the marketing
* site). When unset we fall back to the docker-compose web service on
* port 3000 — operators running the local stack expect that to open the
* @gonext/web preview, not the admin's own root.
*
* The constant is evaluated at module-load time so the link href is
* deterministic across renders.
*/
const SITE_URL =
process.env.NEXT_PUBLIC_SITE_URL && process.env.NEXT_PUBLIC_SITE_URL !== ''
? process.env.NEXT_PUBLIC_SITE_URL
: 'http://localhost:3000';

export function TopHeader(): ReactElement {
return (
<header className="app-shell__header" role="banner">
Expand All @@ -28,14 +43,16 @@ export function TopHeader(): ReactElement {
<span className="app-shell__brand-tag">Admin</span>
</Link>
<div className="app-shell__header-actions">
<Link
href="/"
<a
href={SITE_URL}
target="_blank"
rel="noopener noreferrer"
className="app-shell__view-site"
aria-label="View site"
aria-label="View site (opens in new tab)"
>
<ExternalLink aria-hidden="true" width={13} height={13} />
View site
</Link>
</a>
<button
type="button"
className="app-shell__icon-btn"
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
/**
* Theme Customizer — server-only API helpers.
*
* Companion to `./api.ts`; this file is the server-side variant of
* `fetchActive` that forwards the operator's session cookie via
* `next/headers`. Kept separate because `next/headers` cannot be
* bundled into a Client Component and the client-side `./api.ts`
* module IS imported by mutation forms that run in the browser.
*
* The error envelope shape matches `./api.ts::fetchActive` so the
* page component can swap between them without branching.
*/
import 'server-only';
import { serverApiFetch } from '@/lib/server-api';
import type { ActiveResponse } from './types';

export async function fetchActiveServer(): Promise<
| { available: true; data: ActiveResponse }
| { available: false; error: string }
> {
try {
const res = await serverApiFetch('/api/v1/admin/customizer/active');
if (!res.ok) {
return {
available: false,
error: `API error ${res.status}: ${res.statusText}`,
};
}
const data = (await res.json()) as ActiveResponse;
return { available: true, data };
} catch (error) {
const message = error instanceof Error ? error.message : 'unknown error';
return { available: false, error: message };
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,11 @@ import type { ActiveResponse, ThemeOverrides } from './types';
* Fetch the active theme manifest plus current overrides. On any
* failure (API down, registry not wired, etc.) returns `available:false`
* so the customizer page can paint an error banner without crashing.
*
* NOTE: When called from a Server Component, use `fetchActiveServer`
* from `./api-server.ts` instead — that variant forwards the session
* cookie from `next/headers`. This client-side variant relies on
* `credentials: 'include'` which is a no-op in Node.js.
*/
export async function fetchActive(): Promise<
| { available: true; data: ActiveResponse }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,14 +17,14 @@
* API outage would be a worse experience than a degraded form.
*/
import type { ReactElement } from 'react';
import { fetchActive } from './api';
import { fetchActiveServer } from './api-server';
import { CustomizerClient } from './CustomizerClient';
import './customizer.css';

const DEFAULT_PUBLIC_SITE_URL = 'http://localhost:3000';

export default async function CustomizerPage(): Promise<ReactElement> {
const result = await fetchActive();
const result = await fetchActiveServer();
const publicSiteUrl =
(typeof process !== 'undefined' && process.env.NEXT_PUBLIC_SITE_URL) ||
DEFAULT_PUBLIC_SITE_URL;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -154,6 +154,15 @@ export function MenusClient({ initialMenus }: Props): ReactElement {
return;
}
const [moved] = ordered.splice(fromIdx, 1);
// `splice` returns `T[]` so the destructure is typed `T |
// undefined` under `noUncheckedIndexedAccess`. fromIdx ≥ 0 was
// checked above, so moved is in practice always defined — but
// the type-checker doesn't know that. Guard explicitly so the
// re-insert doesn't push `undefined` into the array.
if (!moved) {
setDragId('');
return;
}
ordered.splice(toIdx, 0, moved);
// Re-stamp paths as flat root-level slots — the drag-drop surface
// here only supports a single nesting level for now.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import type { InstallResponse, ThemesListResponse } from './types';

export async function fetchThemesListClient(): Promise<ThemesListResponse | null> {
try {
const res = await fetch(`${apiBaseUrl}/api/v1/admin/themes`, {
const res = await fetch(`${apiBaseUrl()}/api/v1/admin/themes`, {
method: 'GET',
credentials: 'include',
headers: { Accept: 'application/json' },
Expand All @@ -30,7 +30,7 @@ export async function fetchThemesListClient(): Promise<ThemesListResponse | null
export async function installTheme(file: File): Promise<InstallResponse> {
const formData = new FormData();
formData.append('file', file, file.name);
const res = await fetch(`${apiBaseUrl}/api/v1/admin/themes/install`, {
const res = await fetch(`${apiBaseUrl()}/api/v1/admin/themes/install`, {
method: 'POST',
credentials: 'include',
body: formData,
Expand All @@ -42,7 +42,7 @@ export async function installTheme(file: File): Promise<InstallResponse> {
}

export async function activateTheme(slug: string): Promise<void> {
const res = await fetch(`${apiBaseUrl}/api/v1/admin/themes/activate`, {
const res = await fetch(`${apiBaseUrl()}/api/v1/admin/themes/activate`, {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
Expand Down
4 changes: 2 additions & 2 deletions apps/admin/src/app/(authenticated)/appearance/themes/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ export async function fetchThemesList(): Promise<ThemesListResponse | null> {
export async function installTheme(file: File): Promise<InstallResponse> {
const formData = new FormData();
formData.append('file', file, file.name);
const res = await fetch(`${apiBaseUrl}${INSTALL_URL}`, {
const res = await fetch(`${apiBaseUrl()}${INSTALL_URL}`, {
method: 'POST',
credentials: 'include',
body: formData,
Expand All @@ -63,7 +63,7 @@ export async function installTheme(file: File): Promise<InstallResponse> {
* message preserved.
*/
export async function activateTheme(slug: string): Promise<void> {
const res = await fetch(`${apiBaseUrl}${ACTIVATE_URL}`, {
const res = await fetch(`${apiBaseUrl()}${ACTIVATE_URL}`, {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -240,6 +240,17 @@ describe('CommentListClient', () => {
expect(screen.getByText(/thanks for sharing/i)).toBeInTheDocument();
});

it('links to the canonical post route without /edit suffix', () => {
render(<CommentListClient initialData={makeInitial(SAMPLE)} />);
// The post-title link in the "On post" column should point to
// `/posts/<id>` — never `/posts/<id>/edit` (the old route 404s).
const postLink = screen.getAllByRole('link', { name: /hello world/i })[0];
expect(postLink).toBeDefined();
const href = postLink!.getAttribute('href') ?? '';
expect(href).not.toMatch(/\/edit($|[?#])/);
expect(href).toContain('/posts/p1');
});

it('all-rows checkbox selects every row', () => {
render(<CommentListClient initialData={makeInitial(SAMPLE)} />);
const selectAll = screen.getByLabelText(/select all comments/i);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ import { Button } from '@/components/ui/button';
import { api, ApiError } from '@/lib/api-client';
import { cn } from '@/lib/utils';

import { postEditHref } from '../posts/columns';
import { BulkActionBar } from './components/BulkActionBar';
import { StatusBadge } from './components/StatusBadge';
import {
Expand Down Expand Up @@ -449,7 +450,7 @@ export function CommentListClient({
</td>
<td className="px-3 py-3 align-top">
<Link
href={{ pathname: `/posts/${c.postId}/edit` }}
href={postEditHref(c.postId)}
className="font-sans text-xs text-emerald-deep hover:underline"
>
{c.postTitle || '(untitled)'}
Expand Down
3 changes: 2 additions & 1 deletion apps/admin/src/app/(authenticated)/comments/[id]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import { Headline } from '@/components/ui/headline';
import { serverApiFetch } from '@/lib/server-api';
import { cn } from '@/lib/utils';

import { postEditHref } from '../../posts/columns';
import { StatusBadge } from '../components/StatusBadge';
import {
toComment,
Expand Down Expand Up @@ -175,7 +176,7 @@ export default async function CommentDetailPage(
<span className="text-fg-muted">
on{' '}
<Link
href={{ pathname: `/posts/${target.postId}/edit` }}
href={postEditHref(target.postId)}
className="text-emerald-deep hover:underline"
>
{target.postTitle || '(untitled)'}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ async function cookieHeader(): Promise<string> {
}

async function call(path: string, init: RequestInit = {}): Promise<Response> {
const url = `${apiBaseUrl.replace(/\/$/, '')}${path}`;
const url = `${apiBaseUrl().replace(/\/$/, '')}${path}`;
const cookie = await cookieHeader();
const headers = new Headers(init.headers);
headers.set('Accept', 'application/json');
Expand Down
2 changes: 1 addition & 1 deletion apps/admin/src/app/(authenticated)/media/actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -116,7 +116,7 @@ export function uploadMedia(
// touch Content-Type.
return new Promise<MediaAsset>((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.open('POST', `${apiBaseUrl}/api/v1/admin/media`, true);
xhr.open('POST', `${apiBaseUrl()}/api/v1/admin/media`, true);
xhr.withCredentials = true;

if (xhr.upload && onProgress) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -106,7 +106,10 @@ export function FolderTree(props: FolderTreeProps): ReactElement {
setLoading(true);
try {
const res = await listCollections();
setCollections(res.data);
// API returns `data: null` for an empty collections list (the
// pgx nil-slice JSON round-trip). Coerce to [] so downstream
// `collections.length` reads don't throw.
setCollections(Array.isArray(res.data) ? res.data : []);
setError(null);
} catch (err) {
setError(err instanceof Error ? err.message : 'failed to load folders');
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -162,7 +162,11 @@ export function MediaGrid(props: MediaGridProps): ReactElement {
cursor: opts.nextCursor || undefined,
collection: collectionParam,
});
setItems((prev) => (opts.reset ? res.data : [...prev, ...res.data]));
// API may return `data: null` on an empty page (pgx nil
// slice → JSON null round-trip). Normalize to [] so the
// spread + length reads downstream don't throw.
const safeData = Array.isArray(res.data) ? res.data : [];
setItems((prev) => (opts.reset ? safeData : [...prev, ...safeData]));
setCursor(res.pagination.next_cursor);
setHydrated(true);
} catch (err) {
Expand Down
12 changes: 9 additions & 3 deletions apps/admin/src/app/(authenticated)/media/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,13 @@ async function fetchInitial(): Promise<MediaListResponse | null> {

export default async function MediaPage(): Promise<ReactElement> {
const initial = await fetchInitial();
return (
<MediaGrid initialData={initial ?? { data: [], pagination: { next_cursor: '' } }} />
);
// The admin API returns `data: null` for an empty media library
// (a Postgres NULL → Go nil-slice → JSON null round-trip). The
// <MediaGrid> island assumes `data` is always an array — calling
// `.length` on null throws "Cannot read properties of null". Coerce
// null → [] here so the client never sees the nullable shape.
const safe = initial
? { ...initial, data: Array.isArray(initial.data) ? initial.data : [] }
: { data: [], pagination: { next_cursor: '' } };
return <MediaGrid initialData={safe} />;
}
Loading
Loading