Compare commits
72 Commits
todo/mvp0-
...
todo/mvp1-
| Author | SHA1 | Date | |
|---|---|---|---|
|
a2bea6326e
|
|||
|
60c9035743
|
|||
|
741883465c
|
|||
|
7a82934fe7
|
|||
|
473433b220
|
|||
|
987843d96b
|
|||
|
c6ebf3759a
|
|||
|
81983cfe40
|
|||
|
697b3ab5e7
|
|||
|
984511f166
|
|||
|
b9424c8a8b
|
|||
|
6e9c0ad4e5
|
|||
|
d016650463
|
|||
|
be6969c30f
|
|||
|
1f29594b7a
|
|||
|
47e59d2926
|
|||
|
958f3ad723
|
|||
|
1fddb6d858
|
|||
|
dc0a41a5ae
|
|||
|
a7895e4dd9
|
|||
|
618319dbc2
|
|||
|
506e2feb10
|
|||
|
749fb80083
|
|||
|
ec4f85e1d0
|
|||
|
6b282ce56b
|
|||
|
37f62a8007
|
|||
|
d1face36c5
|
|||
|
39178c2d8d
|
|||
|
24676bd384
|
|||
|
7c4b667bc7
|
|||
|
dbf817c255
|
|||
|
994b33e081
|
|||
|
f65a9ea03f
|
|||
|
281b1d7a1b
|
|||
|
7d9bc9dca9
|
|||
|
3e4f0b6c75
|
|||
|
86a8af25d8
|
|||
|
19738b77d8
|
|||
|
5becba602c
|
|||
|
ad351ed73a
|
|||
|
d727ab8b5b
|
|||
|
5b47fafe89
|
|||
|
37fabad1f8
|
|||
|
637dfd2651
|
|||
|
f9f2b4eb15
|
|||
|
ccac669454
|
|||
|
af52b8581f
|
|||
|
3de4d5732e
|
|||
|
14c3df623a
|
|||
|
a57464d818
|
|||
|
c174f840bc
|
|||
|
334a5e3526
|
|||
|
516b773012
|
|||
|
21cc55a1b9
|
|||
|
969e88670f
|
|||
|
cec87679ca
|
|||
|
4d6e17a13b
|
|||
|
7b4b23fc4f
|
|||
|
5872593b01
|
|||
|
3b130568e9
|
|||
|
8390689c8d
|
|||
|
bf1a92d129
|
|||
|
36b09cd9d7
|
|||
| 70fc154f97 | |||
| c4d0499d12 | |||
| d16fb6e121 | |||
| a508e3203a | |||
|
4d4b583cf4
|
|||
|
4ac7410148
|
|||
|
d0f731743c
|
|||
|
b618c8cb51
|
|||
|
07e5f53793
|
19
.env.example
19
.env.example
@@ -10,5 +10,24 @@ CMS_SUPPORT_EMAIL="support@cms.local"
|
||||
CMS_SUPPORT_PASSWORD="change-me-support-password"
|
||||
CMS_SUPPORT_NAME="Technical Support"
|
||||
CMS_SUPPORT_LOGIN_KEY="support-access-change-me"
|
||||
# Optional deterministic e2e admin user (seeded by `bun run test:e2e:prepare`)
|
||||
# CMS_E2E_ADMIN_EMAIL="e2e-admin@cms.local"
|
||||
# CMS_E2E_ADMIN_USERNAME="e2e-admin"
|
||||
# CMS_E2E_ADMIN_PASSWORD="e2e-admin-password"
|
||||
# CMS_E2E_ADMIN_NAME="E2E Admin"
|
||||
CMS_MEDIA_STORAGE_PROVIDER="s3"
|
||||
CMS_MEDIA_STORAGE_TENANT_ID="default"
|
||||
CMS_MEDIA_UPLOAD_MAX_BYTES="26214400"
|
||||
# Optional: override local media storage directory for admin upload adapter.
|
||||
# CMS_MEDIA_LOCAL_STORAGE_DIR="/absolute/path/to/media-storage"
|
||||
# S3/object-storage config (default provider). If unavailable, upload falls back to local storage.
|
||||
# CMS_MEDIA_S3_BUCKET="cms-media"
|
||||
# CMS_MEDIA_S3_REGION="eu-central-1"
|
||||
# CMS_MEDIA_S3_ACCESS_KEY_ID=""
|
||||
# CMS_MEDIA_S3_SECRET_ACCESS_KEY=""
|
||||
# CMS_MEDIA_S3_ENDPOINT="" # optional (e.g. MinIO, R2)
|
||||
# CMS_MEDIA_S3_FORCE_PATH_STYLE="false"
|
||||
NEXT_PUBLIC_APP_VERSION="0.1.0-dev"
|
||||
NEXT_PUBLIC_GIT_SHA="local"
|
||||
# Optional dev bypass role for admin middleware. Leave empty to require auth login.
|
||||
# CMS_DEV_ROLE="admin"
|
||||
|
||||
4
.env.gitea-runner.example
Normal file
4
.env.gitea-runner.example
Normal file
@@ -0,0 +1,4 @@
|
||||
GITEA_INSTANCE_URL="https://git.example.com"
|
||||
GITEA_RUNNER_REGISTRATION_TOKEN="replace-with-runner-registration-token"
|
||||
GITEA_RUNNER_NAME="cms-runner"
|
||||
GITEA_RUNNER_LABELS="ubuntu-latest:docker://node:20-bookworm"
|
||||
17
.gitea/PULL_REQUEST_TEMPLATE.md
Normal file
17
.gitea/PULL_REQUEST_TEMPLATE.md
Normal file
@@ -0,0 +1,17 @@
|
||||
## Summary
|
||||
|
||||
- TODO item reference (exact text): `...`
|
||||
- Scope (single primary TODO item): `...`
|
||||
|
||||
## Checklist
|
||||
|
||||
- [ ] Linked TODO item is in `TODO.md`
|
||||
- [ ] Branch name follows `todo/*`, `refactor/*`, or `code/*`
|
||||
- [ ] `bun run check`
|
||||
- [ ] `bun run typecheck`
|
||||
- [ ] `bun run test`
|
||||
- [ ] E2E validation plan included (`bun run test:e2e` or reason if deferred)
|
||||
|
||||
## Notes
|
||||
|
||||
- Risks / migrations / rollout notes:
|
||||
25
.gitea/scripts/check-branch-name.sh
Executable file
25
.gitea/scripts/check-branch-name.sh
Executable file
@@ -0,0 +1,25 @@
|
||||
#!/usr/bin/env sh
|
||||
set -eu
|
||||
|
||||
branch="${1:-}"
|
||||
|
||||
if [ -z "$branch" ]; then
|
||||
echo "Missing branch name."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
case "$branch" in
|
||||
dev|staging|main)
|
||||
echo "Long-lived branch detected: $branch"
|
||||
exit 0
|
||||
;;
|
||||
esac
|
||||
|
||||
if printf "%s" "$branch" | grep -Eq '^(todo|refactor|code)\/[a-z0-9]+([._-][a-z0-9]+)*$'; then
|
||||
echo "Branch naming valid: $branch"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "Invalid branch name: $branch"
|
||||
echo "Expected: todo/<slug> | refactor/<slug> | code/<slug>"
|
||||
exit 1
|
||||
17
.gitea/scripts/check-pr-todo-reference.sh
Executable file
17
.gitea/scripts/check-pr-todo-reference.sh
Executable file
@@ -0,0 +1,17 @@
|
||||
#!/usr/bin/env sh
|
||||
set -eu
|
||||
|
||||
body="${1:-}"
|
||||
|
||||
if [ -z "$body" ]; then
|
||||
echo "PR body is empty."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if printf "%s" "$body" | grep -Eq 'TODO|todo|\[P[1-3]\]'; then
|
||||
echo "PR body includes TODO reference."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "PR body must reference the related TODO item."
|
||||
exit 1
|
||||
34
.gitea/scripts/configure-branch-protection.sh
Executable file
34
.gitea/scripts/configure-branch-protection.sh
Executable file
@@ -0,0 +1,34 @@
|
||||
#!/usr/bin/env sh
|
||||
set -eu
|
||||
|
||||
if [ "${#}" -ne 4 ]; then
|
||||
echo "Usage: $0 <base-url> <owner> <repo> <token>"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
base_url="$1"
|
||||
owner="$2"
|
||||
repo="$3"
|
||||
token="$4"
|
||||
|
||||
protect_branch() {
|
||||
branch="$1"
|
||||
|
||||
curl -sS -X POST \
|
||||
"${base_url}/api/v1/repos/${owner}/${repo}/branch_protections" \
|
||||
-H "Authorization: token ${token}" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "{
|
||||
\"branch_name\": \"${branch}\",
|
||||
\"enable_push\": false,
|
||||
\"enable_push_whitelist\": false,
|
||||
\"enable_merge_whitelist\": false,
|
||||
\"enable_status_check\": true,
|
||||
\"status_check_contexts\": [\"Governance Checks\", \"Lint Typecheck Unit E2E\"]
|
||||
}" >/dev/null
|
||||
}
|
||||
|
||||
protect_branch "main"
|
||||
protect_branch "staging"
|
||||
|
||||
echo "Branch protection applied for main and staging."
|
||||
45
.gitea/scripts/extract-release-notes.sh
Normal file
45
.gitea/scripts/extract-release-notes.sh
Normal file
@@ -0,0 +1,45 @@
|
||||
#!/usr/bin/env sh
|
||||
set -eu
|
||||
|
||||
tag="${1:-}"
|
||||
|
||||
if [ -z "$tag" ]; then
|
||||
echo "Missing release tag argument (expected vX.Y.Z)."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ ! -f CHANGELOG.md ]; then
|
||||
echo "CHANGELOG.md not found."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
version="${tag#v}"
|
||||
|
||||
awk -v version="$version" '
|
||||
BEGIN {
|
||||
in_section = 0
|
||||
started = 0
|
||||
}
|
||||
/^## / {
|
||||
if (in_section == 1) {
|
||||
exit
|
||||
}
|
||||
|
||||
if (index($0, version) > 0) {
|
||||
in_section = 1
|
||||
started = 1
|
||||
print $0
|
||||
next
|
||||
}
|
||||
}
|
||||
{
|
||||
if (in_section == 1) {
|
||||
print $0
|
||||
}
|
||||
}
|
||||
END {
|
||||
if (started == 0) {
|
||||
exit 2
|
||||
}
|
||||
}
|
||||
' CHANGELOG.md
|
||||
80
.gitea/scripts/publish-gitea-release.mjs
Normal file
80
.gitea/scripts/publish-gitea-release.mjs
Normal file
@@ -0,0 +1,80 @@
|
||||
import { readFileSync } from "node:fs"
|
||||
|
||||
const tag = process.env.RELEASE_TAG?.trim()
|
||||
const releaseName = process.env.RELEASE_NAME?.trim() || tag
|
||||
const bodyFile = process.env.RELEASE_BODY_FILE?.trim() || ".gitea-release-notes.md"
|
||||
const serverUrl = process.env.GITHUB_SERVER_URL?.trim()
|
||||
const repository = process.env.GITHUB_REPOSITORY?.trim()
|
||||
const token = process.env.GITEA_RELEASE_TOKEN?.trim()
|
||||
|
||||
if (!tag) {
|
||||
throw new Error("RELEASE_TAG is required")
|
||||
}
|
||||
|
||||
if (!serverUrl || !repository) {
|
||||
throw new Error("GITHUB_SERVER_URL and GITHUB_REPOSITORY are required")
|
||||
}
|
||||
|
||||
if (!token) {
|
||||
throw new Error("GITEA_RELEASE_TOKEN is required")
|
||||
}
|
||||
|
||||
const body = readFileSync(bodyFile, "utf8")
|
||||
const baseApi = `${serverUrl.replace(/\/$/, "")}/api/v1/repos/${repository}`
|
||||
|
||||
async function request(path, options = {}) {
|
||||
const response = await fetch(`${baseApi}${path}`, {
|
||||
...options,
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
authorization: `token ${token}`,
|
||||
...(options.headers ?? {}),
|
||||
},
|
||||
})
|
||||
|
||||
return response
|
||||
}
|
||||
|
||||
const payload = {
|
||||
tag_name: tag,
|
||||
target_commitish: "main",
|
||||
name: releaseName,
|
||||
body,
|
||||
draft: false,
|
||||
prerelease: false,
|
||||
}
|
||||
|
||||
const existingResponse = await request(`/releases/tags/${encodeURIComponent(tag)}`)
|
||||
|
||||
if (existingResponse.ok) {
|
||||
const existing = await existingResponse.json()
|
||||
const updateResponse = await request(`/releases/${existing.id}`, {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify({
|
||||
...payload,
|
||||
target_commitish: existing.target_commitish ?? payload.target_commitish,
|
||||
}),
|
||||
})
|
||||
|
||||
if (!updateResponse.ok) {
|
||||
const message = await updateResponse.text()
|
||||
throw new Error(`Failed to update release: ${updateResponse.status} ${message}`)
|
||||
}
|
||||
|
||||
console.log(`Updated release for tag ${tag}`)
|
||||
} else if (existingResponse.status === 404) {
|
||||
const createResponse = await request("/releases", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
|
||||
if (!createResponse.ok) {
|
||||
const message = await createResponse.text()
|
||||
throw new Error(`Failed to create release: ${createResponse.status} ${message}`)
|
||||
}
|
||||
|
||||
console.log(`Created release for tag ${tag}`)
|
||||
} else {
|
||||
const message = await existingResponse.text()
|
||||
throw new Error(`Failed to query existing release: ${existingResponse.status} ${message}`)
|
||||
}
|
||||
18
.gitea/scripts/validate-tag-version.sh
Executable file
18
.gitea/scripts/validate-tag-version.sh
Executable file
@@ -0,0 +1,18 @@
|
||||
#!/usr/bin/env sh
|
||||
set -eu
|
||||
|
||||
tag="${1:-}"
|
||||
|
||||
if [ -z "$tag" ]; then
|
||||
echo "Missing tag ref name (expected vX.Y.Z)."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
version="$(node -p "require('./package.json').version")"
|
||||
|
||||
if [ "$tag" != "v$version" ]; then
|
||||
echo "Tag/version mismatch: tag=$tag package.json=$version"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Tag matches package.json version: $tag"
|
||||
@@ -1,113 +0,0 @@
|
||||
name: CMS CI/CD (Theoretical)
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
push:
|
||||
branches:
|
||||
- dev
|
||||
- main
|
||||
- staging
|
||||
tags:
|
||||
- "v*"
|
||||
workflow_dispatch:
|
||||
|
||||
env:
|
||||
BUN_VERSION: "1.3.5"
|
||||
|
||||
jobs:
|
||||
quality:
|
||||
name: Lint Typecheck Tests
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Bun
|
||||
uses: oven-sh/setup-bun@v2
|
||||
with:
|
||||
bun-version: ${{ env.BUN_VERSION }}
|
||||
|
||||
- name: Install dependencies
|
||||
run: bun install --frozen-lockfile
|
||||
|
||||
- name: Generate Prisma client
|
||||
run: bun run db:generate
|
||||
|
||||
- name: Lint
|
||||
run: bun run lint
|
||||
|
||||
- name: Typecheck
|
||||
run: bun run typecheck
|
||||
|
||||
- name: Unit and component tests
|
||||
run: bun run test
|
||||
|
||||
- name: E2E suite discovery check
|
||||
run: bun run test:e2e --list
|
||||
|
||||
- name: Conventional commit check (latest commit)
|
||||
run: bun run commitlint
|
||||
|
||||
build_staging_images:
|
||||
name: Build Staging Images
|
||||
runs-on: ubuntu-latest
|
||||
needs: quality
|
||||
if: github.ref == 'refs/heads/staging'
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Build web image (staging)
|
||||
run: docker build -f apps/web/Dockerfile -t cms-web:staging .
|
||||
|
||||
- name: Build admin image (staging)
|
||||
run: docker build -f apps/admin/Dockerfile -t cms-admin:staging .
|
||||
|
||||
build_production_images:
|
||||
name: Build Production Images
|
||||
runs-on: ubuntu-latest
|
||||
needs: quality
|
||||
if: startsWith(github.ref, 'refs/tags/v')
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Build web image (production)
|
||||
run: docker build -f apps/web/Dockerfile -t cms-web:${{ github.ref_name }} .
|
||||
|
||||
- name: Build admin image (production)
|
||||
run: docker build -f apps/admin/Dockerfile -t cms-admin:${{ github.ref_name }} .
|
||||
|
||||
- name: Generate changelog
|
||||
run: |
|
||||
bun install --frozen-lockfile
|
||||
bun run changelog:release
|
||||
|
||||
- name: Push images (placeholder)
|
||||
run: |
|
||||
echo "TODO: docker login to registry"
|
||||
echo "TODO: docker push cms-web:${{ github.ref_name }}"
|
||||
echo "TODO: docker push cms-admin:${{ github.ref_name }}"
|
||||
echo "TODO: publish CHANGELOG.md content as release notes"
|
||||
|
||||
deploy_staging:
|
||||
name: Deploy Staging (Placeholder)
|
||||
runs-on: ubuntu-latest
|
||||
needs: build_staging_images
|
||||
if: github.ref == 'refs/heads/staging'
|
||||
steps:
|
||||
- name: Deploy placeholder
|
||||
run: |
|
||||
echo "TODO: Pull and restart staging compose on target host"
|
||||
echo "docker compose -f docker-compose.staging.yml up -d"
|
||||
|
||||
deploy_production:
|
||||
name: Deploy Production (Placeholder)
|
||||
runs-on: ubuntu-latest
|
||||
needs: build_production_images
|
||||
if: startsWith(github.ref, 'refs/tags/v')
|
||||
steps:
|
||||
- name: Deploy placeholder
|
||||
run: |
|
||||
echo "TODO: Pull and restart production compose on target host"
|
||||
echo "docker compose -f docker-compose.production.yml up -d"
|
||||
100
.gitea/workflows/ci.yml
Normal file
100
.gitea/workflows/ci.yml
Normal file
@@ -0,0 +1,100 @@
|
||||
name: CMS CI
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
push:
|
||||
branches:
|
||||
- dev
|
||||
- staging
|
||||
- main
|
||||
workflow_dispatch:
|
||||
|
||||
env:
|
||||
BUN_VERSION: "1.3.5"
|
||||
NODE_ENV: "test"
|
||||
DATABASE_URL: "postgresql://postgres:postgres@postgres:5432/cms?schema=public"
|
||||
BETTER_AUTH_SECRET: "ci-test-secret-change-me"
|
||||
BETTER_AUTH_URL: "http://localhost:3001"
|
||||
CMS_ADMIN_ORIGIN: "http://127.0.0.1:3001"
|
||||
CMS_WEB_ORIGIN: "http://127.0.0.1:3000"
|
||||
CMS_ADMIN_SELF_REGISTRATION_ENABLED: "false"
|
||||
CMS_SUPPORT_USERNAME: "support"
|
||||
CMS_SUPPORT_EMAIL: "support@cms.local"
|
||||
CMS_SUPPORT_PASSWORD: "support-ci-password"
|
||||
CMS_SUPPORT_NAME: "Technical Support"
|
||||
CMS_SUPPORT_LOGIN_KEY: "support-access"
|
||||
|
||||
jobs:
|
||||
governance:
|
||||
name: Governance Checks
|
||||
runs-on: node22-bun
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Validate branch naming
|
||||
run: |
|
||||
branch="${GITHUB_HEAD_REF:-${GITHUB_REF_NAME}}"
|
||||
sh .gitea/scripts/check-branch-name.sh "$branch"
|
||||
|
||||
- name: Validate PR TODO reference
|
||||
if: github.event_name == 'pull_request'
|
||||
run: |
|
||||
body='${{ github.event.pull_request.body }}'
|
||||
sh .gitea/scripts/check-pr-todo-reference.sh "$body"
|
||||
|
||||
- name: Commit schema check (latest commit)
|
||||
uses: oven-sh/setup-bun@v2
|
||||
with:
|
||||
bun-version: ${{ env.BUN_VERSION }}
|
||||
|
||||
- name: Install dependencies for commitlint
|
||||
run: bun install --frozen-lockfile
|
||||
|
||||
- name: Commitlint
|
||||
run: bun run commitlint
|
||||
|
||||
quality:
|
||||
name: Lint Typecheck (Testing Paused)
|
||||
needs: governance
|
||||
runs-on: node22-bun
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:16-alpine
|
||||
env:
|
||||
POSTGRES_DB: cms
|
||||
POSTGRES_USER: postgres
|
||||
POSTGRES_PASSWORD: postgres
|
||||
ports:
|
||||
- 5432:5432
|
||||
options: >-
|
||||
--health-cmd "pg_isready -U postgres -d cms"
|
||||
--health-interval 10s
|
||||
--health-timeout 5s
|
||||
--health-retries 5
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Bun
|
||||
uses: oven-sh/setup-bun@v2
|
||||
with:
|
||||
bun-version: ${{ env.BUN_VERSION }}
|
||||
|
||||
- name: Install dependencies
|
||||
run: bun install --frozen-lockfile
|
||||
|
||||
- name: Resolve build metadata
|
||||
run: |
|
||||
version=$(bun -e 'const pkg = JSON.parse(await Bun.file("package.json").text()); console.log(pkg.version)')
|
||||
echo "NEXT_PUBLIC_APP_VERSION=$version" >> "$GITHUB_ENV"
|
||||
echo "NEXT_PUBLIC_GIT_SHA=${GITHUB_SHA}" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Lint and format checks
|
||||
run: bun run check
|
||||
|
||||
- name: Generate Prisma client
|
||||
run: bun run db:generate
|
||||
|
||||
- name: Typecheck
|
||||
run: bun run typecheck
|
||||
54
.gitea/workflows/deploy.yml
Normal file
54
.gitea/workflows/deploy.yml
Normal file
@@ -0,0 +1,54 @@
|
||||
name: CMS Deploy
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
environment:
|
||||
description: "Target environment"
|
||||
required: true
|
||||
type: choice
|
||||
options:
|
||||
- staging
|
||||
- production
|
||||
image_tag:
|
||||
description: "Image tag to deploy (e.g. v0.1.0)"
|
||||
required: true
|
||||
rollback_tag:
|
||||
description: "Optional rollback tag"
|
||||
required: false
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
name: Deploy Compose Stack
|
||||
runs-on: node22-bun
|
||||
steps:
|
||||
- name: Resolve deployment target
|
||||
id: target
|
||||
run: |
|
||||
if [ "${{ github.event.inputs.environment }}" = "staging" ]; then
|
||||
echo "host=${{ secrets.CMS_STAGING_HOST }}" >> "$GITHUB_OUTPUT"
|
||||
echo "user=${{ secrets.CMS_STAGING_USER }}" >> "$GITHUB_OUTPUT"
|
||||
echo "compose=docker-compose.staging.yml" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "host=${{ secrets.CMS_PRODUCTION_HOST }}" >> "$GITHUB_OUTPUT"
|
||||
echo "user=${{ secrets.CMS_PRODUCTION_USER }}" >> "$GITHUB_OUTPUT"
|
||||
echo "compose=docker-compose.production.yml" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
- name: Setup SSH
|
||||
run: |
|
||||
mkdir -p ~/.ssh
|
||||
echo "${{ secrets.CMS_DEPLOY_KEY }}" > ~/.ssh/id_rsa
|
||||
chmod 600 ~/.ssh/id_rsa
|
||||
ssh-keyscan -H "${{ steps.target.outputs.host }}" >> ~/.ssh/known_hosts
|
||||
|
||||
- name: Deploy image tag
|
||||
run: |
|
||||
ssh "${{ steps.target.outputs.user }}@${{ steps.target.outputs.host }}" \
|
||||
"cd ${{ secrets.CMS_REMOTE_DEPLOY_PATH }} && CMS_IMAGE_TAG=${{ github.event.inputs.image_tag }} docker compose -f ${{ steps.target.outputs.compose }} up -d"
|
||||
|
||||
- name: Optional rollback
|
||||
if: github.event.inputs.rollback_tag != ''
|
||||
run: |
|
||||
ssh "${{ steps.target.outputs.user }}@${{ steps.target.outputs.host }}" \
|
||||
"cd ${{ secrets.CMS_REMOTE_DEPLOY_PATH }} && CMS_IMAGE_TAG=${{ github.event.inputs.rollback_tag }} docker compose -f ${{ steps.target.outputs.compose }} up -d"
|
||||
103
.gitea/workflows/release.yml
Normal file
103
.gitea/workflows/release.yml
Normal file
@@ -0,0 +1,103 @@
|
||||
name: CMS Release
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- "v*"
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
release_tag:
|
||||
description: "Release tag in vX.Y.Z format"
|
||||
required: false
|
||||
rollback_image_tag:
|
||||
description: "Optional rollback image tag"
|
||||
required: false
|
||||
|
||||
env:
|
||||
BUN_VERSION: "1.3.5"
|
||||
REGISTRY: ${{ secrets.CMS_IMAGE_REGISTRY }}
|
||||
IMAGE_NAMESPACE: ${{ secrets.CMS_IMAGE_NAMESPACE }}
|
||||
|
||||
jobs:
|
||||
release:
|
||||
name: Build Push Changelog
|
||||
if: github.event_name == 'push' || github.event.inputs.rollback_image_tag == ''
|
||||
runs-on: node22-bun
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Bun
|
||||
uses: oven-sh/setup-bun@v2
|
||||
with:
|
||||
bun-version: ${{ env.BUN_VERSION }}
|
||||
|
||||
- name: Install dependencies
|
||||
run: bun install --frozen-lockfile
|
||||
|
||||
- name: Resolve release tag
|
||||
id: tag
|
||||
run: |
|
||||
if [ "${GITHUB_EVENT_NAME}" = "workflow_dispatch" ]; then
|
||||
if [ -z "${{ github.event.inputs.release_tag }}" ]; then
|
||||
echo "release_tag input is required when publishing a release manually."
|
||||
exit 1
|
||||
fi
|
||||
echo "value=${{ github.event.inputs.release_tag }}" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "value=${GITHUB_REF_NAME}" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
- name: Validate tag against package version
|
||||
run: sh .gitea/scripts/validate-tag-version.sh "${{ steps.tag.outputs.value }}"
|
||||
|
||||
- name: Generate changelog
|
||||
run: bun run changelog:release
|
||||
|
||||
- name: Build release notes payload
|
||||
run: |
|
||||
if ! sh .gitea/scripts/extract-release-notes.sh "${{ steps.tag.outputs.value }}" > .gitea-release-notes.md; then
|
||||
echo "Could not isolate section for tag ${{ steps.tag.outputs.value }}. Falling back to full CHANGELOG.md."
|
||||
cp CHANGELOG.md .gitea-release-notes.md
|
||||
fi
|
||||
|
||||
- name: Login to image registry
|
||||
run: |
|
||||
echo "${{ secrets.CMS_IMAGE_REGISTRY_PASSWORD }}" | docker login "${{ env.REGISTRY }}" -u "${{ secrets.CMS_IMAGE_REGISTRY_USER }}" --password-stdin
|
||||
|
||||
- name: Build and push web image
|
||||
run: |
|
||||
image="${{ env.REGISTRY }}/${{ env.IMAGE_NAMESPACE }}/cms-web:${{ steps.tag.outputs.value }}"
|
||||
docker build -f apps/web/Dockerfile -t "$image" .
|
||||
docker push "$image"
|
||||
|
||||
- name: Build and push admin image
|
||||
run: |
|
||||
image="${{ env.REGISTRY }}/${{ env.IMAGE_NAMESPACE }}/cms-admin:${{ steps.tag.outputs.value }}"
|
||||
docker build -f apps/admin/Dockerfile -t "$image" .
|
||||
docker push "$image"
|
||||
|
||||
- name: Publish release notes to Gitea
|
||||
env:
|
||||
RELEASE_TAG: ${{ steps.tag.outputs.value }}
|
||||
RELEASE_NAME: ${{ steps.tag.outputs.value }}
|
||||
RELEASE_BODY_FILE: ".gitea-release-notes.md"
|
||||
GITEA_RELEASE_TOKEN: ${{ secrets.GITEA_RELEASE_TOKEN }}
|
||||
run: bun .gitea/scripts/publish-gitea-release.mjs
|
||||
|
||||
rollback:
|
||||
name: Rollback Production (Manual)
|
||||
if: github.event_name == 'workflow_dispatch' && github.event.inputs.rollback_image_tag != ''
|
||||
runs-on: node22-bun
|
||||
steps:
|
||||
- name: Setup SSH
|
||||
run: |
|
||||
mkdir -p ~/.ssh
|
||||
echo "${{ secrets.CMS_DEPLOY_KEY }}" > ~/.ssh/id_rsa
|
||||
chmod 600 ~/.ssh/id_rsa
|
||||
ssh-keyscan -H "${{ secrets.CMS_PRODUCTION_HOST }}" >> ~/.ssh/known_hosts
|
||||
|
||||
- name: Apply rollback image tag on production
|
||||
run: |
|
||||
ssh "${{ secrets.CMS_PRODUCTION_USER }}@${{ secrets.CMS_PRODUCTION_HOST }}" \
|
||||
"cd ${{ secrets.CMS_REMOTE_DEPLOY_PATH }} && CMS_IMAGE_TAG=${{ github.event.inputs.rollback_image_tag }} docker compose -f docker-compose.production.yml pull && CMS_IMAGE_TAG=${{ github.event.inputs.rollback_image_tag }} docker compose -f docker-compose.production.yml up -d"
|
||||
5
.gitignore
vendored
5
.gitignore
vendored
@@ -24,6 +24,7 @@ test-results
|
||||
!.env.example
|
||||
!.env.staging.example
|
||||
!.env.production.example
|
||||
!.env.gitea-runner.example
|
||||
|
||||
# prisma
|
||||
packages/db/prisma/dev.db*
|
||||
@@ -31,3 +32,7 @@ packages/db/prisma/generated/
|
||||
|
||||
# misc
|
||||
.DS_Store
|
||||
|
||||
# local media storage
|
||||
.data/
|
||||
apps/admin/.data/
|
||||
|
||||
@@ -96,6 +96,13 @@ Apply in repository settings:
|
||||
Optional:
|
||||
|
||||
- Protect `dev` from direct push if team size/process requires stricter control.
|
||||
- Automate protection via `.gitea/scripts/configure-branch-protection.sh`.
|
||||
|
||||
## Governance Automation
|
||||
|
||||
- Branch naming check: `.gitea/scripts/check-branch-name.sh`
|
||||
- PR TODO reference check: `.gitea/scripts/check-pr-todo-reference.sh`
|
||||
- PR template: `.gitea/PULL_REQUEST_TEMPLATE.md`
|
||||
|
||||
## Commit Signing Notes
|
||||
|
||||
|
||||
53
CHANGELOG.md
53
CHANGELOG.md
@@ -1,3 +1,56 @@
|
||||
## Unreleased (2026-02-12)
|
||||
|
||||
* test(admin): cover support fallback route and mark todo complete ([4ac74101487e0bb220215328510fd4344c9110e3](https://git.fellies.net/Citali/cms.fellies.org/commits/4ac74101487e0bb220215328510fd4344c9110e3))
|
||||
* test(auth): add registration policy route-flow integration tests ([39178c2d8d9c203ceca63a36012b58173d21d4d4](https://git.fellies.net/Citali/cms.fellies.org/commits/39178c2d8d9c203ceca63a36012b58173d21d4d4))
|
||||
* test(ci): add quality gates, e2e data prep, and i18n integration coverage ([4d4b583cf4dcb0f3b99f74c666af15d3b1a0fc59](https://git.fellies.net/Citali/cms.fellies.org/commits/4d4b583cf4dcb0f3b99f74c666af15d3b1a0fc59))
|
||||
* test(crud): finalize MVP1 gate CRUD contract coverage ([36b09cd9d76049219569bb4828aa292aec78a935](https://git.fellies.net/Citali/cms.fellies.org/commits/36b09cd9d76049219569bb4828aa292aec78a935))
|
||||
* test(e2e): add mvp1 happy path scenarios ([7c4b667bc79a022518d03a52a98060a9c86c673c](https://git.fellies.net/Citali/cms.fellies.org/commits/7c4b667bc79a022518d03a52a98060a9c86c673c))
|
||||
* test(mvp0): complete remaining i18n, RBAC, and CRUD coverage ([3b130568e9bfff9e428c3650c37dd8b1abfbed57](https://git.fellies.net/Citali/cms.fellies.org/commits/3b130568e9bfff9e428c3650c37dd8b1abfbed57))
|
||||
* test(mvp1): add owner invariants and media form coverage ([37f62a8007d6b50e7b53e5785365965c00c74a5b](https://git.fellies.net/Citali/cms.fellies.org/commits/37f62a8007d6b50e7b53e5785365965c00c74a5b))
|
||||
* test(mvp1): expand domain schema and service unit coverage ([24676bd384c39d954b791ff2322a4469ab86a83a](https://git.fellies.net/Citali/cms.fellies.org/commits/24676bd384c39d954b791ff2322a4469ab86a83a))
|
||||
* feat(admin-auth): add first-start onboarding flow and dev db reset command ([7b665ae633e869560654cf1c115d12dc05af1c45](https://git.fellies.net/Citali/cms.fellies.org/commits/7b665ae633e869560654cf1c115d12dc05af1c45))
|
||||
* feat(admin-auth): support username login and add dashboard logout ([b96cd6d8005aea50c49a70540e0fd2afff94c682](https://git.fellies.net/Citali/cms.fellies.org/commits/b96cd6d8005aea50c49a70540e0fd2afff94c682))
|
||||
* feat(admin-i18n): add cookie-based locale runtime and switcher baseline ([b618c8cb5161eef46e80d622156a1e53a732ea35](https://git.fellies.net/Citali/cms.fellies.org/commits/b618c8cb5161eef46e80d622156a1e53a732ea35))
|
||||
* feat(admin): add IA shell and protected section skeleton routes ([bf1a92d129b89811b58d6920a4e09482816988a0](https://git.fellies.net/Citali/cms.fellies.org/commits/bf1a92d129b89811b58d6920a4e09482816988a0))
|
||||
* feat(admin): add posts CRUD sandbox and shared CRUD foundation ([07e5f53793da5aa0b17e62a3a9b99c23a56dbcf0](https://git.fellies.net/Citali/cms.fellies.org/commits/07e5f53793da5aa0b17e62a3a9b99c23a56dbcf0))
|
||||
* feat(admin): add registration policy settings and disabled register state ([d0f731743c789ef836e4573eadece2f4c67c973d](https://git.fellies.net/Citali/cms.fellies.org/commits/d0f731743c789ef836e4573eadece2f4c67c973d))
|
||||
* feat(auth): block protected account deletion in auth endpoints ([0e2248b5c7f72684e4db6d4ab8f306b10f50ac66](https://git.fellies.net/Citali/cms.fellies.org/commits/0e2248b5c7f72684e4db6d4ab8f306b10f50ac66))
|
||||
* feat(auth): bootstrap protected support and first owner users ([411861419f160e3573a71ea67d57af7e0e91de7d](https://git.fellies.net/Citali/cms.fellies.org/commits/411861419f160e3573a71ea67d57af7e0e91de7d))
|
||||
* feat(auth): enforce single-owner invariant in bootstrap flow ([29a6e38ff3b725e4232736c4b5b007a2989acb82](https://git.fellies.net/Citali/cms.fellies.org/commits/29a6e38ff3b725e4232736c4b5b007a2989acb82))
|
||||
* feat(ci): stamp build metadata and validate footer version hash ([af52b8581f7dbe320c92752fcc56b73e37f0c2ba](https://git.fellies.net/Citali/cms.fellies.org/commits/af52b8581f7dbe320c92752fcc56b73e37f0c2ba))
|
||||
* feat(commissions): add customer records and kanban workflow baseline ([994b33e081c3507cbf88820028b819a4fc4b07a0](https://git.fellies.net/Citali/cms.fellies.org/commits/994b33e081c3507cbf88820028b819a4fc4b07a0))
|
||||
* feat(content): add announcements and public news flows ([dbf817c25511b3038b7abe81e4577d3518fd3f19](https://git.fellies.net/Citali/cms.fellies.org/commits/dbf817c25511b3038b7abe81e4577d3518fd3f19))
|
||||
* feat(media): add admin media CRUD preview and storage cleanup ([7d9bc9dca9197e87cc590ad6b49837c5774fcd4f](https://git.fellies.net/Citali/cms.fellies.org/commits/7d9bc9dca9197e87cc590ad6b49837c5774fcd4f))
|
||||
* feat(media): add mvp1 upload pipeline baseline ([5becba602c3aaefbe24ec71414f62b29a155d158](https://git.fellies.net/Citali/cms.fellies.org/commits/5becba602c3aaefbe24ec71414f62b29a155d158))
|
||||
* feat(media): complete mvp1 media foundation workflows ([ad351ed73ab7c76a9e751348ebf943aec5f0d084](https://git.fellies.net/Citali/cms.fellies.org/commits/ad351ed73ab7c76a9e751348ebf943aec5f0d084))
|
||||
* feat(media): default to s3 with local upload fallback ([86a8af25d8c28c2ab19039b56b1c69263c7450c5](https://git.fellies.net/Citali/cms.fellies.org/commits/86a8af25d8c28c2ab19039b56b1c69263c7450c5))
|
||||
* feat(media): scaffold mvp1 media and portfolio foundation ([d727ab8b5b896e5471829a6a1880dc33da28d070](https://git.fellies.net/Citali/cms.fellies.org/commits/d727ab8b5b896e5471829a6a1880dc33da28d070))
|
||||
* feat(media): support local and s3 upload providers ([19738b77d8842f3263e7f049aa47063dcfbd4ae6](https://git.fellies.net/Citali/cms.fellies.org/commits/19738b77d8842f3263e7f049aa47063dcfbd4ae6))
|
||||
* feat(pages): add pages and navigation builder baseline ([281b1d7a1be72af4cff790ca7e97d51cafcd8139](https://git.fellies.net/Citali/cms.fellies.org/commits/281b1d7a1be72af4cff790ca7e97d51cafcd8139))
|
||||
* feat(release): publish gitea release notes and enable production rollback ([ccac669454b46a3918b34df1d3c5f0e5f00aa1d9](https://git.fellies.net/Citali/cms.fellies.org/commits/ccac669454b46a3918b34df1d3c5f0e5f00aa1d9))
|
||||
* feat(settings): manage public header banner in admin ([d1face36c540673486b494d276f5af1621b6e6cb](https://git.fellies.net/Citali/cms.fellies.org/commits/d1face36c540673486b494d276f5af1621b6e6cb))
|
||||
* feat(versioning): show runtime version and git hash in app footers ([3de4d5732e26e06e825986e58ec271d0f0ff4007](https://git.fellies.net/Citali/cms.fellies.org/commits/3de4d5732e26e06e825986e58ec271d0f0ff4007))
|
||||
* feat(web-i18n): add es/fr locales and expand switcher locale set ([de26cb7647cf537a783cc9c77ae447a0f8a09ef6](https://git.fellies.net/Citali/cms.fellies.org/commits/de26cb7647cf537a783cc9c77ae447a0f8a09ef6))
|
||||
* feat(web): complete MVP0 public layout, banner, and SEO baseline ([8390689c8dd81dca5662b842c827a4759a9025e1](https://git.fellies.net/Citali/cms.fellies.org/commits/8390689c8dd81dca5662b842c827a4759a9025e1))
|
||||
* feat(web): render cms pages and navigation from db ([f65a9ea03f39c21ee9b31e7f9100e3a1f522525f](https://git.fellies.net/Citali/cms.fellies.org/commits/f65a9ea03f39c21ee9b31e7f9100e3a1f522525f))
|
||||
* refactor(db): simplify to single prisma schema workflow ([df1280af4a1d24bd9374fc9a005cea9142745d46](https://git.fellies.net/Citali/cms.fellies.org/commits/df1280af4a1d24bd9374fc9a005cea9142745d46))
|
||||
* refactor(media): use asset-centric storage key layout ([3e4f0b6c75c59422675637bf658b6682c22f4a89](https://git.fellies.net/Citali/cms.fellies.org/commits/3e4f0b6c75c59422675637bf658b6682c22f4a89))
|
||||
* docs(adr): add glossary pages and ADR baseline structure ([cec87679ca5efcf70883b6c78245f8197a8a4432](https://git.fellies.net/Citali/cms.fellies.org/commits/cec87679ca5efcf70883b6c78245f8197a8a4432))
|
||||
* docs(crud): add implementation examples and complete docs task ([7b4b23fc4ffdd7e6be8af9da6b2026067acbd35e](https://git.fellies.net/Citali/cms.fellies.org/commits/7b4b23fc4ffdd7e6be8af9da6b2026067acbd35e))
|
||||
* docs(gitflow): add branch protection verification checklist ([f9f2b4eb15bd42690891bdc8e4d34c5e55c343dc](https://git.fellies.net/Citali/cms.fellies.org/commits/f9f2b4eb15bd42690891bdc8e4d34c5e55c343dc))
|
||||
* docs(i18n): add conventions guide and wire docs navigation ([5872593b014e527ef884ea89053f9cf191edf5dc](https://git.fellies.net/Citali/cms.fellies.org/commits/5872593b014e527ef884ea89053f9cf191edf5dc))
|
||||
* docs(ops): add environment and deployment runbook ([4d6e17a13b3ee4a00d93713a223871e96ee94550](https://git.fellies.net/Citali/cms.fellies.org/commits/4d6e17a13b3ee4a00d93713a223871e96ee94550))
|
||||
* docs(ops): add staging deployment checklist and evidence template ([637dfd2651a8ad7b0900c0a87c714da7750aaae2](https://git.fellies.net/Citali/cms.fellies.org/commits/637dfd2651a8ad7b0900c0a87c714da7750aaae2))
|
||||
* docs(product): add cms feature topics, package catalog, and inspiration notes ([5b47fafe89e7d1e4fb42646f8bac0e2423828c07](https://git.fellies.net/Citali/cms.fellies.org/commits/5b47fafe89e7d1e4fb42646f8bac0e2423828c07))
|
||||
* docs(versioning): define release policy and close MVP0 pipeline tasks ([516b7730128951a9f0527b89291b21e14e35aca2](https://git.fellies.net/Citali/cms.fellies.org/commits/516b7730128951a9f0527b89291b21e14e35aca2))
|
||||
* chore(ci): add gitea actions runner compose setup ([334a5e35264bf57f1f3586bd78364a5b1d704876](https://git.fellies.net/Citali/cms.fellies.org/commits/334a5e35264bf57f1f3586bd78364a5b1d704876))
|
||||
* chore(repo): remove theoretical workflow and fix prisma ci generation ([a57464d818c10caa2732c1aac113d9a251342de1](https://git.fellies.net/Citali/cms.fellies.org/commits/a57464d818c10caa2732c1aac113d9a251342de1))
|
||||
* chore(repo): update turbo dependency ([37fabad1f8ceb6224c892facb60b5aa2bca02cc5](https://git.fellies.net/Citali/cms.fellies.org/commits/37fabad1f8ceb6224c892facb60b5aa2bca02cc5))
|
||||
* fix(ci): gitea workflows ([c174f840bcfa297937fa40bc3ce4593ddc8ca599](https://git.fellies.net/Citali/cms.fellies.org/commits/c174f840bcfa297937fa40bc3ce4593ddc8ca599))
|
||||
* fix(db): organize imports for biome check ([14c3df623a84a3307d4e825bbf36cccfd882eb49](https://git.fellies.net/Citali/cms.fellies.org/commits/14c3df623a84a3307d4e825bbf36cccfd882eb49))
|
||||
* ci(delivery): add deploy and release workflow scaffolds ([969e88670f5cb3dd0156e4a53bd84d729be4fe82](https://git.fellies.net/Citali/cms.fellies.org/commits/969e88670f5cb3dd0156e4a53bd84d729be4fe82))
|
||||
* ci(gitflow): enforce branch and PR governance checks ([21cc55a1b93f9c7d5ec0db7643f6fe895312a325](https://git.fellies.net/Citali/cms.fellies.org/commits/21cc55a1b93f9c7d5ec0db7643f6fe895312a325))
|
||||
|
||||
## 0.1.0 (2026-02-10)
|
||||
|
||||
### Features
|
||||
|
||||
@@ -4,6 +4,8 @@
|
||||
|
||||
Follow `BRANCHING.md` for long-lived and task branch rules.
|
||||
|
||||
Pull requests should use `.gitea/PULL_REQUEST_TEMPLATE.md` and link the exact TODO item.
|
||||
|
||||
## Commit Message Schema
|
||||
|
||||
This repository uses Conventional Commits.
|
||||
@@ -44,7 +46,7 @@ Rules:
|
||||
## Changelog Process
|
||||
|
||||
- Keep commit messages conventional.
|
||||
- Generate/update `CHANGELOG.md` with:
|
||||
- Generate/update `CHANGELOG.md` with release-focused sections (includes `Unreleased`):
|
||||
|
||||
```bash
|
||||
bun run changelog:release
|
||||
@@ -55,3 +57,10 @@ bun run changelog:release
|
||||
```bash
|
||||
bun run changelog:preview
|
||||
```
|
||||
|
||||
- For exhaustive output across all allowed commit types (`feat`, `fix`, `docs`, `test`, `ci`, `chore`, `refactor`, etc.):
|
||||
|
||||
```bash
|
||||
bun run changelog:full:preview
|
||||
bun run changelog:full:release
|
||||
```
|
||||
|
||||
26
README.md
26
README.md
@@ -3,6 +3,7 @@
|
||||
Roadmap and progress are tracked in `TODO.md` (also visible in admin at `/todo`).
|
||||
Branch model and promotion flow are documented in `BRANCHING.md`.
|
||||
Commit schema and changelog workflow are documented in `CONTRIBUTING.md`.
|
||||
Versioning and release policy are documented in `VERSIONING.md`.
|
||||
|
||||
A baseline monorepo with:
|
||||
|
||||
@@ -69,6 +70,7 @@ bun run dev
|
||||
- `bun run test`
|
||||
- `bun run test:watch`
|
||||
- `bun run test:coverage`
|
||||
- `bun run test:e2e:prepare`
|
||||
- `bun run test:e2e`
|
||||
- `bun run lint`
|
||||
- `bun run typecheck`
|
||||
@@ -85,6 +87,7 @@ bun run dev
|
||||
- Unit/integration/component: Vitest + Testing Library + MSW
|
||||
- E2E: Playwright (separate projects for `web` and `admin`)
|
||||
- Use `bun run test` and `bun run test:e2e` (not plain `bun test`, which uses Bun's runner)
|
||||
- E2E data prep (migrations + seed): `bun run test:e2e:prepare`
|
||||
|
||||
One-time Playwright browser install:
|
||||
|
||||
@@ -94,9 +97,11 @@ bunx playwright install
|
||||
|
||||
## Delivery Scaffolding
|
||||
|
||||
The repo includes a theoretical CI/CD and deployment baseline:
|
||||
The repo includes a CI/CD and deployment baseline:
|
||||
|
||||
- Gitea workflow: `.gitea/workflows/ci-cd-theoretical.yml`
|
||||
- Quality gate workflow: `.gitea/workflows/ci.yml`
|
||||
- Deployment workflow: `.gitea/workflows/deploy.yml`
|
||||
- Release workflow: `.gitea/workflows/release.yml`
|
||||
- App images:
|
||||
- `apps/web/Dockerfile`
|
||||
- `apps/admin/Dockerfile`
|
||||
@@ -115,17 +120,25 @@ Environment examples:
|
||||
|
||||
- `.env.staging.example`
|
||||
- `.env.production.example`
|
||||
- `.env.gitea-runner.example`
|
||||
|
||||
Notes:
|
||||
|
||||
- `dev` remains your local non-docker Bun workflow.
|
||||
- Staging and production compose files are templates and still require real secrets, registry strategy, and deployment host wiring.
|
||||
|
||||
Gitea Actions runner compose (self-hosted):
|
||||
|
||||
```bash
|
||||
cp .env.gitea-runner.example .env.gitea-runner
|
||||
docker compose --env-file .env.gitea-runner -f docker-compose.gitea-runner.yml up -d
|
||||
```
|
||||
|
||||
## Changelog
|
||||
|
||||
- Changelog file: `CHANGELOG.md`
|
||||
- Commit schema: Conventional Commits (see `CONTRIBUTING.md`)
|
||||
- Generate/update changelog from git commits:
|
||||
- Generate/update changelog from git commits (release-focused sections + `Unreleased`):
|
||||
|
||||
```bash
|
||||
bun run changelog:release
|
||||
@@ -137,6 +150,13 @@ bun run changelog:release
|
||||
bun run changelog:preview
|
||||
```
|
||||
|
||||
- Generate exhaustive changelog output across all supported commit types:
|
||||
|
||||
```bash
|
||||
bun run changelog:full:preview
|
||||
bun run changelog:full:release
|
||||
```
|
||||
|
||||
## Docs Tool
|
||||
|
||||
- Docs tool: VitePress
|
||||
|
||||
332
TODO.md
332
TODO.md
@@ -21,144 +21,236 @@ This file is the single source of truth for roadmap and delivery progress.
|
||||
- [x] [P1] RBAC domain model finalized (roles, permissions, resource scopes)
|
||||
- [x] [P1] RBAC enforcement at route and action level in admin
|
||||
- [x] [P1] Permission matrix documented and tested
|
||||
- [~] [P1] i18n baseline architecture (default locale, supported locales, routing strategy)
|
||||
- [~] [P1] i18n runtime integration baseline for both apps (locale provider + message loading)
|
||||
- [~] [P1] Locale persistence and switcher base component (cookie/header + UI)
|
||||
- [x] [P1] i18n baseline architecture (default locale, supported locales, routing strategy)
|
||||
- [x] [P1] i18n runtime integration baseline for both apps (locale provider + message loading)
|
||||
- [x] [P1] Locale persistence and switcher base component (cookie/header + UI)
|
||||
- [x] [P1] Integrate Better Auth core configuration and session wiring
|
||||
- [x] [P1] Bootstrap first-run owner account creation via initial registration flow
|
||||
- [x] [P1] Enforce invariant: exactly one owner user must always exist
|
||||
- [x] [P1] Create hidden technical support user by default (non-demotable, non-deletable)
|
||||
- [~] [P1] Admin registration policy control (allow/deny self-registration for admin panel)
|
||||
- [x] [P1] Admin registration policy control (allow/deny self-registration for admin panel)
|
||||
- [x] [P1] First-start onboarding route for initial owner creation (`/welcome`)
|
||||
- [x] [P1] Split auth entry points (`/welcome`, `/login`, `/register`) with cross-links
|
||||
- [~] [P2] Support fallback sign-in route (`/support/:key`) as break-glass access
|
||||
- [ ] [P1] Reusable CRUD base patterns (list/detail/editor/service/repository)
|
||||
- [ ] [P1] Shared CRUD validation strategy (Zod + server-side enforcement)
|
||||
- [ ] [P1] Shared error and audit hooks for CRUD mutations
|
||||
- [x] [P2] Support fallback sign-in route (`/support/:key`) as break-glass access
|
||||
- [x] [P1] Reusable CRUD base patterns (list/detail/editor/service/repository)
|
||||
- [x] [P1] Shared CRUD validation strategy (Zod + server-side enforcement)
|
||||
- [x] [P1] Shared error and audit hooks for CRUD mutations
|
||||
|
||||
### Admin App
|
||||
|
||||
- [x] [P1] Separate Next.js admin app in monorepo
|
||||
- [x] [P1] App Router + TypeScript + `src/` structure
|
||||
- [x] [P1] Shared DB access via `@cms/db`
|
||||
- [~] [P2] Base admin dashboard shell and roadmap page (`/todo`)
|
||||
- [x] [P2] Base admin dashboard shell and roadmap page (`/todo`)
|
||||
- [x] [P1] Authentication and session model (`admin`, `editor`, `manager`)
|
||||
- [x] [P1] Protected admin routes and session handling
|
||||
- [ ] [P1] Core admin IA (pages/media/users/commissions/settings)
|
||||
- [x] [P1] Temporary admin posts CRUD sandbox for baseline functional validation
|
||||
- [x] [P1] Core admin IA (pages/media/users/commissions/settings)
|
||||
|
||||
### Public App
|
||||
|
||||
- [x] [P1] Separate Next.js public app in monorepo
|
||||
- [x] [P1] App Router + TypeScript + `src/` structure
|
||||
- [~] [P1] Public app connected to shared data layer
|
||||
- [ ] [P1] Localized route structure and middleware rules
|
||||
- [ ] [P2] Public layout system (header/footer/navigation)
|
||||
- [ ] [P1] Header banner rendering from CMS-managed content
|
||||
- [ ] [P2] Basic SEO defaults (metadata, OG, sitemap, robots)
|
||||
- [x] [P1] Public app connected to shared data layer
|
||||
- [x] [P1] Localized route structure and middleware rules
|
||||
- [x] [P2] Public layout system (header/footer/navigation)
|
||||
- [x] [P1] Header banner rendering from CMS-managed content
|
||||
- [x] [P2] Basic SEO defaults (metadata, OG, sitemap, robots)
|
||||
|
||||
### Testing
|
||||
|
||||
- [x] [P1] Vitest + Testing Library + MSW baseline
|
||||
- [x] [P1] Playwright baseline with web/admin projects
|
||||
- [ ] [P1] CI workflow for lint/typecheck/unit/e2e gates
|
||||
- [ ] [P1] Test data strategy (seed fixtures + isolated e2e data)
|
||||
- [~] [P1] RBAC policy unit tests and permission regression suite
|
||||
- [ ] [P1] i18n unit tests (locale resolution, fallback, message key loading)
|
||||
- [ ] [P1] i18n integration tests (admin/public locale switch and persistence)
|
||||
- [ ] [P1] i18n e2e smoke tests (localized headings/content per route)
|
||||
- [ ] [P1] CRUD contract tests for shared service patterns
|
||||
- [~] [P1] Testing workstream moved to `MVP 3: Testing and Quality` and temporarily paused to prioritize feature delivery
|
||||
|
||||
### Documentation
|
||||
|
||||
- [x] [P1] Docs tool baseline added (`docs/` via VitePress)
|
||||
- [x] [P1] RBAC and permission model documentation in docs site
|
||||
- [ ] [P2] i18n conventions docs (keys, namespaces, fallback, translation workflow)
|
||||
- [ ] [P1] CRUD base patterns documentation and examples
|
||||
- [ ] [P1] Environment and deployment runbook docs (dev/staging/production)
|
||||
- [ ] [P2] API and domain glossary pages
|
||||
- [ ] [P2] Architecture Decision Records (ADR) structure and first ADRs
|
||||
- [x] [P2] i18n conventions docs (keys, namespaces, fallback, translation workflow)
|
||||
- [x] [P1] CRUD base patterns documentation and examples
|
||||
- [x] [P1] Environment and deployment runbook docs (dev/staging/production)
|
||||
- [x] [P2] API and domain glossary pages
|
||||
- [x] [P2] Architecture Decision Records (ADR) structure and first ADRs
|
||||
|
||||
### Delivery Pipeline And Runtime
|
||||
|
||||
- [x] [P2] Theoretical Gitea Actions workflow scaffold (`.gitea/workflows/ci-cd-theoretical.yml`)
|
||||
- [x] [P2] Gitea workflow baseline (`.gitea/workflows/ci.yml`, `.gitea/workflows/deploy.yml`, `.gitea/workflows/release.yml`)
|
||||
- [x] [P2] Bun-based Dockerfiles for public and admin apps
|
||||
- [x] [P2] Staging and production docker-compose templates
|
||||
- [ ] [P1] Registry credentials and image push strategy
|
||||
- [ ] [P1] Staging deployment automation against real host
|
||||
- [ ] [P1] Production promotion and rollback procedure
|
||||
- [x] [P1] Registry credentials and image push strategy
|
||||
- [~] [P1] Staging deployment automation against real host
|
||||
- [~] [P1] Production promotion and rollback procedure
|
||||
|
||||
### Git Flow And Branching
|
||||
|
||||
- [ ] [P1] Protect `main` and `staging` branches in Gitea
|
||||
- [ ] [P1] Define PR gates: lint + typecheck + unit + e2e list minimum
|
||||
- [ ] [P1] Enforce one todo item per branch naming convention
|
||||
- [ ] [P2] Add PR template requiring linked TODO step
|
||||
- [ ] [P2] Define branch lifecycle for `todo/*`, `refactor/*`, and `code/*`
|
||||
- [~] [P1] Protect `main` and `staging` branches in Gitea
|
||||
- [x] [P1] Define PR gates: lint + typecheck + unit + e2e list minimum
|
||||
- [x] [P1] Enforce one todo item per branch naming convention
|
||||
- [x] [P2] Add PR template requiring linked TODO step
|
||||
- [x] [P2] Define branch lifecycle for `todo/*`, `refactor/*`, and `code/*`
|
||||
- [x] [P2] Conventional commit schema documentation (`CONTRIBUTING.md`)
|
||||
- [x] [P2] Changelog scaffold and generation scripts (`CHANGELOG.md`, `bun run changelog:*`)
|
||||
- [ ] [P1] Versioning policy definition (SemVer strategy + when to bump major/minor/patch)
|
||||
- [ ] [P1] Source of truth for version (`package.json` root) and release tagging rules (`vX.Y.Z`)
|
||||
- [ ] [P1] Build metadata policy for git hash (`+sha.<short>`) in app runtime footer
|
||||
- [ ] [P1] App footer implementation plan for version + commit hash (admin + web)
|
||||
- [ ] [P2] Automated version injection in CI (stamping build from tag + commit hash)
|
||||
- [ ] [P2] Validation tests for displayed version/hash consistency per deployment
|
||||
- [ ] [P1] Release tagging and changelog publication policy in CI
|
||||
- [x] [P1] Versioning policy definition (SemVer strategy + when to bump major/minor/patch)
|
||||
- [x] [P1] Source of truth for version (`package.json` root) and release tagging rules (`vX.Y.Z`)
|
||||
- [x] [P1] Build metadata policy for git hash (`+sha.<short>`) in app runtime footer
|
||||
- [x] [P1] App footer implementation plan for version + commit hash (admin + web)
|
||||
- [x] [P2] Automated version injection in CI (stamping build from tag + commit hash)
|
||||
- [x] [P2] Validation tests for displayed version/hash consistency per deployment
|
||||
- [x] [P1] Release tagging and changelog publication policy in CI
|
||||
|
||||
### MVP0 Close-Out Checklist
|
||||
|
||||
- [~] [P1] Verify and document protected branch rules in Gitea (`main`, `staging`)
|
||||
- [~] [P1] Run first staging deployment against a real host with deploy workflow and document result
|
||||
- [x] [P1] Replace release workflow placeholders with real release-notes and rollback execution steps
|
||||
- [x] [P1] Expose runtime version + short git hash in admin and public app footer
|
||||
- [x] [P2] Add CI build stamping for version/hash values consumed by app footers
|
||||
- [x] [P2] Add automated tests validating displayed version/hash format and consistency
|
||||
|
||||
## MVP 1: Core CMS Business Features
|
||||
|
||||
### MVP1 Suggested Branch Order
|
||||
|
||||
- [x] [P1] `todo/mvp1-media-foundation`:
|
||||
media model, artwork entity, grouping primitives (gallery/album/category/tag), rendition slots
|
||||
- [~] [P1] `todo/mvp1-media-upload-pipeline`:
|
||||
S3/local upload adapter, media processing presets, metadata input flows, admin media CRUD UI
|
||||
- [~] [P1] `todo/mvp1-pages-navigation-builder`:
|
||||
page CRUD, navigation tree, reusable page blocks (forms/price cards/gallery embeds)
|
||||
- [~] [P1] `todo/mvp1-commissions-customers`:
|
||||
commission request intake + admin CRUD + kanban + customer entity/linking
|
||||
- [x] [P1] `todo/mvp1-announcements-news`:
|
||||
announcement management/rendering + news/blog CRUD and public rendering
|
||||
- [x] [P1] `todo/mvp1-public-rendering-integration`:
|
||||
public rendering for pages/navigation/media/portfolio/announcements and commissioning entrypoints
|
||||
- [~] [P1] `todo/mvp1-e2e-happy-paths`:
|
||||
end-to-end scenarios for page publish, media flow, announcement display, commission flow
|
||||
|
||||
### Separate Product Ideas Backlog (Non-Blocking)
|
||||
|
||||
- [ ] [P2] Smart homepage section presets for artists (featured artwork, latest news, open commissions)
|
||||
- [ ] [P2] Portfolio narrative mode (series story + process notes + ordered media sequence)
|
||||
- [ ] [P2] Reusable CTA/form snippets with per-page override tokens
|
||||
- [ ] [P2] Lightweight CRM timeline per customer (requests, replies, outcomes)
|
||||
- [ ] [P3] AI-assisted alt text and metadata suggestion workflow (human approval required)
|
||||
- [ ] [P3] Auto-generated social crops/promo packs from selected artworks
|
||||
|
||||
### Admin App (Primary Focus)
|
||||
|
||||
- [ ] [P1] Page management (create/edit/publish/unpublish/schedule)
|
||||
- [ ] [P1] Navigation management (menus, nested items, order, visibility)
|
||||
- [ ] [P1] Media library (upload, browse, replace, delete)
|
||||
- [ ] [P1] Media enrichment metadata (alt text, copyright, author, source, tags)
|
||||
- [ ] [P1] Media refinement for artworks (medium, dimensions, year, framing, availability)
|
||||
- [ ] [P1] Users management (invite, roles, status)
|
||||
- [ ] [P1] Disable/ban user function and enforcement in auth/session checks
|
||||
- [~] [P1] Owner/support protection rules in user management actions (cannot delete/demote)
|
||||
- [ ] [P1] Commissions management (request intake, owner, due date, notes)
|
||||
- [ ] [P1] Kanban workflow for commissions (new, scoped, in-progress, review, done)
|
||||
- [ ] [P1] Header banner management (message, CTA, active window)
|
||||
- [~] [P1] Page management (create/edit/publish/unpublish/schedule)
|
||||
- [x] [P1] Page builder with reusable content blocks (hero, rich text, gallery, CTA, forms, price cards)
|
||||
- [x] [P1] Navigation management (menus, nested items, order, visibility)
|
||||
- [~] [P1] Media library (upload, browse, replace, delete) with media-type classification (artwork, banner, promo, generic, video/gif)
|
||||
- [x] [P1] Media enrichment metadata (alt text, copyright, author, source, tags, licensing, usage context)
|
||||
- [x] [P1] Portfolio grouping primitives (galleries, albums, categories, tags) with ordering/visibility controls
|
||||
- [x] [P1] Artwork refinement fields (medium, dimensions, year, framing, availability, price visibility)
|
||||
- [x] [P1] Artwork rendition management (thumbnail, card, full, retina/custom sizes)
|
||||
- [x] [P1] Type-specific processing presets (artwork/banner/promo/video/gif) with validation rules
|
||||
- [x] [P1] Users management (invite, roles, status)
|
||||
- [x] [P1] Disable/ban user function and enforcement in auth/session checks
|
||||
- [x] [P1] Owner/support protection rules in user management actions (cannot delete/demote)
|
||||
- [x] [P1] Commissions management (request intake, owner, due date, notes, linked customer, linked artworks)
|
||||
- [x] [P1] Customer records (contact profile, notes, consent flags, recurrence marker)
|
||||
- [x] [P1] Customer-to-commission linkage and reuse workflow (no re-entry for recurring customers)
|
||||
- [x] [P1] Kanban workflow for commissions (new, scoped, in-progress, review, done)
|
||||
- [x] [P1] Header banner management (message, CTA, active window)
|
||||
- [x] [P1] Announcements management (prominent site notices with schedule, priority, and audience targeting)
|
||||
- [~] [P2] News/blog editorial workflow (draft/review/publish, authoring metadata)
|
||||
|
||||
### Public App
|
||||
|
||||
- [ ] [P1] Dynamic page rendering from CMS page entities
|
||||
- [ ] [P1] Navigation rendering from managed menu structure
|
||||
- [ ] [P1] Media entity rendering with enrichment data
|
||||
- [ ] [P1] Translation-ready content model for public entities (pages/news/navigation labels)
|
||||
- [x] [P1] Dynamic page rendering from CMS page entities
|
||||
- [x] [P1] Navigation rendering from managed menu structure
|
||||
- [x] [P1] Media entity rendering with enrichment data
|
||||
- [x] [P1] Portfolio views (gallery/album/category/tag) for artworks with filter and sort controls
|
||||
- [x] [P1] Rendition-aware media delivery (thumbnail/card/full) per template slot
|
||||
- [x] [P1] Translation-ready content model for public entities (pages/news/navigation labels)
|
||||
- [ ] [P2] Artwork views and listing filters
|
||||
- [ ] [P1] Commission request submission flow
|
||||
- [ ] [P1] Header banner render logic and fallbacks
|
||||
- [x] [P1] Commission request submission flow
|
||||
- [x] [P1] Header banner render logic and fallbacks
|
||||
- [x] [P1] Announcement render slots (homepage + optional global/top banner position)
|
||||
|
||||
### News / Blog (Secondary Track)
|
||||
|
||||
- [ ] [P2] News/blog content type (not primary CMS domain)
|
||||
- [ ] [P2] Admin list/editor for news posts
|
||||
- [ ] [P2] Public news index + detail pages
|
||||
- [ ] [P3] Tag/category and basic archive support
|
||||
- [x] [P1] News/blog content type (editorial content for artist updates and process posts)
|
||||
- [x] [P1] Admin list/editor for news posts
|
||||
- [x] [P1] Public news index + detail pages
|
||||
- [ ] [P2] Tag/category and basic archive support
|
||||
|
||||
### Testing
|
||||
|
||||
- [ ] [P1] Unit tests for content schemas and service logic
|
||||
- [ ] [P1] Component tests for admin forms (pages/media/navigation)
|
||||
- [ ] [P1] Integration tests for owner invariant and hidden support-user protection
|
||||
- [ ] [P1] Integration tests for registration allow/deny behavior
|
||||
- [ ] [P1] Integration tests for translated content CRUD and locale-specific validation
|
||||
- [ ] [P1] E2E happy paths: create page, publish, see on public app
|
||||
- [ ] [P1] E2E happy paths: media upload + artwork refinement display
|
||||
- [ ] [P1] E2E happy paths: commissions kanban transitions
|
||||
- [~] [P1] Testing workstream moved to `MVP 3: Testing and Quality` and temporarily paused to prioritize feature delivery
|
||||
|
||||
## MVP 2: Production Readiness
|
||||
### Code Documentation And Handover
|
||||
|
||||
- [x] [P1] Create architecture map per package/app (`what exists`, `why`, `how to extend`) for `@cms/db`, `@cms/content`, `@cms/crud`, `@cms/ui`, `apps/admin`, `apps/web`
|
||||
- [x] [P1] Add module-level ownership docs for auth, media, pages/navigation, commissions, announcements/news flows
|
||||
- [x] [P1] Document critical invariants (single owner rule, protected support user, registration policy gates, media storage key contract)
|
||||
- [x] [P1] Add “request lifecycle” docs for key flows (auth sign-in/up, media upload, page publish, commission status change)
|
||||
- [x] [P1] Add coding handover playbook: local setup, migration workflow, test strategy, branch/release process, common failure recovery
|
||||
- [ ] [P2] Add code-level diagrams (Mermaid) for service boundaries and data relationships
|
||||
- [ ] [P2] Add route/action inventory for admin and public apps with linked source files
|
||||
|
||||
## MVP 1.5: MVP1 Refinements (Planned)
|
||||
|
||||
### Scope
|
||||
|
||||
- [ ] [P1] Refine and harden all completed MVP1 modules (pages, navigation, media, portfolio, commissions, news)
|
||||
- [ ] [P1] Resolve UX rough edges discovered during MVP1 implementation
|
||||
- [ ] [P1] Improve admin workflows and reduce editor friction for daily use
|
||||
- [ ] [P1] Stabilize public rendering behavior with better fallbacks and consistency
|
||||
|
||||
## MVP 2: MVP1 Quality Refinements (Planned)
|
||||
|
||||
### Scope
|
||||
|
||||
- [ ] [P1] Finish non-blocking enhancements postponed from MVP1 implementation
|
||||
- [ ] [P1] Improve data modeling consistency and migration hygiene for MVP1 modules
|
||||
- [ ] [P1] Consolidate reusable UI and domain primitives introduced during MVP1
|
||||
- [ ] [P1] Address integration debt before moving to larger design/production phases
|
||||
|
||||
## MVP 3: UX/UI And Theming
|
||||
|
||||
### MVP3 Suggested Branch Order
|
||||
|
||||
- [ ] [P1] `todo/mvp3-design-tokens-foundation`:
|
||||
establish shared design tokens (color, spacing, radius, typography scale, motion) in `@cms/ui` and app-level theme contracts
|
||||
- [ ] [P1] `todo/mvp3-admin-layout-polish`:
|
||||
refine admin shell, navigation hierarchy, spacing rhythm, table/form visual consistency, empty/loading/error states
|
||||
- [ ] [P1] `todo/mvp3-public-layout-and-templates`:
|
||||
define public visual direction (hero/header/footer/content widths), page templates for home/content/news/portfolio
|
||||
- [ ] [P2] `todo/mvp3-component-library-pass`:
|
||||
align shadcn-based primitives with CMS brand system (buttons, inputs, cards, badges, tabs, dialogs, toasts)
|
||||
- [ ] [P2] `todo/mvp3-responsive-and-a11y-pass`:
|
||||
mobile/tablet breakpoints, keyboard flow, focus states, contrast checks, reduced-motion support
|
||||
- [ ] [P2] `todo/mvp3-visual-regression-baseline`:
|
||||
add screenshot baselines for critical admin/public routes to guard layout regressions
|
||||
|
||||
### Deliverables
|
||||
|
||||
- [ ] [P1] Admin UI baseline feels production-ready for daily editorial use
|
||||
- [ ] [P1] Public UI baseline is template-ready for artist branding and portfolio storytelling
|
||||
- [ ] [P2] Shared UI primitives are consistent across admin and public apps
|
||||
- [ ] [P2] Core routes have visual-regression coverage for the new layout baseline
|
||||
|
||||
## MVP 4: Production Readiness
|
||||
|
||||
### Admin App
|
||||
|
||||
- [ ] [P1] Audit log for key content operations
|
||||
- [ ] [P2] Revision history for pages/navigation/media metadata
|
||||
- [ ] [P1] Permission matrix refinement with granular scopes
|
||||
- [ ] [P2] Media processing orchestration UI (queue status, retries, processing diagnostics)
|
||||
- [ ] [P2] Automatic color palette extraction from artworks (stored for theming/filtering)
|
||||
- [ ] [P2] Watermark pipeline for artwork renditions with configurable watermark asset/position/opacity
|
||||
- [ ] [P2] Advanced media transforms by type (video transcode profiles, gif optimization, banner safe-area presets)
|
||||
- [ ] [P2] Announcement targeting refinement (locale/segment targeting rules)
|
||||
- [ ] [P2] Customer lifecycle tooling (status stages, communication history, export)
|
||||
- [ ] [P1] Verify email pipeline and operational templates (welcome/verify/resend)
|
||||
- [ ] [P1] Forgot password/reset password pipeline and support tooling
|
||||
- [ ] [P2] GUI page to edit role-permission mappings with safety guardrails
|
||||
- [ ] [P2] Translation management UI for admin (language toggles, key coverage, missing translation markers)
|
||||
- [ ] [P2] Time-boxed support access keys generated by privileged admins; while active, disable direct support-user password login on the regular auth form
|
||||
- [ ] [P2] Keep permanent emergency support key fallback via env (`CMS_SUPPORT_LOGIN_KEY`)
|
||||
- [ ] [P2] Error boundaries and UX fallback states
|
||||
|
||||
### Public App
|
||||
@@ -167,6 +259,7 @@ This file is the single source of truth for roadmap and delivery progress.
|
||||
- [ ] [P2] Performance budget checks (Core Web Vitals)
|
||||
- [ ] [P1] 404/500 content-aware error pages
|
||||
- [ ] [P1] Accessibility review and fixes
|
||||
- [ ] [P2] Theme assistance from extracted artwork palettes (opt-in per page/section)
|
||||
|
||||
### Platform
|
||||
|
||||
@@ -178,6 +271,38 @@ This file is the single source of truth for roadmap and delivery progress.
|
||||
|
||||
### Testing
|
||||
|
||||
- [~] [P1] Testing workstream moved to `MVP 5: Testing and Quality` and temporarily paused to prioritize feature delivery
|
||||
|
||||
## MVP 5: Testing and Quality
|
||||
|
||||
### Status
|
||||
|
||||
- [~] [P1] Temporary freeze for active testing execution in local scripts and CI while MVP feature delivery is prioritized
|
||||
- [ ] [P1] Re-enable root package test scripts (`test`, `test:*`) after MVP feature catch-up
|
||||
- [ ] [P1] Re-enable CI quality test gates (unit + integration + e2e) in `.gitea/workflows/ci.yml`
|
||||
|
||||
### Baseline And Regression
|
||||
|
||||
- [x] [P1] Vitest + Testing Library + MSW baseline
|
||||
- [x] [P1] Playwright baseline with web/admin projects
|
||||
- [x] [P1] CI workflow for lint/typecheck/unit/e2e gates
|
||||
- [x] [P1] Test data strategy (seed fixtures + isolated e2e data)
|
||||
- [x] [P1] RBAC policy unit tests and permission regression suite
|
||||
- [x] [P1] i18n unit tests (locale resolution, fallback, message key loading)
|
||||
- [x] [P1] i18n integration tests (admin/public locale switch and persistence)
|
||||
- [x] [P1] i18n e2e smoke tests (localized headings/content per route)
|
||||
- [x] [P1] CRUD contract tests for shared service patterns
|
||||
- [x] [P1] Unit tests for content schemas and service logic
|
||||
- [x] [P1] Component tests for admin forms (pages/media/navigation)
|
||||
- [x] [P1] Integration tests for owner invariant and hidden support-user protection
|
||||
- [x] [P1] Integration tests for registration allow/deny behavior
|
||||
- [x] [P1] Integration tests for translated content CRUD and locale-specific validation
|
||||
- [~] [P1] E2E happy paths: create page, publish, see on public app
|
||||
- [~] [P1] E2E happy paths: media upload + artwork refinement display
|
||||
- [~] [P1] E2E happy paths: commissions kanban transitions
|
||||
|
||||
### Advanced Quality Work
|
||||
|
||||
- [ ] [P2] Visual regression workflow for critical templates
|
||||
- [ ] [P2] Load/perf tests for key public routes
|
||||
- [ ] [P2] Flake tracking and quarantine policy for e2e
|
||||
@@ -192,8 +317,63 @@ This file is the single source of truth for roadmap and delivery progress.
|
||||
- [2026-02-10] Next.js 16 deprecates `middleware.ts` convention in favor of `proxy.ts`; admin route guard now lives at `apps/admin/src/proxy.ts`.
|
||||
- [2026-02-10] `server-only` imports break Bun CLI scripts; shared auth bootstrap code used by scripts must avoid Next-only runtime markers.
|
||||
- [2026-02-10] Auth delete-account endpoints now block protected users (support + canonical owner); admin user-management delete/demote guards remain to be implemented.
|
||||
- [2026-02-10] Public app i18n baseline now uses `next-intl` with a Zustand-backed language switcher and path-stable routes; admin i18n runtime is still pending.
|
||||
- [2026-02-10] Public app i18n baseline now uses `next-intl` with a Zustand-backed language switcher and path-stable routes.
|
||||
- [2026-02-10] Public baseline locales are now `de`, `en`, `es`, `fr`; locale enable/disable policy will move to admin settings later.
|
||||
- [2026-02-10] Shared CRUD base (`@cms/crud`) is live with validation, not-found errors, and audit hook contracts; only posts are migrated so far.
|
||||
- [2026-02-10] Admin dashboard includes a temporary posts CRUD sandbox (create/update/delete) to validate the shared CRUD base through the real app UI.
|
||||
- [2026-02-10] Admin i18n baseline now resolves locale from cookie and loads runtime message dictionaries in root layout; admin locale switcher is active on auth and dashboard views.
|
||||
- [2026-02-10] Admin self-registration policy is now managed via `/settings` and persisted in `system_setting`; env var is fallback/default only.
|
||||
- [2026-02-10] E2E now runs with deterministic preparation (`test:e2e:prepare`: generate + migrate deploy + seed) before Playwright execution.
|
||||
- [2026-02-10] CI quality workflow `.gitea/workflows/ci.yml` enforces `check`, `typecheck`, `test`, and `test:e2e` against a PostgreSQL service.
|
||||
- [2026-02-10] Admin app now uses a shared shell with permission-aware navigation and dedicated IA routes (`/pages`, `/media`, `/users`, `/commissions`).
|
||||
- [2026-02-10] Public app now has a shared site layout (`banner/header/footer`), DB-backed header banner config, and SEO defaults (`metadata`, `robots`, `sitemap`).
|
||||
- [2026-02-10] Testing baseline now includes explicit RBAC regression checks, locale-resolution unit tests (admin/web), CRUD service contract tests, and i18n smoke e2e routes.
|
||||
- [2026-02-10] i18n conventions are now documented as an engineering standard (`docs/product-engineering/i18n-conventions.md`).
|
||||
- [2026-02-10] Docs now include a domain glossary, public API glossary, and ADR baseline with initial accepted decision (`ADR 0001`).
|
||||
- [2026-02-10] Delivery and release governance now include branch/PR policy checks, deploy/release workflows, and explicit versioning policy (`VERSIONING.md`).
|
||||
- [2026-02-11] Release workflow now publishes changelog-derived notes to Gitea releases and supports executable production rollback via SSH + compose tag switch.
|
||||
- [2026-02-11] Branch protection verification checklist is now documented; final UI-level verification remains environment-specific.
|
||||
- [2026-02-11] Added a staging deployment execution checklist and deployment-record template to capture first real-host rollout evidence.
|
||||
- [2026-02-11] Artist-focused feature map refined: MVP1 covers portfolio media/domain CRUD + announcements + customer/commission linking; MVP4 covers advanced automation (watermark, palette extraction, media transform pipelines).
|
||||
- [2026-02-11] `gaertan` inspiration to reuse: S3 object strategy with signed delivery, commission type/options/extras/custom-input modeling, request-status kanban mapping, and gallery rendition/color extraction patterns.
|
||||
- [2026-02-11] MVP1 media foundation started: portfolio domain models (`MediaAsset`, `Artwork`, galleries/albums/categories/tags, rendition links) plus initial admin `/media` and `/portfolio` data views.
|
||||
- [2026-02-11] `prisma migrate dev --name media_foundation` can fail when DB endpoint is unreachable; apply this named migration once `DATABASE_URL` host is reachable again.
|
||||
- [2026-02-11] MVP1 media foundation now includes baseline create/link workflows in admin (`/media`, `/portfolio`), seeded sample portfolio entities, and schema/service test coverage.
|
||||
- [2026-02-12] MVP1 media upload pipeline started: admin `/api/media/upload` accepts metadata + file upload with permission checks, stores files via local adapter (`.data/media`), and persists upload metadata to `MediaAsset`.
|
||||
- [2026-02-12] Upload storage is now provider-based (`local` + `s3`) via `CMS_MEDIA_STORAGE_PROVIDER`; admin-side GUI toggle remains a later MVP item.
|
||||
- [2026-02-12] Media storage keys now use asset-centric layout (`tenant/<id>/asset/<assetId>/<fileRole>/<assetId>__<variant>.<ext>`) with DB-managed media taxonomy.
|
||||
- [2026-02-12] Admin media CRUD now includes list-to-detail flow (`/media/:id`) with metadata edit and delete actions.
|
||||
- [2026-02-12] Media enrichment metadata baseline completed: `MediaAsset` now supports licensing/usage/location/captured-at fields across upload input, admin editor, and public artwork detail rendering.
|
||||
- [2026-02-12] MVP1 pages/navigation baseline started: `Page`, `NavigationMenu`, and `NavigationItem` models plus admin CRUD routes (`/pages`, `/pages/:id`, `/navigation`).
|
||||
- [2026-02-12] Public app now renders CMS-managed navigation (header) and CMS-managed pages by slug (including homepage when `home` page exists).
|
||||
- [2026-02-12] Commissions/customer baseline added: admin `/commissions` now supports customer creation, commission intake, status transitions, and a basic kanban board.
|
||||
- [2026-02-12] Announcements/news baseline added: admin `/announcements` + `/news` management screens and public announcement rendering slots (`global_top`, `homepage`).
|
||||
- [2026-02-12] Public news routes now exist at `/news` and `/news/:slug` (detail restricted to published posts).
|
||||
- [2026-02-12] Added `e2e/happy-paths.pw.ts` covering admin login, page publish/public rendering, announcement rendering, media upload, and commission status transition.
|
||||
- [2026-02-12] Expanded unit coverage for content/domain schemas and post service behavior (`packages/content/src/domain-schemas.test.ts`, `packages/db/src/posts.test.ts`).
|
||||
- [2026-02-12] Added auth flow integration tests for `/login`, `/register`, `/welcome` to validate registration allow/deny and owner bootstrap redirects.
|
||||
- [2026-02-12] Admin settings now manage public header banner (enabled/message/CTA), backed by `system_setting` and consumed by public layout rendering.
|
||||
- [2026-02-12] Added owner/support invariant integration tests for auth guards (`apps/admin/src/lib/auth/server.test.ts`), covering protected-user deletion blocking and one-owner repair/promotion rules.
|
||||
- [2026-02-12] Started admin form component tests with media upload behavior coverage (`apps/admin/src/components/media/media-upload-form.test.tsx`).
|
||||
- [2026-02-12] Added code handover documentation baseline: architecture map, critical invariants, request lifecycles, and onboarding playbook under `docs/product-engineering/`.
|
||||
- [2026-02-12] Completed admin form component coverage for pages/navigation/media using isolated form components and tests.
|
||||
- [2026-02-12] Added page translation CRUD baseline (`PageTranslation`) with locale validation (`de/en/es/fr`) and integration coverage for localized read + fallback behavior.
|
||||
- [2026-02-12] Page editor now supports locale translations in `/pages/:id`; public page rendering uses locale-aware page lookup with base-content fallback.
|
||||
- [2026-02-12] Public rendering integration advanced with locale-aware navigation/news translations and a new public commission request entry route (`/[locale]/commissions`) that creates/reuses customer records and opens a `new` commission.
|
||||
- [2026-02-12] Public portfolio baseline added with `/{locale}/portfolio` and `/{locale}/portfolio/{slug}`, including published-artwork filters (gallery/album/category/tag), rendition image streaming via web `/api/media/file/:id`, and media-aware artwork detail rendering.
|
||||
- [2026-02-12] Portfolio grouping controls completed in admin `/portfolio`: galleries/albums/categories/tags now support visibility and sort-order management (create/update/delete), and public tag filters now respect visibility.
|
||||
- [2026-02-12] Artwork refinement baseline completed: admin `/portfolio` now captures/edits medium, dimensions, year, framing, availability, publish state, and optional price visibility (`priceAmountCents` + `priceCurrency`), with public artwork detail rendering visible prices only.
|
||||
- [2026-02-12] Artwork rendition management completed: admin `/portfolio` supports `thumbnail/card/full/retina/custom` slot assignment with dimensions and primary flag, plus per-artwork rendition listing and delete controls.
|
||||
- [2026-02-12] Media type presets baseline completed in upload API: server-side validation now uses shared per-type rules (mime + max size) for `artwork/banner/promotion/video/gif/generic`, with optional env cap override via `CMS_MEDIA_UPLOAD_MAX_BYTES`.
|
||||
- [2026-02-12] Page builder reusable blocks completed: admin block editor now supports full field editing + ordering controls for hero/rich-text/gallery/cta/form/price-cards; public renderer includes form-link behavior for `contact`/`commission` keys.
|
||||
- [2026-02-12] Navigation management completed: admin `/navigation` now supports menu update/delete controls, nested item parent selection via menu-local dropdown, and full order/visibility updates across menus and items.
|
||||
- [2026-02-12] Users management baseline completed: admin `/users` now supports managed user creation, role changes (`admin/editor/manager`), status changes (ban/unban), and protected/system guardrails for role-change/delete/ban actions.
|
||||
- [2026-02-12] Commissions management completed: admin kanban cards now include inline detail editing (assignee/customer/budget/due date/notes), linked-artwork references via `linkedArtworkIds`, and creation/edit flows use assignable users instead of raw ID entry.
|
||||
- [2026-02-12] Announcements/news completed: announcements now support locale audience targeting (`targetLocales`) with public locale-aware rendering, and homepage news list now uses locale-aware published posts only.
|
||||
- [2026-02-12] Public rendering integration completed: portfolio now supports locale-aware tag filters and explicit sort controls, while db/service sorting and rendition selection align public listing/detail media delivery.
|
||||
- [2026-02-12] Public UX pass: commission request flow now reports explicit invalid budget range errors, and header navigation now falls back to localized defaults (`home`, `portfolio`, `news`, `commissions`) when no CMS menu exists; seed data now creates those default menu entries.
|
||||
- [2026-02-12] Added `e2e/public-rendering.pw.ts` web coverage for fallback navigation visibility, portfolio routes, and commission submission validation (invalid budget range + successful submission path).
|
||||
- [2026-02-12] Testing execution is temporarily paused for delivery velocity: root test scripts are stubbed and CI test steps are disabled; all testing backlog is consolidated under `MVP 3: Testing and Quality`.
|
||||
|
||||
## How We Use This File
|
||||
|
||||
|
||||
71
VERSIONING.md
Normal file
71
VERSIONING.md
Normal file
@@ -0,0 +1,71 @@
|
||||
# Versioning Policy
|
||||
|
||||
## Source Of Truth
|
||||
|
||||
- Canonical version: root `package.json` field `version`
|
||||
- Tag format: `vX.Y.Z`
|
||||
|
||||
Tag validation is enforced in CI:
|
||||
|
||||
- `.gitea/scripts/validate-tag-version.sh`
|
||||
|
||||
## SemVer Strategy
|
||||
|
||||
- `major`: breaking API/behavior changes
|
||||
- `minor`: backward-compatible features
|
||||
- `patch`: backward-compatible fixes
|
||||
|
||||
## Build Metadata Policy
|
||||
|
||||
Use git metadata in runtime display format:
|
||||
|
||||
- `<version>+sha.<short>`
|
||||
|
||||
Example:
|
||||
|
||||
- `0.1.0+sha.a1b2c3d`
|
||||
|
||||
## Footer Display Plan (Admin + Web)
|
||||
|
||||
Planned runtime footer fields:
|
||||
|
||||
- app name
|
||||
- version from root `package.json`
|
||||
- commit hash (short)
|
||||
- environment (`dev|staging|production`)
|
||||
|
||||
Implementation note:
|
||||
|
||||
- inject values at build/deploy time through env vars
|
||||
- render in shared footer components
|
||||
|
||||
## CI Version Injection
|
||||
|
||||
Release/deploy workflows pass release tag and commit metadata:
|
||||
|
||||
- `.gitea/workflows/release.yml`
|
||||
- `.gitea/workflows/deploy.yml`
|
||||
|
||||
Required inputs:
|
||||
|
||||
- release tag (`vX.Y.Z`)
|
||||
- image tag for deployment
|
||||
|
||||
## Validation Strategy
|
||||
|
||||
CI validations:
|
||||
|
||||
- tag equals `v${package.json.version}`
|
||||
- required checks pass before release builds
|
||||
|
||||
Runtime validations (planned):
|
||||
|
||||
- smoke tests assert footer version/hash format
|
||||
- environment-specific deployment checks assert expected image tag
|
||||
|
||||
## Changelog and Release Publication
|
||||
|
||||
- changelog generation command:
|
||||
- `bun run changelog:release`
|
||||
- release workflow generates changelog on tag pipeline
|
||||
- release notes publication remains a dedicated step in CI workflow.
|
||||
@@ -8,12 +8,15 @@
|
||||
"build": "bun --env-file=../../.env next build",
|
||||
"start": "bun --env-file=../../.env next start --port 3001",
|
||||
"auth:seed:support": "bun --env-file=../../.env ./scripts/seed-support-user.ts",
|
||||
"auth:seed:e2e-admin": "bun --env-file=../../.env ./scripts/seed-e2e-admin-user.ts",
|
||||
"lint": "biome check src",
|
||||
"typecheck": "tsc -p tsconfig.json --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-s3": "3.988.0",
|
||||
"@cms/content": "workspace:*",
|
||||
"@cms/db": "workspace:*",
|
||||
"@cms/i18n": "workspace:*",
|
||||
"@cms/ui": "workspace:*",
|
||||
"@tanstack/react-form": "1.28.0",
|
||||
"@tanstack/react-query": "5.90.20",
|
||||
|
||||
11
apps/admin/scripts/seed-e2e-admin-user.ts
Normal file
11
apps/admin/scripts/seed-e2e-admin-user.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { ensureE2EAdminBootstrap } from "../src/lib/auth/server"
|
||||
|
||||
async function main() {
|
||||
await ensureE2EAdminBootstrap()
|
||||
console.log("E2E admin bootstrap completed")
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error(error)
|
||||
process.exit(1)
|
||||
})
|
||||
476
apps/admin/src/app/announcements/page.tsx
Normal file
476
apps/admin/src/app/announcements/page.tsx
Normal file
@@ -0,0 +1,476 @@
|
||||
import {
|
||||
createAnnouncement,
|
||||
deleteAnnouncement,
|
||||
listAnnouncements,
|
||||
updateAnnouncement,
|
||||
} from "@cms/db"
|
||||
import { Button } from "@cms/ui/button"
|
||||
import { revalidatePath } from "next/cache"
|
||||
import { redirect } from "next/navigation"
|
||||
|
||||
import { AdminShell } from "@/components/admin-shell"
|
||||
import { requirePermissionForRoute } from "@/lib/route-guards"
|
||||
|
||||
export const dynamic = "force-dynamic"
|
||||
|
||||
type SearchParamsInput = Record<string, string | string[] | undefined>
|
||||
const SUPPORTED_LOCALES = ["de", "en", "es", "fr"] as const
|
||||
|
||||
function readFirstValue(value: string | string[] | undefined): string | null {
|
||||
if (Array.isArray(value)) {
|
||||
return value[0] ?? null
|
||||
}
|
||||
|
||||
return value ?? null
|
||||
}
|
||||
|
||||
function readInputString(formData: FormData, field: string): string {
|
||||
const value = formData.get(field)
|
||||
return typeof value === "string" ? value.trim() : ""
|
||||
}
|
||||
|
||||
function readNullableString(formData: FormData, field: string): string | null {
|
||||
const value = readInputString(formData, field)
|
||||
return value.length > 0 ? value : null
|
||||
}
|
||||
|
||||
function readNullableDate(formData: FormData, field: string): Date | null {
|
||||
const value = readInputString(formData, field)
|
||||
|
||||
if (!value) {
|
||||
return null
|
||||
}
|
||||
|
||||
const parsed = new Date(value)
|
||||
|
||||
if (Number.isNaN(parsed.getTime())) {
|
||||
return null
|
||||
}
|
||||
|
||||
return parsed
|
||||
}
|
||||
|
||||
function readLocaleSelections(formData: FormData, field: string): string[] {
|
||||
const values = formData.getAll(field)
|
||||
const locales = new Set<string>()
|
||||
|
||||
for (const value of values) {
|
||||
if (
|
||||
typeof value === "string" &&
|
||||
SUPPORTED_LOCALES.includes(value as (typeof SUPPORTED_LOCALES)[number])
|
||||
) {
|
||||
locales.add(value)
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(locales)
|
||||
}
|
||||
|
||||
function readInt(formData: FormData, field: string, fallback = 100): number {
|
||||
const value = readInputString(formData, field)
|
||||
|
||||
if (!value) {
|
||||
return fallback
|
||||
}
|
||||
|
||||
const parsed = Number.parseInt(value, 10)
|
||||
|
||||
if (!Number.isFinite(parsed)) {
|
||||
return fallback
|
||||
}
|
||||
|
||||
return parsed
|
||||
}
|
||||
|
||||
function redirectWithState(params: { notice?: string; error?: string }) {
|
||||
const query = new URLSearchParams()
|
||||
|
||||
if (params.notice) {
|
||||
query.set("notice", params.notice)
|
||||
}
|
||||
|
||||
if (params.error) {
|
||||
query.set("error", params.error)
|
||||
}
|
||||
|
||||
const value = query.toString()
|
||||
redirect(value ? `/announcements?${value}` : "/announcements")
|
||||
}
|
||||
|
||||
async function createAnnouncementAction(formData: FormData) {
|
||||
"use server"
|
||||
|
||||
await requirePermissionForRoute({
|
||||
nextPath: "/announcements",
|
||||
permission: "banner:write",
|
||||
scope: "global",
|
||||
})
|
||||
|
||||
try {
|
||||
await createAnnouncement({
|
||||
title: readInputString(formData, "title"),
|
||||
message: readInputString(formData, "message"),
|
||||
placement: readInputString(formData, "placement"),
|
||||
targetLocales: readLocaleSelections(formData, "targetLocales"),
|
||||
priority: readInt(formData, "priority", 100),
|
||||
ctaLabel: readNullableString(formData, "ctaLabel"),
|
||||
ctaHref: readNullableString(formData, "ctaHref"),
|
||||
startsAt: readNullableDate(formData, "startsAt"),
|
||||
endsAt: readNullableDate(formData, "endsAt"),
|
||||
isVisible: readInputString(formData, "isVisible") === "true",
|
||||
})
|
||||
} catch {
|
||||
redirectWithState({ error: "Failed to create announcement." })
|
||||
}
|
||||
|
||||
revalidatePath("/announcements")
|
||||
revalidatePath("/")
|
||||
redirectWithState({ notice: "Announcement created." })
|
||||
}
|
||||
|
||||
async function updateAnnouncementAction(formData: FormData) {
|
||||
"use server"
|
||||
|
||||
await requirePermissionForRoute({
|
||||
nextPath: "/announcements",
|
||||
permission: "banner:write",
|
||||
scope: "global",
|
||||
})
|
||||
|
||||
try {
|
||||
await updateAnnouncement({
|
||||
id: readInputString(formData, "id"),
|
||||
title: readInputString(formData, "title"),
|
||||
message: readInputString(formData, "message"),
|
||||
placement: readInputString(formData, "placement"),
|
||||
targetLocales: readLocaleSelections(formData, "targetLocales"),
|
||||
priority: readInt(formData, "priority", 100),
|
||||
ctaLabel: readNullableString(formData, "ctaLabel"),
|
||||
ctaHref: readNullableString(formData, "ctaHref"),
|
||||
startsAt: readNullableDate(formData, "startsAt"),
|
||||
endsAt: readNullableDate(formData, "endsAt"),
|
||||
isVisible: readInputString(formData, "isVisible") === "true",
|
||||
})
|
||||
} catch {
|
||||
redirectWithState({ error: "Failed to update announcement." })
|
||||
}
|
||||
|
||||
revalidatePath("/announcements")
|
||||
revalidatePath("/")
|
||||
redirectWithState({ notice: "Announcement updated." })
|
||||
}
|
||||
|
||||
async function deleteAnnouncementAction(formData: FormData) {
|
||||
"use server"
|
||||
|
||||
await requirePermissionForRoute({
|
||||
nextPath: "/announcements",
|
||||
permission: "banner:write",
|
||||
scope: "global",
|
||||
})
|
||||
|
||||
try {
|
||||
await deleteAnnouncement(readInputString(formData, "id"))
|
||||
} catch {
|
||||
redirectWithState({ error: "Failed to delete announcement." })
|
||||
}
|
||||
|
||||
revalidatePath("/announcements")
|
||||
revalidatePath("/")
|
||||
redirectWithState({ notice: "Announcement deleted." })
|
||||
}
|
||||
|
||||
function dateInputValue(value: Date | null): string {
|
||||
if (!value) {
|
||||
return ""
|
||||
}
|
||||
|
||||
return value.toISOString().slice(0, 10)
|
||||
}
|
||||
|
||||
export default async function AnnouncementsPage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<SearchParamsInput>
|
||||
}) {
|
||||
const role = await requirePermissionForRoute({
|
||||
nextPath: "/announcements",
|
||||
permission: "banner:read",
|
||||
scope: "global",
|
||||
})
|
||||
|
||||
const [resolvedSearchParams, announcements] = await Promise.all([
|
||||
searchParams,
|
||||
listAnnouncements(200),
|
||||
])
|
||||
|
||||
const notice = readFirstValue(resolvedSearchParams.notice)
|
||||
const error = readFirstValue(resolvedSearchParams.error)
|
||||
|
||||
return (
|
||||
<AdminShell
|
||||
role={role}
|
||||
activePath="/announcements"
|
||||
badge="Admin App"
|
||||
title="Announcements"
|
||||
description="Manage public site announcements with schedule and placement controls."
|
||||
>
|
||||
{notice ? (
|
||||
<section className="rounded-xl border border-emerald-300 bg-emerald-50 px-4 py-3 text-sm text-emerald-800">
|
||||
{notice}
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
{error ? (
|
||||
<section className="rounded-xl border border-red-300 bg-red-50 px-4 py-3 text-sm text-red-800">
|
||||
{error}
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
<section className="rounded-xl border border-neutral-200 p-6">
|
||||
<h2 className="text-xl font-medium">Create Announcement</h2>
|
||||
<form action={createAnnouncementAction} className="mt-4 space-y-3">
|
||||
<label className="space-y-1">
|
||||
<span className="text-xs text-neutral-600">Title</span>
|
||||
<input
|
||||
name="title"
|
||||
required
|
||||
className="w-full rounded border border-neutral-300 px-3 py-2 text-sm"
|
||||
/>
|
||||
</label>
|
||||
<label className="space-y-1">
|
||||
<span className="text-xs text-neutral-600">Message</span>
|
||||
<textarea
|
||||
name="message"
|
||||
rows={3}
|
||||
required
|
||||
className="w-full rounded border border-neutral-300 px-3 py-2 text-sm"
|
||||
/>
|
||||
</label>
|
||||
<div className="grid gap-3 md:grid-cols-3">
|
||||
<label className="space-y-1">
|
||||
<span className="text-xs text-neutral-600">Placement</span>
|
||||
<select
|
||||
name="placement"
|
||||
defaultValue="global_top"
|
||||
className="w-full rounded border border-neutral-300 px-3 py-2 text-sm"
|
||||
>
|
||||
<option value="global_top">global_top</option>
|
||||
<option value="homepage">homepage</option>
|
||||
</select>
|
||||
</label>
|
||||
<label className="space-y-1">
|
||||
<span className="text-xs text-neutral-600">Priority</span>
|
||||
<input
|
||||
name="priority"
|
||||
type="number"
|
||||
min={0}
|
||||
defaultValue="100"
|
||||
className="w-full rounded border border-neutral-300 px-3 py-2 text-sm"
|
||||
/>
|
||||
</label>
|
||||
<label className="inline-flex items-center gap-2 pt-6 text-sm text-neutral-700">
|
||||
<input
|
||||
name="isVisible"
|
||||
type="checkbox"
|
||||
value="true"
|
||||
defaultChecked
|
||||
className="size-4"
|
||||
/>
|
||||
Visible
|
||||
</label>
|
||||
</div>
|
||||
<div className="grid gap-3 md:grid-cols-2">
|
||||
<label className="space-y-1">
|
||||
<span className="text-xs text-neutral-600">CTA label</span>
|
||||
<input
|
||||
name="ctaLabel"
|
||||
className="w-full rounded border border-neutral-300 px-3 py-2 text-sm"
|
||||
/>
|
||||
</label>
|
||||
<label className="space-y-1">
|
||||
<span className="text-xs text-neutral-600">CTA href</span>
|
||||
<input
|
||||
name="ctaHref"
|
||||
className="w-full rounded border border-neutral-300 px-3 py-2 text-sm"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<p className="text-xs text-neutral-600">Target locales (empty = all locales)</p>
|
||||
<div className="flex flex-wrap gap-3">
|
||||
{SUPPORTED_LOCALES.map((locale) => (
|
||||
<label
|
||||
key={`create-locale-${locale}`}
|
||||
className="inline-flex items-center gap-2 text-sm"
|
||||
>
|
||||
<input name="targetLocales" type="checkbox" value={locale} className="size-4" />
|
||||
{locale.toUpperCase()}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid gap-3 md:grid-cols-2">
|
||||
<label className="space-y-1">
|
||||
<span className="text-xs text-neutral-600">Starts at</span>
|
||||
<input
|
||||
name="startsAt"
|
||||
type="date"
|
||||
className="w-full rounded border border-neutral-300 px-3 py-2 text-sm"
|
||||
/>
|
||||
</label>
|
||||
<label className="space-y-1">
|
||||
<span className="text-xs text-neutral-600">Ends at</span>
|
||||
<input
|
||||
name="endsAt"
|
||||
type="date"
|
||||
className="w-full rounded border border-neutral-300 px-3 py-2 text-sm"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<Button type="submit">Create announcement</Button>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section className="space-y-3">
|
||||
{announcements.length === 0 ? (
|
||||
<article className="rounded-xl border border-neutral-200 p-6 text-sm text-neutral-600">
|
||||
No announcements yet.
|
||||
</article>
|
||||
) : (
|
||||
announcements.map((announcement) => (
|
||||
<form
|
||||
key={announcement.id}
|
||||
action={updateAnnouncementAction}
|
||||
className="rounded-xl border border-neutral-200 p-6"
|
||||
>
|
||||
<input type="hidden" name="id" value={announcement.id} />
|
||||
<div className="grid gap-3 md:grid-cols-2">
|
||||
<label className="space-y-1">
|
||||
<span className="text-xs text-neutral-600">Title</span>
|
||||
<input
|
||||
name="title"
|
||||
defaultValue={announcement.title}
|
||||
className="w-full rounded border border-neutral-300 px-3 py-2 text-sm"
|
||||
/>
|
||||
</label>
|
||||
<label className="space-y-1">
|
||||
<span className="text-xs text-neutral-600">Placement</span>
|
||||
<select
|
||||
name="placement"
|
||||
defaultValue={announcement.placement}
|
||||
className="w-full rounded border border-neutral-300 px-3 py-2 text-sm"
|
||||
>
|
||||
<option value="global_top">global_top</option>
|
||||
<option value="homepage">homepage</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
<label className="mt-3 block space-y-1">
|
||||
<span className="text-xs text-neutral-600">Message</span>
|
||||
<textarea
|
||||
name="message"
|
||||
rows={2}
|
||||
defaultValue={announcement.message}
|
||||
className="w-full rounded border border-neutral-300 px-3 py-2 text-sm"
|
||||
/>
|
||||
</label>
|
||||
<div className="mt-3 grid gap-3 md:grid-cols-3">
|
||||
<label className="space-y-1">
|
||||
<span className="text-xs text-neutral-600">Priority</span>
|
||||
<input
|
||||
name="priority"
|
||||
type="number"
|
||||
min={0}
|
||||
defaultValue={announcement.priority}
|
||||
className="w-full rounded border border-neutral-300 px-3 py-2 text-sm"
|
||||
/>
|
||||
</label>
|
||||
<label className="space-y-1">
|
||||
<span className="text-xs text-neutral-600">Starts</span>
|
||||
<input
|
||||
name="startsAt"
|
||||
type="date"
|
||||
defaultValue={dateInputValue(announcement.startsAt)}
|
||||
className="w-full rounded border border-neutral-300 px-3 py-2 text-sm"
|
||||
/>
|
||||
</label>
|
||||
<label className="space-y-1">
|
||||
<span className="text-xs text-neutral-600">Ends</span>
|
||||
<input
|
||||
name="endsAt"
|
||||
type="date"
|
||||
defaultValue={dateInputValue(announcement.endsAt)}
|
||||
className="w-full rounded border border-neutral-300 px-3 py-2 text-sm"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<div className="mt-3 grid gap-3 md:grid-cols-2">
|
||||
<label className="space-y-1">
|
||||
<span className="text-xs text-neutral-600">CTA label</span>
|
||||
<input
|
||||
name="ctaLabel"
|
||||
defaultValue={announcement.ctaLabel ?? ""}
|
||||
className="w-full rounded border border-neutral-300 px-3 py-2 text-sm"
|
||||
/>
|
||||
</label>
|
||||
<label className="space-y-1">
|
||||
<span className="text-xs text-neutral-600">CTA href</span>
|
||||
<input
|
||||
name="ctaHref"
|
||||
defaultValue={announcement.ctaHref ?? ""}
|
||||
className="w-full rounded border border-neutral-300 px-3 py-2 text-sm"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<div className="mt-3 space-y-1">
|
||||
<p className="text-xs text-neutral-600">Target locales (empty = all locales)</p>
|
||||
<div className="flex flex-wrap gap-3">
|
||||
{SUPPORTED_LOCALES.map((locale) => (
|
||||
<label
|
||||
key={`${announcement.id}-locale-${locale}`}
|
||||
className="inline-flex items-center gap-2 text-sm"
|
||||
>
|
||||
<input
|
||||
name="targetLocales"
|
||||
type="checkbox"
|
||||
value={locale}
|
||||
defaultChecked={announcement.targetLocales.includes(locale)}
|
||||
className="size-4"
|
||||
/>
|
||||
{locale.toUpperCase()}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-3 flex flex-wrap items-center justify-between gap-3">
|
||||
<label className="inline-flex items-center gap-2 text-sm text-neutral-700">
|
||||
<input
|
||||
name="isVisible"
|
||||
type="checkbox"
|
||||
value="true"
|
||||
defaultChecked={announcement.isVisible}
|
||||
className="size-4"
|
||||
/>
|
||||
Visible
|
||||
</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button type="submit" size="sm">
|
||||
Save
|
||||
</Button>
|
||||
<button
|
||||
type="submit"
|
||||
formAction={deleteAnnouncementAction}
|
||||
className="rounded-md border border-red-300 px-3 py-2 text-sm text-red-700"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
))
|
||||
)}
|
||||
</section>
|
||||
</AdminShell>
|
||||
)
|
||||
}
|
||||
120
apps/admin/src/app/api/media/file/[id]/route.ts
Normal file
120
apps/admin/src/app/api/media/file/[id]/route.ts
Normal file
@@ -0,0 +1,120 @@
|
||||
import { readFile } from "node:fs/promises"
|
||||
import path from "node:path"
|
||||
import { GetObjectCommand } from "@aws-sdk/client-s3"
|
||||
import { hasPermission } from "@cms/content/rbac"
|
||||
import { getMediaAssetById } from "@cms/db"
|
||||
|
||||
import { auth, resolveRoleFromAuthSession } from "@/lib/auth/server"
|
||||
import { resolveLocalMediaBaseDirectory } from "@/lib/media/local-storage"
|
||||
import { createS3Client, resolveS3Config } from "@/lib/media/s3-storage"
|
||||
import { resolveMediaStorageProvider } from "@/lib/media/storage"
|
||||
|
||||
export const runtime = "nodejs"
|
||||
|
||||
type RouteContext = {
|
||||
params: Promise<{ id: string }>
|
||||
}
|
||||
|
||||
async function readFromLocalStorage(storageKey: string): Promise<Uint8Array> {
|
||||
const baseDirectory = resolveLocalMediaBaseDirectory()
|
||||
const outputPath = path.join(baseDirectory, storageKey)
|
||||
|
||||
return readFile(outputPath)
|
||||
}
|
||||
|
||||
async function readFromS3Storage(storageKey: string): Promise<Uint8Array> {
|
||||
const config = resolveS3Config()
|
||||
const client = createS3Client(config)
|
||||
|
||||
const response = await client.send(
|
||||
new GetObjectCommand({
|
||||
Bucket: config.bucket,
|
||||
Key: storageKey,
|
||||
}),
|
||||
)
|
||||
|
||||
if (!response.Body) {
|
||||
throw new Error("S3 object body is empty")
|
||||
}
|
||||
|
||||
return response.Body.transformToByteArray()
|
||||
}
|
||||
|
||||
function toBody(data: Uint8Array): BodyInit {
|
||||
const bytes = new Uint8Array(data.byteLength)
|
||||
bytes.set(data)
|
||||
return bytes
|
||||
}
|
||||
|
||||
export async function GET(request: Request, context: RouteContext): Promise<Response> {
|
||||
const session = await auth.api
|
||||
.getSession({
|
||||
headers: request.headers,
|
||||
})
|
||||
.catch(() => null)
|
||||
const role = resolveRoleFromAuthSession(session)
|
||||
|
||||
if (!role) {
|
||||
return Response.json(
|
||||
{
|
||||
message: "Unauthorized",
|
||||
},
|
||||
{ status: 401 },
|
||||
)
|
||||
}
|
||||
|
||||
if (!hasPermission(role, "media:read", "team")) {
|
||||
return Response.json(
|
||||
{
|
||||
message: "Missing permission: media:read",
|
||||
},
|
||||
{ status: 403 },
|
||||
)
|
||||
}
|
||||
|
||||
const { id } = await context.params
|
||||
const asset = await getMediaAssetById(id)
|
||||
|
||||
if (!asset || !asset.storageKey) {
|
||||
return Response.json(
|
||||
{
|
||||
message: "Media file not found",
|
||||
},
|
||||
{ status: 404 },
|
||||
)
|
||||
}
|
||||
|
||||
const preferred = resolveMediaStorageProvider(process.env.CMS_MEDIA_STORAGE_PROVIDER)
|
||||
const reads =
|
||||
preferred === "s3"
|
||||
? [
|
||||
() => readFromS3Storage(asset.storageKey as string),
|
||||
() => readFromLocalStorage(asset.storageKey as string),
|
||||
]
|
||||
: [
|
||||
() => readFromLocalStorage(asset.storageKey as string),
|
||||
() => readFromS3Storage(asset.storageKey as string),
|
||||
]
|
||||
|
||||
for (const read of reads) {
|
||||
try {
|
||||
const data = await read()
|
||||
return new Response(toBody(data), {
|
||||
status: 200,
|
||||
headers: {
|
||||
"content-type": asset.mimeType || "application/octet-stream",
|
||||
"cache-control": "private, max-age=0, no-store",
|
||||
},
|
||||
})
|
||||
} catch {
|
||||
// Try next backend.
|
||||
}
|
||||
}
|
||||
|
||||
return Response.json(
|
||||
{
|
||||
message: "Unable to read media file from configured storage backends",
|
||||
},
|
||||
{ status: 404 },
|
||||
)
|
||||
}
|
||||
194
apps/admin/src/app/api/media/upload/route.ts
Normal file
194
apps/admin/src/app/api/media/upload/route.ts
Normal file
@@ -0,0 +1,194 @@
|
||||
import { randomUUID } from "node:crypto"
|
||||
import {
|
||||
getMediaUploadMaxBytes,
|
||||
isMimeAllowedForMediaType,
|
||||
mediaAssetTypeSchema,
|
||||
} from "@cms/content"
|
||||
import { hasPermission } from "@cms/content/rbac"
|
||||
import { createMediaAsset } from "@cms/db"
|
||||
|
||||
import { auth, resolveRoleFromAuthSession } from "@/lib/auth/server"
|
||||
import { storeUpload } from "@/lib/media/storage"
|
||||
|
||||
export const runtime = "nodejs"
|
||||
|
||||
const MAX_UPLOAD_BYTES_OVERRIDE = Number(process.env.CMS_MEDIA_UPLOAD_MAX_BYTES ?? 0)
|
||||
|
||||
function parseTextField(formData: FormData, field: string): string {
|
||||
const value = formData.get(field)
|
||||
return typeof value === "string" ? value.trim() : ""
|
||||
}
|
||||
|
||||
function parseOptionalField(formData: FormData, field: string): string | undefined {
|
||||
const value = parseTextField(formData, field)
|
||||
return value.length > 0 ? value : undefined
|
||||
}
|
||||
|
||||
function parseTags(formData: FormData): string[] {
|
||||
const value = parseTextField(formData, "tags")
|
||||
|
||||
if (!value) {
|
||||
return []
|
||||
}
|
||||
|
||||
return value
|
||||
.split(",")
|
||||
.map((item) => item.trim())
|
||||
.filter((item) => item.length > 0)
|
||||
}
|
||||
|
||||
function parseOptionalDateField(formData: FormData, field: string): Date | undefined {
|
||||
const value = parseTextField(formData, field)
|
||||
|
||||
if (!value) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const parsed = new Date(value)
|
||||
|
||||
if (Number.isNaN(parsed.getTime())) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
return parsed
|
||||
}
|
||||
|
||||
function deriveTitleFromFilename(fileName: string): string {
|
||||
const trimmed = fileName.trim()
|
||||
|
||||
if (!trimmed) {
|
||||
return "Untitled media"
|
||||
}
|
||||
|
||||
const dotIndex = trimmed.lastIndexOf(".")
|
||||
const base = dotIndex > 0 ? trimmed.slice(0, dotIndex) : trimmed
|
||||
const normalized = base.trim()
|
||||
|
||||
return normalized.length > 0 ? normalized : "Untitled media"
|
||||
}
|
||||
|
||||
function badRequest(message: string): Response {
|
||||
return Response.json(
|
||||
{
|
||||
message,
|
||||
},
|
||||
{ status: 400 },
|
||||
)
|
||||
}
|
||||
|
||||
export async function POST(request: Request): Promise<Response> {
|
||||
const session = await auth.api
|
||||
.getSession({
|
||||
headers: request.headers,
|
||||
})
|
||||
.catch(() => null)
|
||||
const role = resolveRoleFromAuthSession(session)
|
||||
|
||||
if (!role) {
|
||||
return Response.json(
|
||||
{
|
||||
message: "Unauthorized",
|
||||
},
|
||||
{ status: 401 },
|
||||
)
|
||||
}
|
||||
|
||||
if (!hasPermission(role, "media:write", "team")) {
|
||||
return Response.json(
|
||||
{
|
||||
message: "Missing permission: media:write",
|
||||
},
|
||||
{ status: 403 },
|
||||
)
|
||||
}
|
||||
|
||||
const formData = await request.formData().catch(() => null)
|
||||
|
||||
if (!formData) {
|
||||
return badRequest("Invalid form payload.")
|
||||
}
|
||||
|
||||
const parsedType = mediaAssetTypeSchema.safeParse(parseTextField(formData, "type"))
|
||||
const fileEntry = formData.get("file")
|
||||
|
||||
if (!parsedType.success) {
|
||||
return badRequest("Type is required.")
|
||||
}
|
||||
const type = parsedType.data
|
||||
|
||||
if (!(fileEntry instanceof File)) {
|
||||
return badRequest("File is required.")
|
||||
}
|
||||
|
||||
if (fileEntry.size === 0) {
|
||||
return badRequest("File is empty.")
|
||||
}
|
||||
|
||||
const typeMaxBytes = getMediaUploadMaxBytes(type)
|
||||
const effectiveMaxBytes =
|
||||
MAX_UPLOAD_BYTES_OVERRIDE > 0 ? Math.min(MAX_UPLOAD_BYTES_OVERRIDE, typeMaxBytes) : typeMaxBytes
|
||||
|
||||
if (fileEntry.size > effectiveMaxBytes) {
|
||||
return badRequest(
|
||||
`File is too large for ${type}. Maximum upload is ${Math.floor(effectiveMaxBytes / 1024 / 1024)} MB.`,
|
||||
)
|
||||
}
|
||||
|
||||
if (!isMimeAllowedForMediaType(type, fileEntry.type)) {
|
||||
return badRequest(`File type ${fileEntry.type || "unknown"} is not allowed for ${type}.`)
|
||||
}
|
||||
|
||||
const title = parseTextField(formData, "title") || deriveTitleFromFilename(fileEntry.name)
|
||||
const mediaAssetId = randomUUID()
|
||||
const variant = "original"
|
||||
const fileRole = "original"
|
||||
|
||||
try {
|
||||
const stored = await storeUpload({
|
||||
file: fileEntry,
|
||||
assetId: mediaAssetId,
|
||||
variant,
|
||||
fileRole,
|
||||
})
|
||||
|
||||
const created = await createMediaAsset({
|
||||
id: mediaAssetId,
|
||||
title,
|
||||
type,
|
||||
description: parseOptionalField(formData, "description"),
|
||||
altText: parseOptionalField(formData, "altText"),
|
||||
source: parseOptionalField(formData, "source"),
|
||||
copyright: parseOptionalField(formData, "copyright"),
|
||||
author: parseOptionalField(formData, "author"),
|
||||
licenseType: parseOptionalField(formData, "licenseType"),
|
||||
licenseUrl: parseOptionalField(formData, "licenseUrl"),
|
||||
usageContext: parseOptionalField(formData, "usageContext"),
|
||||
location: parseOptionalField(formData, "location"),
|
||||
capturedAt: parseOptionalDateField(formData, "capturedAt"),
|
||||
tags: parseTags(formData),
|
||||
storageKey: stored.storageKey,
|
||||
mimeType: fileEntry.type || undefined,
|
||||
sizeBytes: fileEntry.size,
|
||||
isPublished: parseTextField(formData, "isPublished") === "true",
|
||||
})
|
||||
|
||||
return Response.json(
|
||||
{
|
||||
id: created.id,
|
||||
provider: stored.provider,
|
||||
warning: stored.fallbackReason,
|
||||
notice: "Media uploaded successfully.",
|
||||
},
|
||||
{ status: 201 },
|
||||
)
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Upload failed. Please try again."
|
||||
|
||||
return Response.json(
|
||||
{
|
||||
message,
|
||||
},
|
||||
{ status: 500 },
|
||||
)
|
||||
}
|
||||
}
|
||||
649
apps/admin/src/app/commissions/page.tsx
Normal file
649
apps/admin/src/app/commissions/page.tsx
Normal file
@@ -0,0 +1,649 @@
|
||||
import {
|
||||
commissionKanbanOrder,
|
||||
createCommission,
|
||||
createCustomer,
|
||||
db,
|
||||
listArtworks,
|
||||
listCommissions,
|
||||
listCustomers,
|
||||
updateCommission,
|
||||
updateCommissionStatus,
|
||||
} from "@cms/db"
|
||||
import { Button } from "@cms/ui/button"
|
||||
import { revalidatePath } from "next/cache"
|
||||
import { redirect } from "next/navigation"
|
||||
|
||||
import { AdminShell } from "@/components/admin-shell"
|
||||
import { requirePermissionForRoute } from "@/lib/route-guards"
|
||||
|
||||
export const dynamic = "force-dynamic"
|
||||
|
||||
type SearchParamsInput = Record<string, string | string[] | undefined>
|
||||
|
||||
function readFirstValue(value: string | string[] | undefined): string | null {
|
||||
if (Array.isArray(value)) {
|
||||
return value[0] ?? null
|
||||
}
|
||||
|
||||
return value ?? null
|
||||
}
|
||||
|
||||
function readInputString(formData: FormData, field: string): string {
|
||||
const value = formData.get(field)
|
||||
return typeof value === "string" ? value.trim() : ""
|
||||
}
|
||||
|
||||
function readNullableString(formData: FormData, field: string): string | null {
|
||||
const value = readInputString(formData, field)
|
||||
return value.length > 0 ? value : null
|
||||
}
|
||||
|
||||
function readNullableNumber(formData: FormData, field: string): number | null {
|
||||
const value = readInputString(formData, field)
|
||||
|
||||
if (!value) {
|
||||
return null
|
||||
}
|
||||
|
||||
const parsed = Number.parseFloat(value)
|
||||
|
||||
if (!Number.isFinite(parsed)) {
|
||||
return null
|
||||
}
|
||||
|
||||
return parsed
|
||||
}
|
||||
|
||||
function readNullableDate(formData: FormData, field: string): Date | null {
|
||||
const value = readInputString(formData, field)
|
||||
|
||||
if (!value) {
|
||||
return null
|
||||
}
|
||||
|
||||
const parsed = new Date(value)
|
||||
|
||||
if (Number.isNaN(parsed.getTime())) {
|
||||
return null
|
||||
}
|
||||
|
||||
return parsed
|
||||
}
|
||||
|
||||
function readUuidList(formData: FormData, field: string): string[] {
|
||||
const raw = readInputString(formData, field)
|
||||
|
||||
if (!raw) {
|
||||
return []
|
||||
}
|
||||
|
||||
return raw
|
||||
.split(",")
|
||||
.map((entry) => entry.trim())
|
||||
.filter((entry) => entry.length > 0)
|
||||
}
|
||||
|
||||
function redirectWithState(params: { notice?: string; error?: string }) {
|
||||
const query = new URLSearchParams()
|
||||
|
||||
if (params.notice) {
|
||||
query.set("notice", params.notice)
|
||||
}
|
||||
|
||||
if (params.error) {
|
||||
query.set("error", params.error)
|
||||
}
|
||||
|
||||
const value = query.toString()
|
||||
redirect(value ? `/commissions?${value}` : "/commissions")
|
||||
}
|
||||
|
||||
async function createCustomerAction(formData: FormData) {
|
||||
"use server"
|
||||
|
||||
await requirePermissionForRoute({
|
||||
nextPath: "/commissions",
|
||||
permission: "commissions:write",
|
||||
scope: "own",
|
||||
})
|
||||
|
||||
try {
|
||||
await createCustomer({
|
||||
name: readInputString(formData, "name"),
|
||||
email: readNullableString(formData, "email"),
|
||||
phone: readNullableString(formData, "phone"),
|
||||
instagram: readNullableString(formData, "instagram"),
|
||||
notes: readNullableString(formData, "notes"),
|
||||
isRecurring: readInputString(formData, "isRecurring") === "true",
|
||||
})
|
||||
} catch {
|
||||
redirectWithState({ error: "Failed to create customer." })
|
||||
}
|
||||
|
||||
revalidatePath("/commissions")
|
||||
redirectWithState({ notice: "Customer created." })
|
||||
}
|
||||
|
||||
async function createCommissionAction(formData: FormData) {
|
||||
"use server"
|
||||
|
||||
await requirePermissionForRoute({
|
||||
nextPath: "/commissions",
|
||||
permission: "commissions:write",
|
||||
scope: "own",
|
||||
})
|
||||
|
||||
try {
|
||||
await createCommission({
|
||||
title: readInputString(formData, "title"),
|
||||
description: readNullableString(formData, "description"),
|
||||
status: readInputString(formData, "status"),
|
||||
customerId: readNullableString(formData, "customerId"),
|
||||
assignedUserId: readNullableString(formData, "assignedUserId"),
|
||||
linkedArtworkIds: readUuidList(formData, "linkedArtworkIds"),
|
||||
budgetMin: readNullableNumber(formData, "budgetMin"),
|
||||
budgetMax: readNullableNumber(formData, "budgetMax"),
|
||||
dueAt: readNullableDate(formData, "dueAt"),
|
||||
})
|
||||
} catch {
|
||||
redirectWithState({ error: "Failed to create commission." })
|
||||
}
|
||||
|
||||
revalidatePath("/commissions")
|
||||
redirectWithState({ notice: "Commission created." })
|
||||
}
|
||||
|
||||
async function updateCommissionAction(formData: FormData) {
|
||||
"use server"
|
||||
|
||||
await requirePermissionForRoute({
|
||||
nextPath: "/commissions",
|
||||
permission: "commissions:write",
|
||||
scope: "own",
|
||||
})
|
||||
|
||||
try {
|
||||
await updateCommission({
|
||||
id: readInputString(formData, "id"),
|
||||
title: readInputString(formData, "title"),
|
||||
description: readNullableString(formData, "description"),
|
||||
customerId: readNullableString(formData, "customerId"),
|
||||
assignedUserId: readNullableString(formData, "assignedUserId"),
|
||||
linkedArtworkIds: readUuidList(formData, "linkedArtworkIds"),
|
||||
budgetMin: readNullableNumber(formData, "budgetMin"),
|
||||
budgetMax: readNullableNumber(formData, "budgetMax"),
|
||||
dueAt: readNullableDate(formData, "dueAt"),
|
||||
})
|
||||
} catch {
|
||||
redirectWithState({ error: "Failed to update commission details." })
|
||||
}
|
||||
|
||||
revalidatePath("/commissions")
|
||||
redirectWithState({ notice: "Commission updated." })
|
||||
}
|
||||
|
||||
async function updateCommissionStatusAction(formData: FormData) {
|
||||
"use server"
|
||||
|
||||
await requirePermissionForRoute({
|
||||
nextPath: "/commissions",
|
||||
permission: "commissions:transition",
|
||||
scope: "own",
|
||||
})
|
||||
|
||||
try {
|
||||
await updateCommissionStatus({
|
||||
id: readInputString(formData, "id"),
|
||||
status: readInputString(formData, "status"),
|
||||
})
|
||||
} catch {
|
||||
redirectWithState({ error: "Failed to transition commission." })
|
||||
}
|
||||
|
||||
revalidatePath("/commissions")
|
||||
redirectWithState({ notice: "Commission status updated." })
|
||||
}
|
||||
|
||||
function formatDate(value: Date | null) {
|
||||
if (!value) {
|
||||
return "-"
|
||||
}
|
||||
|
||||
return value.toLocaleDateString("en-US")
|
||||
}
|
||||
|
||||
function formatDateInput(value: Date | null) {
|
||||
if (!value) {
|
||||
return ""
|
||||
}
|
||||
|
||||
return value.toISOString().slice(0, 10)
|
||||
}
|
||||
|
||||
export default async function CommissionsManagementPage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<SearchParamsInput>
|
||||
}) {
|
||||
const role = await requirePermissionForRoute({
|
||||
nextPath: "/commissions",
|
||||
permission: "commissions:read",
|
||||
scope: "own",
|
||||
})
|
||||
|
||||
const [resolvedSearchParams, customers, commissions, assignees, artworks] = await Promise.all([
|
||||
searchParams,
|
||||
listCustomers(200),
|
||||
listCommissions(300),
|
||||
db.user.findMany({
|
||||
where: {
|
||||
isBanned: false,
|
||||
},
|
||||
orderBy: [{ createdAt: "asc" }],
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
username: true,
|
||||
},
|
||||
}),
|
||||
listArtworks(300),
|
||||
])
|
||||
|
||||
const notice = readFirstValue(resolvedSearchParams.notice)
|
||||
const error = readFirstValue(resolvedSearchParams.error)
|
||||
|
||||
return (
|
||||
<AdminShell
|
||||
role={role}
|
||||
activePath="/commissions"
|
||||
badge="Admin App"
|
||||
title="Commissions"
|
||||
description="Manage customers and commission requests with kanban-style status transitions."
|
||||
>
|
||||
{notice ? (
|
||||
<section className="rounded-xl border border-emerald-300 bg-emerald-50 px-4 py-3 text-sm text-emerald-800">
|
||||
{notice}
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
{error ? (
|
||||
<section className="rounded-xl border border-red-300 bg-red-50 px-4 py-3 text-sm text-red-800">
|
||||
{error}
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
<section className="grid gap-4 xl:grid-cols-2">
|
||||
<article className="rounded-xl border border-neutral-200 p-6">
|
||||
<h2 className="text-xl font-medium">Create Customer</h2>
|
||||
<form action={createCustomerAction} className="mt-4 space-y-3">
|
||||
<label className="space-y-1">
|
||||
<span className="text-xs text-neutral-600">Name</span>
|
||||
<input
|
||||
name="name"
|
||||
required
|
||||
className="w-full rounded border border-neutral-300 px-3 py-2 text-sm"
|
||||
/>
|
||||
</label>
|
||||
<div className="grid gap-3 md:grid-cols-2">
|
||||
<label className="space-y-1">
|
||||
<span className="text-xs text-neutral-600">Email</span>
|
||||
<input
|
||||
name="email"
|
||||
type="email"
|
||||
className="w-full rounded border border-neutral-300 px-3 py-2 text-sm"
|
||||
/>
|
||||
</label>
|
||||
<label className="space-y-1">
|
||||
<span className="text-xs text-neutral-600">Phone</span>
|
||||
<input
|
||||
name="phone"
|
||||
className="w-full rounded border border-neutral-300 px-3 py-2 text-sm"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<label className="space-y-1">
|
||||
<span className="text-xs text-neutral-600">Instagram</span>
|
||||
<input
|
||||
name="instagram"
|
||||
className="w-full rounded border border-neutral-300 px-3 py-2 text-sm"
|
||||
/>
|
||||
</label>
|
||||
<label className="space-y-1">
|
||||
<span className="text-xs text-neutral-600">Notes</span>
|
||||
<textarea
|
||||
name="notes"
|
||||
rows={3}
|
||||
className="w-full rounded border border-neutral-300 px-3 py-2 text-sm"
|
||||
/>
|
||||
</label>
|
||||
<label className="inline-flex items-center gap-2 text-sm text-neutral-700">
|
||||
<input name="isRecurring" type="checkbox" value="true" className="size-4" />
|
||||
Recurring customer
|
||||
</label>
|
||||
<Button type="submit">Create customer</Button>
|
||||
</form>
|
||||
</article>
|
||||
|
||||
<article className="rounded-xl border border-neutral-200 p-6">
|
||||
<h2 className="text-xl font-medium">Create Commission</h2>
|
||||
<form action={createCommissionAction} className="mt-4 space-y-3">
|
||||
<label className="space-y-1">
|
||||
<span className="text-xs text-neutral-600">Title</span>
|
||||
<input
|
||||
name="title"
|
||||
required
|
||||
className="w-full rounded border border-neutral-300 px-3 py-2 text-sm"
|
||||
/>
|
||||
</label>
|
||||
<label className="space-y-1">
|
||||
<span className="text-xs text-neutral-600">Description</span>
|
||||
<textarea
|
||||
name="description"
|
||||
rows={3}
|
||||
className="w-full rounded border border-neutral-300 px-3 py-2 text-sm"
|
||||
/>
|
||||
</label>
|
||||
<div className="grid gap-3 md:grid-cols-2">
|
||||
<label className="space-y-1">
|
||||
<span className="text-xs text-neutral-600">Status</span>
|
||||
<select
|
||||
name="status"
|
||||
defaultValue="new"
|
||||
className="w-full rounded border border-neutral-300 px-3 py-2 text-sm"
|
||||
>
|
||||
{commissionKanbanOrder.map((status) => (
|
||||
<option key={status} value={status}>
|
||||
{status}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label className="space-y-1">
|
||||
<span className="text-xs text-neutral-600">Customer</span>
|
||||
<select
|
||||
name="customerId"
|
||||
className="w-full rounded border border-neutral-300 px-3 py-2 text-sm"
|
||||
>
|
||||
<option value="">(none)</option>
|
||||
{customers.map((customer) => (
|
||||
<option key={customer.id} value={customer.id}>
|
||||
{customer.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
<div className="grid gap-3 md:grid-cols-3">
|
||||
<label className="space-y-1">
|
||||
<span className="text-xs text-neutral-600">Assigned user</span>
|
||||
<select
|
||||
name="assignedUserId"
|
||||
className="w-full rounded border border-neutral-300 px-3 py-2 text-sm"
|
||||
>
|
||||
<option value="">(none)</option>
|
||||
{assignees.map((assignee) => (
|
||||
<option key={assignee.id} value={assignee.id}>
|
||||
{assignee.name} @{assignee.username ?? "no-user"}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label className="space-y-1">
|
||||
<span className="text-xs text-neutral-600">Budget min</span>
|
||||
<input
|
||||
name="budgetMin"
|
||||
type="number"
|
||||
min={0}
|
||||
step="0.01"
|
||||
className="w-full rounded border border-neutral-300 px-3 py-2 text-sm"
|
||||
/>
|
||||
</label>
|
||||
<label className="space-y-1">
|
||||
<span className="text-xs text-neutral-600">Budget max</span>
|
||||
<input
|
||||
name="budgetMax"
|
||||
type="number"
|
||||
min={0}
|
||||
step="0.01"
|
||||
className="w-full rounded border border-neutral-300 px-3 py-2 text-sm"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<label className="space-y-1">
|
||||
<span className="text-xs text-neutral-600">Due date</span>
|
||||
<input
|
||||
name="dueAt"
|
||||
type="date"
|
||||
className="w-full rounded border border-neutral-300 px-3 py-2 text-sm"
|
||||
/>
|
||||
</label>
|
||||
<label className="space-y-1">
|
||||
<span className="text-xs text-neutral-600">Linked artwork IDs (comma separated)</span>
|
||||
<textarea
|
||||
name="linkedArtworkIds"
|
||||
rows={2}
|
||||
className="w-full rounded border border-neutral-300 px-3 py-2 text-sm"
|
||||
/>
|
||||
</label>
|
||||
<Button type="submit">Create commission</Button>
|
||||
</form>
|
||||
</article>
|
||||
</section>
|
||||
|
||||
<section className="space-y-4">
|
||||
<h2 className="text-xl font-medium">Kanban Board</h2>
|
||||
<div className="grid gap-3 xl:grid-cols-6">
|
||||
{commissionKanbanOrder.map((status) => {
|
||||
const items = commissions.filter((commission) => commission.status === status)
|
||||
|
||||
return (
|
||||
<article
|
||||
key={status}
|
||||
className="rounded-xl border border-neutral-200 bg-neutral-50 p-3"
|
||||
>
|
||||
<header className="mb-3 flex items-center justify-between">
|
||||
<h3 className="text-sm font-semibold uppercase tracking-wide text-neutral-700">
|
||||
{status}
|
||||
</h3>
|
||||
<span className="text-xs text-neutral-500">{items.length}</span>
|
||||
</header>
|
||||
|
||||
<div className="space-y-2">
|
||||
{items.length === 0 ? (
|
||||
<p className="text-xs text-neutral-500">No commissions</p>
|
||||
) : (
|
||||
items.map((commission) => (
|
||||
<form
|
||||
key={commission.id}
|
||||
action={updateCommissionStatusAction}
|
||||
className="rounded border border-neutral-200 bg-white p-2"
|
||||
>
|
||||
<input type="hidden" name="id" value={commission.id} />
|
||||
<div className="space-y-1">
|
||||
<p className="text-sm font-medium">{commission.title}</p>
|
||||
<p className="text-xs text-neutral-600">
|
||||
{commission.customer?.name ?? "No customer"}
|
||||
</p>
|
||||
<p className="text-xs text-neutral-500">
|
||||
Assignee: {commission.assignedUser?.name ?? "none"}
|
||||
</p>
|
||||
<p className="text-xs text-neutral-500">
|
||||
Due: {formatDate(commission.dueAt)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="mt-2 flex items-center gap-2">
|
||||
<select
|
||||
name="status"
|
||||
defaultValue={commission.status}
|
||||
className="w-full rounded border border-neutral-300 px-2 py-1 text-xs"
|
||||
>
|
||||
{commissionKanbanOrder.map((value) => (
|
||||
<option key={value} value={value}>
|
||||
{value}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<button
|
||||
type="submit"
|
||||
className="rounded border border-neutral-300 px-2 py-1 text-xs"
|
||||
>
|
||||
Move
|
||||
</button>
|
||||
</div>
|
||||
<details className="mt-2 rounded border border-neutral-200 p-2 text-xs">
|
||||
<summary className="cursor-pointer text-neutral-700">
|
||||
Edit details
|
||||
</summary>
|
||||
<form action={updateCommissionAction} className="mt-2 space-y-2">
|
||||
<input type="hidden" name="id" value={commission.id} />
|
||||
<input
|
||||
name="title"
|
||||
defaultValue={commission.title}
|
||||
className="w-full rounded border border-neutral-300 px-2 py-1"
|
||||
/>
|
||||
<textarea
|
||||
name="description"
|
||||
rows={2}
|
||||
defaultValue={commission.description ?? ""}
|
||||
className="w-full rounded border border-neutral-300 px-2 py-1"
|
||||
/>
|
||||
<select
|
||||
name="customerId"
|
||||
defaultValue={commission.customerId ?? ""}
|
||||
className="w-full rounded border border-neutral-300 px-2 py-1"
|
||||
>
|
||||
<option value="">(no customer)</option>
|
||||
{customers.map((customer) => (
|
||||
<option
|
||||
key={`${commission.id}-customer-${customer.id}`}
|
||||
value={customer.id}
|
||||
>
|
||||
{customer.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<select
|
||||
name="assignedUserId"
|
||||
defaultValue={commission.assignedUserId ?? ""}
|
||||
className="w-full rounded border border-neutral-300 px-2 py-1"
|
||||
>
|
||||
<option value="">(no assignee)</option>
|
||||
{assignees.map((assignee) => (
|
||||
<option
|
||||
key={`${commission.id}-assignee-${assignee.id}`}
|
||||
value={assignee.id}
|
||||
>
|
||||
{assignee.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<input
|
||||
name="budgetMin"
|
||||
type="number"
|
||||
min={0}
|
||||
step="0.01"
|
||||
defaultValue={commission.budgetMin ?? ""}
|
||||
placeholder="Budget min"
|
||||
className="rounded border border-neutral-300 px-2 py-1"
|
||||
/>
|
||||
<input
|
||||
name="budgetMax"
|
||||
type="number"
|
||||
min={0}
|
||||
step="0.01"
|
||||
defaultValue={commission.budgetMax ?? ""}
|
||||
placeholder="Budget max"
|
||||
className="rounded border border-neutral-300 px-2 py-1"
|
||||
/>
|
||||
</div>
|
||||
<input
|
||||
name="dueAt"
|
||||
type="date"
|
||||
defaultValue={formatDateInput(commission.dueAt)}
|
||||
className="w-full rounded border border-neutral-300 px-2 py-1"
|
||||
/>
|
||||
<textarea
|
||||
name="linkedArtworkIds"
|
||||
rows={2}
|
||||
defaultValue={commission.linkedArtworkIds.join(",")}
|
||||
placeholder="Artwork IDs"
|
||||
className="w-full rounded border border-neutral-300 px-2 py-1"
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
className="rounded border border-neutral-300 px-2 py-1 text-xs"
|
||||
>
|
||||
Save details
|
||||
</button>
|
||||
</form>
|
||||
</details>
|
||||
{commission.linkedArtworkIds.length > 0 ? (
|
||||
<p className="mt-2 text-[11px] text-neutral-500">
|
||||
Linked artworks: {commission.linkedArtworkIds.length}
|
||||
</p>
|
||||
) : null}
|
||||
</form>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</article>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="rounded-xl border border-neutral-200 p-6">
|
||||
<h2 className="text-xl font-medium">Customers</h2>
|
||||
<div className="mt-4 overflow-x-auto">
|
||||
<table className="min-w-full text-left text-sm">
|
||||
<thead className="text-xs uppercase tracking-wide text-neutral-500">
|
||||
<tr>
|
||||
<th className="py-2 pr-4">Name</th>
|
||||
<th className="py-2 pr-4">Email</th>
|
||||
<th className="py-2 pr-4">Phone</th>
|
||||
<th className="py-2 pr-4">Recurring</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{customers.length === 0 ? (
|
||||
<tr>
|
||||
<td className="py-3 text-neutral-500" colSpan={4}>
|
||||
No customers yet.
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
customers.map((customer) => (
|
||||
<tr key={customer.id} className="border-t border-neutral-200">
|
||||
<td className="py-3 pr-4">{customer.name}</td>
|
||||
<td className="py-3 pr-4 text-neutral-600">{customer.email ?? "-"}</td>
|
||||
<td className="py-3 pr-4 text-neutral-600">{customer.phone ?? "-"}</td>
|
||||
<td className="py-3 pr-4">{customer.isRecurring ? "yes" : "no"}</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="rounded-xl border border-neutral-200 p-6">
|
||||
<h2 className="text-xl font-medium">Artwork Reference</h2>
|
||||
<p className="mt-1 text-sm text-neutral-600">
|
||||
Use these IDs when linking artworks to commissions.
|
||||
</p>
|
||||
<div className="mt-3 max-h-64 overflow-auto rounded border border-neutral-200 p-3 text-xs">
|
||||
{artworks.length === 0 ? (
|
||||
<p className="text-neutral-500">No artworks available.</p>
|
||||
) : (
|
||||
artworks.map((artwork) => (
|
||||
<p key={artwork.id} className="font-mono text-neutral-700">
|
||||
{artwork.id} - {artwork.title}
|
||||
</p>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
</AdminShell>
|
||||
)
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { Metadata } from "next"
|
||||
import type { ReactNode } from "react"
|
||||
|
||||
import { getAdminMessages, resolveAdminLocale } from "@/i18n/server"
|
||||
import "./globals.css"
|
||||
import { Providers } from "./providers"
|
||||
|
||||
@@ -9,11 +10,16 @@ export const metadata: Metadata = {
|
||||
description: "Admin dashboard for the CMS monorepo",
|
||||
}
|
||||
|
||||
export default function RootLayout({ children }: { children: ReactNode }) {
|
||||
export default async function RootLayout({ children }: { children: ReactNode }) {
|
||||
const locale = await resolveAdminLocale()
|
||||
const messages = await getAdminMessages(locale)
|
||||
|
||||
return (
|
||||
<html lang="en">
|
||||
<html lang={locale}>
|
||||
<body>
|
||||
<Providers>{children}</Providers>
|
||||
<Providers locale={locale} messages={messages}>
|
||||
{children}
|
||||
</Providers>
|
||||
</body>
|
||||
</html>
|
||||
)
|
||||
|
||||
@@ -4,8 +4,11 @@ import Link from "next/link"
|
||||
import { useRouter, useSearchParams } from "next/navigation"
|
||||
import { type FormEvent, useMemo, useState } from "react"
|
||||
|
||||
import { AdminLocaleSwitcher } from "@/components/admin-locale-switcher"
|
||||
import { useAdminT } from "@/providers/admin-i18n-provider"
|
||||
|
||||
type LoginFormProps = {
|
||||
mode: "signin" | "signup-owner" | "signup-user"
|
||||
mode: "signin" | "signup-owner" | "signup-user" | "signup-disabled"
|
||||
}
|
||||
|
||||
type AuthResponse = {
|
||||
@@ -27,6 +30,7 @@ function persistRoleCookie(role: unknown) {
|
||||
export function LoginForm({ mode }: LoginFormProps) {
|
||||
const router = useRouter()
|
||||
const searchParams = useSearchParams()
|
||||
const t = useAdminT()
|
||||
|
||||
const nextPath = useMemo(() => searchParams.get("next") || "/", [searchParams])
|
||||
|
||||
@@ -37,6 +41,7 @@ export function LoginForm({ mode }: LoginFormProps) {
|
||||
const [isBusy, setIsBusy] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [success, setSuccess] = useState<string | null>(null)
|
||||
const canSubmitSignUp = mode === "signup-owner" || mode === "signup-user"
|
||||
|
||||
async function handleSignIn(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault()
|
||||
@@ -60,7 +65,7 @@ export function LoginForm({ mode }: LoginFormProps) {
|
||||
const payload = (await response.json().catch(() => null)) as AuthResponse | null
|
||||
|
||||
if (!response.ok) {
|
||||
setError(payload?.message ?? "Sign in failed")
|
||||
setError(payload?.message ?? t("auth.errors.signInFailed", "Sign in failed"))
|
||||
return
|
||||
}
|
||||
|
||||
@@ -68,7 +73,7 @@ export function LoginForm({ mode }: LoginFormProps) {
|
||||
router.push(nextPath)
|
||||
router.refresh()
|
||||
} catch {
|
||||
setError("Network error while signing in")
|
||||
setError(t("auth.errors.networkSignIn", "Network error while signing in"))
|
||||
} finally {
|
||||
setIsBusy(false)
|
||||
}
|
||||
@@ -78,7 +83,7 @@ export function LoginForm({ mode }: LoginFormProps) {
|
||||
event.preventDefault()
|
||||
|
||||
if (!name.trim()) {
|
||||
setError("Name is required for account creation")
|
||||
setError(t("auth.errors.nameRequired", "Name is required for account creation"))
|
||||
return
|
||||
}
|
||||
|
||||
@@ -104,20 +109,20 @@ export function LoginForm({ mode }: LoginFormProps) {
|
||||
const payload = (await response.json().catch(() => null)) as AuthResponse | null
|
||||
|
||||
if (!response.ok) {
|
||||
setError(payload?.message ?? "Sign up failed")
|
||||
setError(payload?.message ?? t("auth.errors.signUpFailed", "Sign up failed"))
|
||||
return
|
||||
}
|
||||
|
||||
persistRoleCookie(payload?.user?.role)
|
||||
setSuccess(
|
||||
mode === "signup-owner"
|
||||
? "Owner account created. Registration is now disabled."
|
||||
: "Account created.",
|
||||
? t("auth.messages.ownerCreated", "Owner account created. Registration is now disabled.")
|
||||
: t("auth.messages.accountCreated", "Account created."),
|
||||
)
|
||||
router.push(nextPath)
|
||||
router.refresh()
|
||||
} catch {
|
||||
setError("Network error while signing up")
|
||||
setError(t("auth.errors.networkSignUp", "Network error while signing up"))
|
||||
} finally {
|
||||
setIsBusy(false)
|
||||
}
|
||||
@@ -126,23 +131,34 @@ export function LoginForm({ mode }: LoginFormProps) {
|
||||
return (
|
||||
<main className="mx-auto flex min-h-screen w-full max-w-md flex-col justify-center px-6 py-16">
|
||||
<div className="space-y-3">
|
||||
<p className="text-sm uppercase tracking-[0.2em] text-neutral-500">Admin Auth</p>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<p className="text-sm uppercase tracking-[0.2em] text-neutral-500">
|
||||
{t("auth.badge", "Admin Auth")}
|
||||
</p>
|
||||
<AdminLocaleSwitcher />
|
||||
</div>
|
||||
<h1 className="text-3xl font-semibold tracking-tight">
|
||||
{mode === "signin"
|
||||
? "Sign in to CMS Admin"
|
||||
? t("auth.titles.signIn", "Sign in to CMS Admin")
|
||||
: mode === "signup-owner"
|
||||
? "Welcome to CMS Admin"
|
||||
: "Create an admin account"}
|
||||
? t("auth.titles.signUpOwner", "Welcome to CMS Admin")
|
||||
: mode === "signup-user"
|
||||
? t("auth.titles.signUpUser", "Create an admin account")
|
||||
: t("auth.titles.signUpDisabled", "Registration is disabled")}
|
||||
</h1>
|
||||
<p className="text-sm text-neutral-600">
|
||||
{mode === "signin" ? (
|
||||
<>
|
||||
Better Auth is active on this app via <code>/api/auth</code>.
|
||||
</>
|
||||
) : mode === "signup-owner" ? (
|
||||
"Create the first owner account to initialize this admin instance."
|
||||
) : (
|
||||
"Self-registration is enabled for admin users."
|
||||
{mode === "signin"
|
||||
? t("auth.descriptions.signIn", "Better Auth is active on this app via /api/auth.")
|
||||
: mode === "signup-owner"
|
||||
? t(
|
||||
"auth.descriptions.signUpOwner",
|
||||
"Create the first owner account to initialize this admin instance.",
|
||||
)
|
||||
: mode === "signup-user"
|
||||
? t("auth.descriptions.signUpUser", "Self-registration is enabled for admin users.")
|
||||
: t(
|
||||
"auth.descriptions.signUpDisabled",
|
||||
"Self-registration is currently turned off by an administrator.",
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
@@ -154,7 +170,7 @@ export function LoginForm({ mode }: LoginFormProps) {
|
||||
>
|
||||
<div className="space-y-1">
|
||||
<label className="text-sm font-medium" htmlFor="email">
|
||||
Email or username
|
||||
{t("auth.fields.emailOrUsername", "Email or username")}
|
||||
</label>
|
||||
<input
|
||||
id="email"
|
||||
@@ -168,84 +184,7 @@ export function LoginForm({ mode }: LoginFormProps) {
|
||||
|
||||
<div className="space-y-1">
|
||||
<label className="text-sm font-medium" htmlFor="password">
|
||||
Password
|
||||
</label>
|
||||
<input
|
||||
id="password"
|
||||
type="password"
|
||||
minLength={8}
|
||||
required
|
||||
value={password}
|
||||
onChange={(event) => setPassword(event.target.value)}
|
||||
className="w-full rounded-md border border-neutral-300 px-3 py-2 text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isBusy}
|
||||
className="w-full rounded-md bg-neutral-900 px-4 py-2 text-sm font-medium text-white disabled:opacity-60"
|
||||
>
|
||||
{isBusy ? "Signing in..." : "Sign in"}
|
||||
</button>
|
||||
|
||||
<p className="text-xs text-neutral-600">
|
||||
Need an account?{" "}
|
||||
<Link href={`/register?next=${encodeURIComponent(nextPath)}`} className="underline">
|
||||
Register
|
||||
</Link>
|
||||
</p>
|
||||
|
||||
{error ? <p className="text-sm text-red-600">{error}</p> : null}
|
||||
</form>
|
||||
) : (
|
||||
<form
|
||||
onSubmit={handleSignUp}
|
||||
className="mt-8 space-y-4 rounded-xl border border-neutral-200 p-6"
|
||||
>
|
||||
<div className="space-y-1">
|
||||
<label className="text-sm font-medium" htmlFor="name">
|
||||
Name
|
||||
</label>
|
||||
<input
|
||||
id="name"
|
||||
type="text"
|
||||
value={name}
|
||||
onChange={(event) => setName(event.target.value)}
|
||||
className="w-full rounded-md border border-neutral-300 px-3 py-2 text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<label className="text-sm font-medium" htmlFor="email">
|
||||
Email
|
||||
</label>
|
||||
<input
|
||||
id="email"
|
||||
type="email"
|
||||
required
|
||||
value={email}
|
||||
onChange={(event) => setEmail(event.target.value)}
|
||||
className="w-full rounded-md border border-neutral-300 px-3 py-2 text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<label className="text-sm font-medium" htmlFor="username">
|
||||
Username (optional)
|
||||
</label>
|
||||
<input
|
||||
id="username"
|
||||
type="text"
|
||||
value={username}
|
||||
onChange={(event) => setUsername(event.target.value)}
|
||||
className="w-full rounded-md border border-neutral-300 px-3 py-2 text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<label className="text-sm font-medium" htmlFor="password">
|
||||
Password
|
||||
{t("auth.fields.password", "Password")}
|
||||
</label>
|
||||
<input
|
||||
id="password"
|
||||
@@ -264,22 +203,115 @@ export function LoginForm({ mode }: LoginFormProps) {
|
||||
className="w-full rounded-md bg-neutral-900 px-4 py-2 text-sm font-medium text-white disabled:opacity-60"
|
||||
>
|
||||
{isBusy
|
||||
? "Creating account..."
|
||||
: mode === "signup-owner"
|
||||
? "Create owner account"
|
||||
: "Create account"}
|
||||
? t("auth.actions.signInBusy", "Signing in...")
|
||||
: t("auth.actions.signInIdle", "Sign in")}
|
||||
</button>
|
||||
|
||||
<p className="text-xs text-neutral-600">
|
||||
Already have an account?{" "}
|
||||
{t("auth.links.needAccount", "Need an account?")}{" "}
|
||||
<Link href={`/register?next=${encodeURIComponent(nextPath)}`} className="underline">
|
||||
{t("auth.links.register", "Register")}
|
||||
</Link>
|
||||
</p>
|
||||
|
||||
{error ? <p className="text-sm text-red-600">{error}</p> : null}
|
||||
</form>
|
||||
) : canSubmitSignUp ? (
|
||||
<form
|
||||
onSubmit={handleSignUp}
|
||||
className="mt-8 space-y-4 rounded-xl border border-neutral-200 p-6"
|
||||
>
|
||||
<div className="space-y-1">
|
||||
<label className="text-sm font-medium" htmlFor="name">
|
||||
{t("auth.fields.name", "Name")}
|
||||
</label>
|
||||
<input
|
||||
id="name"
|
||||
type="text"
|
||||
value={name}
|
||||
onChange={(event) => setName(event.target.value)}
|
||||
className="w-full rounded-md border border-neutral-300 px-3 py-2 text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<label className="text-sm font-medium" htmlFor="email">
|
||||
{t("auth.fields.email", "Email")}
|
||||
</label>
|
||||
<input
|
||||
id="email"
|
||||
type="email"
|
||||
required
|
||||
value={email}
|
||||
onChange={(event) => setEmail(event.target.value)}
|
||||
className="w-full rounded-md border border-neutral-300 px-3 py-2 text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<label className="text-sm font-medium" htmlFor="username">
|
||||
{t("auth.fields.username", "Username (optional)")}
|
||||
</label>
|
||||
<input
|
||||
id="username"
|
||||
type="text"
|
||||
value={username}
|
||||
onChange={(event) => setUsername(event.target.value)}
|
||||
className="w-full rounded-md border border-neutral-300 px-3 py-2 text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<label className="text-sm font-medium" htmlFor="password">
|
||||
{t("auth.fields.password", "Password")}
|
||||
</label>
|
||||
<input
|
||||
id="password"
|
||||
type="password"
|
||||
minLength={8}
|
||||
required
|
||||
value={password}
|
||||
onChange={(event) => setPassword(event.target.value)}
|
||||
className="w-full rounded-md border border-neutral-300 px-3 py-2 text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isBusy}
|
||||
className="w-full rounded-md bg-neutral-900 px-4 py-2 text-sm font-medium text-white disabled:opacity-60"
|
||||
>
|
||||
{isBusy
|
||||
? t("auth.actions.signUpBusy", "Creating account...")
|
||||
: mode === "signup-owner"
|
||||
? t("auth.actions.signUpOwnerIdle", "Create owner account")
|
||||
: t("auth.actions.signUpUserIdle", "Create account")}
|
||||
</button>
|
||||
|
||||
<p className="text-xs text-neutral-600">
|
||||
{t("auth.links.alreadyHaveAccount", "Already have an account?")}{" "}
|
||||
<Link href={`/login?next=${encodeURIComponent(nextPath)}`} className="underline">
|
||||
Go to sign in
|
||||
{t("auth.links.goToSignIn", "Go to sign in")}
|
||||
</Link>
|
||||
</p>
|
||||
|
||||
{error ? <p className="text-sm text-red-600">{error}</p> : null}
|
||||
{success ? <p className="text-sm text-green-700">{success}</p> : null}
|
||||
</form>
|
||||
) : (
|
||||
<section className="mt-8 space-y-4 rounded-xl border border-neutral-200 p-6">
|
||||
<p className="text-sm text-neutral-700">
|
||||
{t(
|
||||
"auth.messages.registrationDisabled",
|
||||
"Registration is disabled for this admin instance. Ask an administrator to create an account or enable self-registration.",
|
||||
)}
|
||||
</p>
|
||||
<p className="text-xs text-neutral-600">
|
||||
<Link href={`/login?next=${encodeURIComponent(nextPath)}`} className="underline">
|
||||
{t("auth.links.goToSignIn", "Go to sign in")}
|
||||
</Link>
|
||||
</p>
|
||||
</section>
|
||||
)}
|
||||
</main>
|
||||
)
|
||||
|
||||
67
apps/admin/src/app/login/page.test.tsx
Normal file
67
apps/admin/src/app/login/page.test.tsx
Normal file
@@ -0,0 +1,67 @@
|
||||
import type { ReactElement } from "react"
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest"
|
||||
|
||||
const { redirectMock, resolveRoleFromServerContextMock, hasOwnerUserMock } = vi.hoisted(() => ({
|
||||
redirectMock: vi.fn((path: string) => {
|
||||
throw new Error(`REDIRECT:${path}`)
|
||||
}),
|
||||
resolveRoleFromServerContextMock: vi.fn(),
|
||||
hasOwnerUserMock: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock("next/navigation", () => ({
|
||||
redirect: redirectMock,
|
||||
}))
|
||||
|
||||
vi.mock("@/lib/access-server", () => ({
|
||||
resolveRoleFromServerContext: resolveRoleFromServerContextMock,
|
||||
}))
|
||||
|
||||
vi.mock("@/lib/auth/server", () => ({
|
||||
hasOwnerUser: hasOwnerUserMock,
|
||||
}))
|
||||
|
||||
vi.mock("./login-form", () => ({
|
||||
LoginForm: ({ mode }: { mode: string }) => ({ type: "login-form", props: { mode } }),
|
||||
}))
|
||||
|
||||
import LoginPage from "./page"
|
||||
|
||||
function expectRedirect(call: () => Promise<unknown>, path: string) {
|
||||
return expect(call()).rejects.toThrow(`REDIRECT:${path}`)
|
||||
}
|
||||
|
||||
describe("login page", () => {
|
||||
beforeEach(() => {
|
||||
redirectMock.mockClear()
|
||||
resolveRoleFromServerContextMock.mockReset()
|
||||
hasOwnerUserMock.mockReset()
|
||||
})
|
||||
|
||||
it("redirects authenticated users to dashboard", async () => {
|
||||
resolveRoleFromServerContextMock.mockResolvedValue("manager")
|
||||
|
||||
await expectRedirect(() => LoginPage({ searchParams: Promise.resolve({}) }), "/")
|
||||
})
|
||||
|
||||
it("redirects to welcome if owner is missing", async () => {
|
||||
resolveRoleFromServerContextMock.mockResolvedValue(null)
|
||||
hasOwnerUserMock.mockResolvedValue(false)
|
||||
|
||||
await expectRedirect(
|
||||
() => LoginPage({ searchParams: Promise.resolve({ next: "/settings" }) }),
|
||||
"/welcome?next=%2Fsettings",
|
||||
)
|
||||
})
|
||||
|
||||
it("renders sign-in mode once owner exists", async () => {
|
||||
resolveRoleFromServerContextMock.mockResolvedValue(null)
|
||||
hasOwnerUserMock.mockResolvedValue(true)
|
||||
|
||||
const page = (await LoginPage({ searchParams: Promise.resolve({}) })) as ReactElement<{
|
||||
mode: string
|
||||
}>
|
||||
|
||||
expect(page.props.mode).toBe("signin")
|
||||
})
|
||||
})
|
||||
494
apps/admin/src/app/media/[id]/page.tsx
Normal file
494
apps/admin/src/app/media/[id]/page.tsx
Normal file
@@ -0,0 +1,494 @@
|
||||
import { deleteMediaAsset, getMediaAssetById, updateMediaAsset } from "@cms/db"
|
||||
import { Button } from "@cms/ui/button"
|
||||
import Link from "next/link"
|
||||
import { redirect } from "next/navigation"
|
||||
|
||||
import { AdminShell } from "@/components/admin-shell"
|
||||
import { deleteStoredMediaObject } from "@/lib/media/storage"
|
||||
import { requirePermissionForRoute } from "@/lib/route-guards"
|
||||
|
||||
export const dynamic = "force-dynamic"
|
||||
|
||||
type SearchParamsInput = Record<string, string | string[] | undefined>
|
||||
|
||||
type PageProps = {
|
||||
params: Promise<{ id: string }>
|
||||
searchParams: Promise<SearchParamsInput>
|
||||
}
|
||||
|
||||
function readFirstValue(value: string | string[] | undefined): string | null {
|
||||
if (Array.isArray(value)) {
|
||||
return value[0] ?? null
|
||||
}
|
||||
|
||||
return value ?? null
|
||||
}
|
||||
|
||||
function readInputString(formData: FormData, field: string): string {
|
||||
const value = formData.get(field)
|
||||
return typeof value === "string" ? value.trim() : ""
|
||||
}
|
||||
|
||||
function readNullableString(formData: FormData, field: string): string | null {
|
||||
const value = readInputString(formData, field)
|
||||
return value.length > 0 ? value : null
|
||||
}
|
||||
|
||||
function readNullableInt(formData: FormData, field: string): number | null {
|
||||
const value = readInputString(formData, field)
|
||||
|
||||
if (!value) {
|
||||
return null
|
||||
}
|
||||
|
||||
const parsed = Number.parseInt(value, 10)
|
||||
|
||||
if (!Number.isFinite(parsed)) {
|
||||
return null
|
||||
}
|
||||
|
||||
return parsed
|
||||
}
|
||||
|
||||
function readNullableDate(formData: FormData, field: string): Date | null {
|
||||
const value = readInputString(formData, field)
|
||||
|
||||
if (!value) {
|
||||
return null
|
||||
}
|
||||
|
||||
const parsed = new Date(value)
|
||||
|
||||
if (Number.isNaN(parsed.getTime())) {
|
||||
return null
|
||||
}
|
||||
|
||||
return parsed
|
||||
}
|
||||
|
||||
function readTags(formData: FormData): string[] {
|
||||
const raw = readInputString(formData, "tags")
|
||||
|
||||
if (!raw) {
|
||||
return []
|
||||
}
|
||||
|
||||
return raw
|
||||
.split(",")
|
||||
.map((item) => item.trim())
|
||||
.filter((item) => item.length > 0)
|
||||
}
|
||||
|
||||
function redirectWithState(mediaAssetId: string, params: { notice?: string; error?: string }) {
|
||||
const query = new URLSearchParams()
|
||||
|
||||
if (params.notice) {
|
||||
query.set("notice", params.notice)
|
||||
}
|
||||
|
||||
if (params.error) {
|
||||
query.set("error", params.error)
|
||||
}
|
||||
|
||||
const value = query.toString()
|
||||
redirect(value ? `/media/${mediaAssetId}?${value}` : `/media/${mediaAssetId}`)
|
||||
}
|
||||
|
||||
function toLocalDateTimeInputValue(date: Date): string {
|
||||
const offset = date.getTimezoneOffset() * 60_000
|
||||
return new Date(date.getTime() - offset).toISOString().slice(0, 16)
|
||||
}
|
||||
|
||||
export default async function MediaAssetEditorPage({ params, searchParams }: PageProps) {
|
||||
const role = await requirePermissionForRoute({
|
||||
nextPath: "/media",
|
||||
permission: "media:read",
|
||||
scope: "team",
|
||||
})
|
||||
const resolvedParams = await params
|
||||
const mediaAssetId = resolvedParams.id
|
||||
|
||||
const [resolvedSearchParams, asset] = await Promise.all([
|
||||
searchParams,
|
||||
getMediaAssetById(mediaAssetId),
|
||||
])
|
||||
|
||||
if (!asset) {
|
||||
redirect("/media?error=Media+asset+not+found")
|
||||
}
|
||||
const mediaAsset = asset
|
||||
|
||||
const notice = readFirstValue(resolvedSearchParams.notice)
|
||||
const error = readFirstValue(resolvedSearchParams.error)
|
||||
const previewUrl = mediaAsset.storageKey ? `/api/media/file/${mediaAsset.id}` : null
|
||||
const isImage = Boolean(mediaAsset.mimeType?.startsWith("image/"))
|
||||
const isVideo = Boolean(mediaAsset.mimeType?.startsWith("video/"))
|
||||
|
||||
async function updateMediaAssetAction(formData: FormData) {
|
||||
"use server"
|
||||
|
||||
await requirePermissionForRoute({
|
||||
nextPath: "/media",
|
||||
permission: "media:write",
|
||||
scope: "team",
|
||||
})
|
||||
|
||||
try {
|
||||
await updateMediaAsset({
|
||||
id: mediaAssetId,
|
||||
title: readInputString(formData, "title"),
|
||||
type: readInputString(formData, "type"),
|
||||
description: readNullableString(formData, "description"),
|
||||
altText: readNullableString(formData, "altText"),
|
||||
source: readNullableString(formData, "source"),
|
||||
copyright: readNullableString(formData, "copyright"),
|
||||
author: readNullableString(formData, "author"),
|
||||
licenseType: readNullableString(formData, "licenseType"),
|
||||
licenseUrl: readNullableString(formData, "licenseUrl"),
|
||||
usageContext: readNullableString(formData, "usageContext"),
|
||||
location: readNullableString(formData, "location"),
|
||||
capturedAt: readNullableDate(formData, "capturedAt"),
|
||||
tags: readTags(formData),
|
||||
mimeType: readNullableString(formData, "mimeType"),
|
||||
width: readNullableInt(formData, "width"),
|
||||
height: readNullableInt(formData, "height"),
|
||||
sizeBytes: readNullableInt(formData, "sizeBytes"),
|
||||
isPublished: readInputString(formData, "isPublished") === "true",
|
||||
})
|
||||
} catch {
|
||||
redirectWithState(mediaAssetId, {
|
||||
error: "Failed to update media asset. Validate values and try again.",
|
||||
})
|
||||
}
|
||||
|
||||
redirectWithState(mediaAssetId, { notice: "Media asset updated." })
|
||||
}
|
||||
|
||||
async function deleteMediaAssetAction() {
|
||||
"use server"
|
||||
|
||||
await requirePermissionForRoute({
|
||||
nextPath: "/media",
|
||||
permission: "media:write",
|
||||
scope: "team",
|
||||
})
|
||||
|
||||
try {
|
||||
if (mediaAsset.storageKey) {
|
||||
await deleteStoredMediaObject(mediaAsset.storageKey)
|
||||
}
|
||||
|
||||
await deleteMediaAsset(mediaAssetId)
|
||||
} catch {
|
||||
redirectWithState(mediaAssetId, {
|
||||
error:
|
||||
"Failed to delete media asset and file from storage. Check storage config and links.",
|
||||
})
|
||||
}
|
||||
|
||||
redirect("/media?notice=Media+asset+deleted")
|
||||
}
|
||||
|
||||
return (
|
||||
<AdminShell
|
||||
role={role}
|
||||
activePath="/media"
|
||||
badge="Admin App"
|
||||
title="Media Asset"
|
||||
description="View, edit, and delete uploaded media metadata."
|
||||
>
|
||||
{notice ? (
|
||||
<section className="rounded-xl border border-emerald-300 bg-emerald-50 px-4 py-3 text-sm text-emerald-800">
|
||||
{notice}
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
{error ? (
|
||||
<section className="rounded-xl border border-red-300 bg-red-50 px-4 py-3 text-sm text-red-800">
|
||||
{error}
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
<section className="rounded-xl border border-neutral-200 p-6">
|
||||
<h3 className="text-lg font-medium">Preview</h3>
|
||||
<p className="mt-1 text-sm text-neutral-600">
|
||||
{mediaAsset.mimeType ? `MIME: ${mediaAsset.mimeType}` : "MIME: unknown"}
|
||||
</p>
|
||||
|
||||
<div className="mt-4 rounded-lg border border-neutral-200 bg-neutral-50 p-3">
|
||||
{!previewUrl ? (
|
||||
<p className="text-sm text-neutral-600">
|
||||
No stored file is linked for this media asset.
|
||||
</p>
|
||||
) : isImage ? (
|
||||
// biome-ignore lint/performance/noImgElement: Auth-protected media preview requires direct browser request with session cookies.
|
||||
<img
|
||||
src={previewUrl}
|
||||
alt={mediaAsset.altText || mediaAsset.title}
|
||||
className="max-h-[26rem] w-auto rounded border border-neutral-200 bg-white"
|
||||
/>
|
||||
) : isVideo ? (
|
||||
// biome-ignore lint/a11y/useMediaCaption: Preview uses source assets without guaranteed caption tracks.
|
||||
<video
|
||||
src={previewUrl}
|
||||
controls
|
||||
preload="metadata"
|
||||
className="max-h-[26rem] w-full rounded border border-neutral-200 bg-black"
|
||||
/>
|
||||
) : (
|
||||
<p className="text-sm text-neutral-700">
|
||||
Inline preview is not available for this media type.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{previewUrl ? (
|
||||
<a
|
||||
href={previewUrl}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="mt-3 inline-block text-sm text-neutral-700 underline underline-offset-2"
|
||||
>
|
||||
Open raw media file
|
||||
</a>
|
||||
) : null}
|
||||
</section>
|
||||
|
||||
<section className="rounded-xl border border-neutral-200 p-6">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<h2 className="text-xl font-medium">{mediaAsset.title}</h2>
|
||||
<p className="mt-1 text-xs text-neutral-600">ID: {mediaAsset.id}</p>
|
||||
</div>
|
||||
<Link href="/media" className="text-sm text-neutral-700 underline underline-offset-2">
|
||||
Back to media list
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<form action={updateMediaAssetAction} className="mt-6 space-y-3">
|
||||
<div className="grid gap-3 md:grid-cols-2">
|
||||
<label className="space-y-1">
|
||||
<span className="text-xs text-neutral-600">Title</span>
|
||||
<input
|
||||
name="title"
|
||||
defaultValue={mediaAsset.title}
|
||||
required
|
||||
className="w-full rounded border border-neutral-300 px-3 py-2 text-sm"
|
||||
/>
|
||||
</label>
|
||||
<label className="space-y-1">
|
||||
<span className="text-xs text-neutral-600">Type</span>
|
||||
<select
|
||||
name="type"
|
||||
defaultValue={mediaAsset.type}
|
||||
className="w-full rounded border border-neutral-300 px-3 py-2 text-sm"
|
||||
>
|
||||
<option value="artwork">artwork</option>
|
||||
<option value="banner">banner</option>
|
||||
<option value="promotion">promotion</option>
|
||||
<option value="video">video</option>
|
||||
<option value="gif">gif</option>
|
||||
<option value="generic">generic</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<label className="space-y-1">
|
||||
<span className="text-xs text-neutral-600">Description</span>
|
||||
<textarea
|
||||
name="description"
|
||||
rows={3}
|
||||
defaultValue={mediaAsset.description ?? ""}
|
||||
className="w-full rounded border border-neutral-300 px-3 py-2 text-sm"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<div className="grid gap-3 md:grid-cols-2">
|
||||
<label className="space-y-1">
|
||||
<span className="text-xs text-neutral-600">Alt text</span>
|
||||
<input
|
||||
name="altText"
|
||||
defaultValue={mediaAsset.altText ?? ""}
|
||||
className="w-full rounded border border-neutral-300 px-3 py-2 text-sm"
|
||||
/>
|
||||
</label>
|
||||
<label className="space-y-1">
|
||||
<span className="text-xs text-neutral-600">Author</span>
|
||||
<input
|
||||
name="author"
|
||||
defaultValue={mediaAsset.author ?? ""}
|
||||
className="w-full rounded border border-neutral-300 px-3 py-2 text-sm"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-3 md:grid-cols-2">
|
||||
<label className="space-y-1">
|
||||
<span className="text-xs text-neutral-600">Source</span>
|
||||
<input
|
||||
name="source"
|
||||
defaultValue={mediaAsset.source ?? ""}
|
||||
className="w-full rounded border border-neutral-300 px-3 py-2 text-sm"
|
||||
/>
|
||||
</label>
|
||||
<label className="space-y-1">
|
||||
<span className="text-xs text-neutral-600">Copyright</span>
|
||||
<input
|
||||
name="copyright"
|
||||
defaultValue={mediaAsset.copyright ?? ""}
|
||||
className="w-full rounded border border-neutral-300 px-3 py-2 text-sm"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-3 md:grid-cols-2">
|
||||
<label className="space-y-1">
|
||||
<span className="text-xs text-neutral-600">License type</span>
|
||||
<input
|
||||
name="licenseType"
|
||||
defaultValue={mediaAsset.licenseType ?? ""}
|
||||
className="w-full rounded border border-neutral-300 px-3 py-2 text-sm"
|
||||
/>
|
||||
</label>
|
||||
<label className="space-y-1">
|
||||
<span className="text-xs text-neutral-600">License URL</span>
|
||||
<input
|
||||
name="licenseUrl"
|
||||
defaultValue={mediaAsset.licenseUrl ?? ""}
|
||||
className="w-full rounded border border-neutral-300 px-3 py-2 text-sm"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-3 md:grid-cols-2">
|
||||
<label className="space-y-1">
|
||||
<span className="text-xs text-neutral-600">Usage context</span>
|
||||
<input
|
||||
name="usageContext"
|
||||
defaultValue={mediaAsset.usageContext ?? ""}
|
||||
className="w-full rounded border border-neutral-300 px-3 py-2 text-sm"
|
||||
/>
|
||||
</label>
|
||||
<label className="space-y-1">
|
||||
<span className="text-xs text-neutral-600">Location</span>
|
||||
<input
|
||||
name="location"
|
||||
defaultValue={mediaAsset.location ?? ""}
|
||||
className="w-full rounded border border-neutral-300 px-3 py-2 text-sm"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<label className="space-y-1">
|
||||
<span className="text-xs text-neutral-600">Captured at</span>
|
||||
<input
|
||||
name="capturedAt"
|
||||
type="datetime-local"
|
||||
defaultValue={
|
||||
mediaAsset.capturedAt ? toLocalDateTimeInputValue(mediaAsset.capturedAt) : ""
|
||||
}
|
||||
className="w-full rounded border border-neutral-300 px-3 py-2 text-sm"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<div className="grid gap-3 md:grid-cols-3">
|
||||
<label className="space-y-1">
|
||||
<span className="text-xs text-neutral-600">MIME type</span>
|
||||
<input
|
||||
name="mimeType"
|
||||
defaultValue={mediaAsset.mimeType ?? ""}
|
||||
className="w-full rounded border border-neutral-300 px-3 py-2 text-sm"
|
||||
/>
|
||||
</label>
|
||||
<label className="space-y-1">
|
||||
<span className="text-xs text-neutral-600">Width</span>
|
||||
<input
|
||||
name="width"
|
||||
type="number"
|
||||
min={1}
|
||||
defaultValue={mediaAsset.width ?? ""}
|
||||
className="w-full rounded border border-neutral-300 px-3 py-2 text-sm"
|
||||
/>
|
||||
</label>
|
||||
<label className="space-y-1">
|
||||
<span className="text-xs text-neutral-600">Height</span>
|
||||
<input
|
||||
name="height"
|
||||
type="number"
|
||||
min={1}
|
||||
defaultValue={mediaAsset.height ?? ""}
|
||||
className="w-full rounded border border-neutral-300 px-3 py-2 text-sm"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-3 md:grid-cols-2">
|
||||
<label className="space-y-1">
|
||||
<span className="text-xs text-neutral-600">Size (bytes)</span>
|
||||
<input
|
||||
name="sizeBytes"
|
||||
type="number"
|
||||
min={0}
|
||||
defaultValue={mediaAsset.sizeBytes ?? ""}
|
||||
className="w-full rounded border border-neutral-300 px-3 py-2 text-sm"
|
||||
/>
|
||||
</label>
|
||||
<label className="space-y-1">
|
||||
<span className="text-xs text-neutral-600">Tags (comma-separated)</span>
|
||||
<input
|
||||
name="tags"
|
||||
defaultValue={mediaAsset.tags.join(", ")}
|
||||
className="w-full rounded border border-neutral-300 px-3 py-2 text-sm"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<label className="inline-flex items-center gap-2 text-sm text-neutral-700">
|
||||
<input
|
||||
type="checkbox"
|
||||
name="isPublished"
|
||||
value="true"
|
||||
defaultChecked={mediaAsset.isPublished}
|
||||
className="size-4"
|
||||
/>
|
||||
Published
|
||||
</label>
|
||||
|
||||
<div className="grid gap-3 md:grid-cols-2">
|
||||
<label className="space-y-1">
|
||||
<span className="text-xs text-neutral-600">Storage key</span>
|
||||
<input
|
||||
value={mediaAsset.storageKey ?? "-"}
|
||||
readOnly
|
||||
className="w-full rounded border border-neutral-200 bg-neutral-50 px-3 py-2 text-xs text-neutral-600"
|
||||
/>
|
||||
</label>
|
||||
<label className="space-y-1">
|
||||
<span className="text-xs text-neutral-600">Last updated</span>
|
||||
<input
|
||||
value={toLocalDateTimeInputValue(mediaAsset.updatedAt)}
|
||||
readOnly
|
||||
className="w-full rounded border border-neutral-200 bg-neutral-50 px-3 py-2 text-xs text-neutral-600"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<Button type="submit">Save changes</Button>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section className="rounded-xl border border-red-300 bg-red-50 p-6">
|
||||
<h3 className="text-lg font-medium text-red-800">Danger Zone</h3>
|
||||
<p className="mt-1 text-sm text-red-700">
|
||||
Deleting this media asset is permanent. Any linked artwork rendition references will also
|
||||
be removed.
|
||||
</p>
|
||||
<form action={deleteMediaAssetAction} className="mt-4">
|
||||
<Button type="submit" variant="secondary" className="border border-red-300 text-red-800">
|
||||
Delete media asset
|
||||
</Button>
|
||||
</form>
|
||||
</section>
|
||||
</AdminShell>
|
||||
)
|
||||
}
|
||||
169
apps/admin/src/app/media/page.tsx
Normal file
169
apps/admin/src/app/media/page.tsx
Normal file
@@ -0,0 +1,169 @@
|
||||
import { getMediaFoundationSummary, listMediaAssets } from "@cms/db"
|
||||
import Link from "next/link"
|
||||
|
||||
import { AdminShell } from "@/components/admin-shell"
|
||||
import { FlashQueryCleanup } from "@/components/media/flash-query-cleanup"
|
||||
import { MediaUploadForm } from "@/components/media/media-upload-form"
|
||||
import { resolveMediaStorageProvider } from "@/lib/media/storage"
|
||||
import { requirePermissionForRoute } from "@/lib/route-guards"
|
||||
|
||||
export const dynamic = "force-dynamic"
|
||||
|
||||
type SearchParamsInput = Record<string, string | string[] | undefined>
|
||||
|
||||
function readFirstValue(value: string | string[] | undefined): string | null {
|
||||
if (Array.isArray(value)) {
|
||||
return value[0] ?? null
|
||||
}
|
||||
|
||||
return value ?? null
|
||||
}
|
||||
|
||||
export default async function MediaManagementPage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<SearchParamsInput>
|
||||
}) {
|
||||
const role = await requirePermissionForRoute({
|
||||
nextPath: "/media",
|
||||
permission: "media:read",
|
||||
scope: "team",
|
||||
})
|
||||
const [resolvedSearchParams, summary, assets] = await Promise.all([
|
||||
searchParams,
|
||||
getMediaFoundationSummary(),
|
||||
listMediaAssets(20),
|
||||
])
|
||||
const notice = readFirstValue(resolvedSearchParams.notice)
|
||||
const error = readFirstValue(resolvedSearchParams.error)
|
||||
const uploadedVia = readFirstValue(resolvedSearchParams.uploadedVia)
|
||||
const warning = readFirstValue(resolvedSearchParams.warning)
|
||||
const activeStorageProvider = resolveMediaStorageProvider(process.env.CMS_MEDIA_STORAGE_PROVIDER)
|
||||
const hasFlashQuery = Boolean(notice || error || warning || uploadedVia)
|
||||
|
||||
return (
|
||||
<AdminShell
|
||||
role={role}
|
||||
activePath="/media"
|
||||
badge="Admin App"
|
||||
title="Media"
|
||||
description="Media foundation baseline for assets, artwork renditions, and grouping metadata."
|
||||
>
|
||||
<FlashQueryCleanup enabled={hasFlashQuery} />
|
||||
|
||||
{notice ? (
|
||||
<section className="rounded-xl border border-emerald-300 bg-emerald-50 px-4 py-3 text-sm text-emerald-800">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span>{notice}</span>
|
||||
{uploadedVia ? (
|
||||
<span className="rounded border border-emerald-300 bg-white px-2 py-0.5 text-xs font-medium uppercase tracking-wide text-emerald-700">
|
||||
Stored via: {uploadedVia}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
{error ? (
|
||||
<section className="rounded-xl border border-red-300 bg-red-50 px-4 py-3 text-sm text-red-800">
|
||||
{error}
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
{warning ? (
|
||||
<section className="rounded-xl border border-amber-300 bg-amber-50 px-4 py-3 text-sm text-amber-900">
|
||||
{warning}
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
<section className="grid gap-3 sm:grid-cols-2 xl:grid-cols-3">
|
||||
<article className="rounded-xl border border-neutral-200 p-4">
|
||||
<p className="text-xs uppercase tracking-[0.2em] text-neutral-500">Media Assets</p>
|
||||
<p className="mt-2 text-3xl font-semibold">{summary.mediaAssets}</p>
|
||||
</article>
|
||||
<article className="rounded-xl border border-neutral-200 p-4">
|
||||
<p className="text-xs uppercase tracking-[0.2em] text-neutral-500">Artworks</p>
|
||||
<p className="mt-2 text-3xl font-semibold">{summary.artworks}</p>
|
||||
</article>
|
||||
<article className="rounded-xl border border-neutral-200 p-4">
|
||||
<p className="text-xs uppercase tracking-[0.2em] text-neutral-500">Groups</p>
|
||||
<p className="mt-2 text-3xl font-semibold">
|
||||
{summary.galleries + summary.albums + summary.categories + summary.tags}
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-neutral-500">
|
||||
{summary.galleries} galleries · {summary.albums} albums · {summary.categories}{" "}
|
||||
categories · {summary.tags} tags
|
||||
</p>
|
||||
</article>
|
||||
</section>
|
||||
|
||||
<section className="rounded-xl border border-neutral-200 p-6">
|
||||
<h2 className="text-xl font-medium">Upload Media Asset</h2>
|
||||
<p className="mt-1 text-sm text-neutral-600">
|
||||
Upload storage provider: <strong>{activeStorageProvider}</strong>. You can switch via
|
||||
`CMS_MEDIA_STORAGE_PROVIDER` (`s3` default, `local` fallback) until the admin settings
|
||||
toggle lands.
|
||||
</p>
|
||||
<MediaUploadForm />
|
||||
</section>
|
||||
|
||||
<section className="rounded-xl border border-neutral-200 p-6">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<h2 className="text-xl font-medium">Recent Media Assets</h2>
|
||||
<span className="text-xs uppercase tracking-[0.2em] text-neutral-500">
|
||||
MVP1 Upload Pipeline
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-4 overflow-x-auto">
|
||||
<table className="min-w-full text-left text-sm">
|
||||
<thead className="text-xs uppercase tracking-wide text-neutral-500">
|
||||
<tr>
|
||||
<th className="py-2 pr-4">Title</th>
|
||||
<th className="py-2 pr-4">Type</th>
|
||||
<th className="py-2 pr-4">MIME</th>
|
||||
<th className="py-2 pr-4">Size</th>
|
||||
<th className="py-2 pr-4">Published</th>
|
||||
<th className="py-2 pr-4">Updated</th>
|
||||
<th className="py-2 pr-4">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{assets.length === 0 ? (
|
||||
<tr>
|
||||
<td className="py-3 text-neutral-500" colSpan={7}>
|
||||
No media assets yet. Upload your first asset above.
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
assets.map((asset) => (
|
||||
<tr key={asset.id} className="border-t border-neutral-200">
|
||||
<td className="py-3 pr-4">{asset.title}</td>
|
||||
<td className="py-3 pr-4">{asset.type}</td>
|
||||
<td className="py-3 pr-4 text-neutral-600">{asset.mimeType ?? "-"}</td>
|
||||
<td className="py-3 pr-4 text-neutral-600">
|
||||
{typeof asset.sizeBytes === "number"
|
||||
? `${Math.max(1, Math.round(asset.sizeBytes / 1024))} KB`
|
||||
: "-"}
|
||||
</td>
|
||||
<td className="py-3 pr-4">{asset.isPublished ? "yes" : "no"}</td>
|
||||
<td className="py-3 pr-4 text-neutral-600">
|
||||
{asset.updatedAt.toLocaleDateString("en-US")}
|
||||
</td>
|
||||
<td className="py-3 pr-4">
|
||||
<Link
|
||||
href={`/media/${asset.id}`}
|
||||
className="text-xs font-medium text-neutral-700 underline underline-offset-2"
|
||||
>
|
||||
Open
|
||||
</Link>
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
</AdminShell>
|
||||
)
|
||||
}
|
||||
522
apps/admin/src/app/navigation/page.tsx
Normal file
522
apps/admin/src/app/navigation/page.tsx
Normal file
@@ -0,0 +1,522 @@
|
||||
import {
|
||||
createNavigationItem,
|
||||
createNavigationMenu,
|
||||
deleteNavigationItem,
|
||||
deleteNavigationMenu,
|
||||
listNavigationMenus,
|
||||
listPages,
|
||||
updateNavigationItem,
|
||||
updateNavigationMenu,
|
||||
upsertNavigationItemTranslation,
|
||||
} from "@cms/db"
|
||||
import { Button } from "@cms/ui/button"
|
||||
import { revalidatePath } from "next/cache"
|
||||
import { redirect } from "next/navigation"
|
||||
|
||||
import { AdminShell } from "@/components/admin-shell"
|
||||
import { CreateMenuForm } from "@/components/navigation/create-menu-form"
|
||||
import { CreateNavigationItemForm } from "@/components/navigation/create-navigation-item-form"
|
||||
import { requirePermissionForRoute } from "@/lib/route-guards"
|
||||
|
||||
export const dynamic = "force-dynamic"
|
||||
|
||||
type SearchParamsInput = Record<string, string | string[] | undefined>
|
||||
const SUPPORTED_LOCALES = ["de", "en", "es", "fr"] as const
|
||||
|
||||
type SupportedLocale = (typeof SUPPORTED_LOCALES)[number]
|
||||
|
||||
function readFirstValue(value: string | string[] | undefined): string | null {
|
||||
if (Array.isArray(value)) {
|
||||
return value[0] ?? null
|
||||
}
|
||||
|
||||
return value ?? null
|
||||
}
|
||||
|
||||
function readInputString(formData: FormData, field: string): string {
|
||||
const value = formData.get(field)
|
||||
return typeof value === "string" ? value.trim() : ""
|
||||
}
|
||||
|
||||
function readNullableString(formData: FormData, field: string): string | null {
|
||||
const value = readInputString(formData, field)
|
||||
return value.length > 0 ? value : null
|
||||
}
|
||||
|
||||
function readInt(formData: FormData, field: string, fallback = 0): number {
|
||||
const value = readInputString(formData, field)
|
||||
|
||||
if (!value) {
|
||||
return fallback
|
||||
}
|
||||
|
||||
const parsed = Number.parseInt(value, 10)
|
||||
|
||||
if (!Number.isFinite(parsed)) {
|
||||
return fallback
|
||||
}
|
||||
|
||||
return parsed
|
||||
}
|
||||
|
||||
function normalizeLocale(input: string | null): SupportedLocale {
|
||||
if (input && SUPPORTED_LOCALES.includes(input as SupportedLocale)) {
|
||||
return input as SupportedLocale
|
||||
}
|
||||
|
||||
return "en"
|
||||
}
|
||||
|
||||
function redirectWithState(params: { notice?: string; error?: string }) {
|
||||
const query = new URLSearchParams()
|
||||
|
||||
if (params.notice) {
|
||||
query.set("notice", params.notice)
|
||||
}
|
||||
|
||||
if (params.error) {
|
||||
query.set("error", params.error)
|
||||
}
|
||||
|
||||
const value = query.toString()
|
||||
redirect(value ? `/navigation?${value}` : "/navigation")
|
||||
}
|
||||
|
||||
async function createMenuAction(formData: FormData) {
|
||||
"use server"
|
||||
|
||||
await requirePermissionForRoute({
|
||||
nextPath: "/navigation",
|
||||
permission: "navigation:write",
|
||||
scope: "team",
|
||||
})
|
||||
|
||||
try {
|
||||
await createNavigationMenu({
|
||||
name: readInputString(formData, "name"),
|
||||
slug: readInputString(formData, "slug"),
|
||||
location: readInputString(formData, "location"),
|
||||
isVisible: readInputString(formData, "isVisible") === "true",
|
||||
})
|
||||
} catch {
|
||||
redirectWithState({ error: "Failed to create navigation menu." })
|
||||
}
|
||||
|
||||
revalidatePath("/navigation")
|
||||
redirectWithState({ notice: "Navigation menu created." })
|
||||
}
|
||||
|
||||
async function createItemAction(formData: FormData) {
|
||||
"use server"
|
||||
|
||||
await requirePermissionForRoute({
|
||||
nextPath: "/navigation",
|
||||
permission: "navigation:write",
|
||||
scope: "team",
|
||||
})
|
||||
|
||||
try {
|
||||
await createNavigationItem({
|
||||
menuId: readInputString(formData, "menuId"),
|
||||
label: readInputString(formData, "label"),
|
||||
href: readNullableString(formData, "href"),
|
||||
pageId: readNullableString(formData, "pageId"),
|
||||
parentId: readNullableString(formData, "parentId"),
|
||||
sortOrder: readInt(formData, "sortOrder", 0),
|
||||
isVisible: readInputString(formData, "isVisible") === "true",
|
||||
})
|
||||
} catch {
|
||||
redirectWithState({ error: "Failed to create navigation item." })
|
||||
}
|
||||
|
||||
revalidatePath("/navigation")
|
||||
redirectWithState({ notice: "Navigation item created." })
|
||||
}
|
||||
|
||||
async function updateMenuAction(formData: FormData) {
|
||||
"use server"
|
||||
|
||||
await requirePermissionForRoute({
|
||||
nextPath: "/navigation",
|
||||
permission: "navigation:write",
|
||||
scope: "team",
|
||||
})
|
||||
|
||||
try {
|
||||
await updateNavigationMenu({
|
||||
id: readInputString(formData, "id"),
|
||||
name: readInputString(formData, "name"),
|
||||
slug: readInputString(formData, "slug"),
|
||||
location: readInputString(formData, "location"),
|
||||
isVisible: readInputString(formData, "isVisible") === "true",
|
||||
})
|
||||
} catch {
|
||||
redirectWithState({ error: "Failed to update navigation menu." })
|
||||
}
|
||||
|
||||
revalidatePath("/navigation")
|
||||
redirectWithState({ notice: "Navigation menu updated." })
|
||||
}
|
||||
|
||||
async function deleteMenuAction(formData: FormData) {
|
||||
"use server"
|
||||
|
||||
await requirePermissionForRoute({
|
||||
nextPath: "/navigation",
|
||||
permission: "navigation:write",
|
||||
scope: "team",
|
||||
})
|
||||
|
||||
try {
|
||||
await deleteNavigationMenu(readInputString(formData, "id"))
|
||||
} catch {
|
||||
redirectWithState({ error: "Failed to delete navigation menu." })
|
||||
}
|
||||
|
||||
revalidatePath("/navigation")
|
||||
redirectWithState({ notice: "Navigation menu deleted." })
|
||||
}
|
||||
|
||||
async function updateItemAction(formData: FormData) {
|
||||
"use server"
|
||||
|
||||
await requirePermissionForRoute({
|
||||
nextPath: "/navigation",
|
||||
permission: "navigation:write",
|
||||
scope: "team",
|
||||
})
|
||||
|
||||
try {
|
||||
await updateNavigationItem({
|
||||
id: readInputString(formData, "id"),
|
||||
label: readInputString(formData, "label"),
|
||||
href: readNullableString(formData, "href"),
|
||||
pageId: readNullableString(formData, "pageId"),
|
||||
parentId: readNullableString(formData, "parentId"),
|
||||
sortOrder: readInt(formData, "sortOrder", 0),
|
||||
isVisible: readInputString(formData, "isVisible") === "true",
|
||||
})
|
||||
} catch {
|
||||
redirectWithState({ error: "Failed to update navigation item." })
|
||||
}
|
||||
|
||||
revalidatePath("/navigation")
|
||||
redirectWithState({ notice: "Navigation item updated." })
|
||||
}
|
||||
|
||||
async function deleteItemAction(formData: FormData) {
|
||||
"use server"
|
||||
|
||||
await requirePermissionForRoute({
|
||||
nextPath: "/navigation",
|
||||
permission: "navigation:write",
|
||||
scope: "team",
|
||||
})
|
||||
|
||||
try {
|
||||
await deleteNavigationItem(readInputString(formData, "id"))
|
||||
} catch {
|
||||
redirectWithState({ error: "Failed to delete navigation item." })
|
||||
}
|
||||
|
||||
revalidatePath("/navigation")
|
||||
redirectWithState({ notice: "Navigation item deleted." })
|
||||
}
|
||||
|
||||
async function upsertItemTranslationAction(formData: FormData) {
|
||||
"use server"
|
||||
|
||||
await requirePermissionForRoute({
|
||||
nextPath: "/navigation",
|
||||
permission: "navigation:write",
|
||||
scope: "team",
|
||||
})
|
||||
|
||||
const locale = normalizeLocale(readInputString(formData, "locale"))
|
||||
|
||||
try {
|
||||
await upsertNavigationItemTranslation({
|
||||
navigationItemId: readInputString(formData, "navigationItemId"),
|
||||
locale,
|
||||
label: readInputString(formData, "label"),
|
||||
})
|
||||
} catch {
|
||||
redirectWithState({ error: "Failed to save item translation." })
|
||||
}
|
||||
|
||||
revalidatePath("/navigation")
|
||||
redirectWithState({ notice: "Navigation item translation saved." })
|
||||
}
|
||||
|
||||
export default async function NavigationManagementPage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<SearchParamsInput>
|
||||
}) {
|
||||
const role = await requirePermissionForRoute({
|
||||
nextPath: "/navigation",
|
||||
permission: "navigation:read",
|
||||
scope: "team",
|
||||
})
|
||||
|
||||
const [resolvedSearchParams, menus, pages] = await Promise.all([
|
||||
searchParams,
|
||||
listNavigationMenus(),
|
||||
listPages(200),
|
||||
])
|
||||
|
||||
const notice = readFirstValue(resolvedSearchParams.notice)
|
||||
const error = readFirstValue(resolvedSearchParams.error)
|
||||
const selectedLocale = normalizeLocale(readFirstValue(resolvedSearchParams.locale))
|
||||
|
||||
return (
|
||||
<AdminShell
|
||||
role={role}
|
||||
activePath="/navigation"
|
||||
badge="Admin App"
|
||||
title="Navigation"
|
||||
description="Manage menus and navigation entries linked to pages or custom routes."
|
||||
>
|
||||
{notice ? (
|
||||
<section className="rounded-xl border border-emerald-300 bg-emerald-50 px-4 py-3 text-sm text-emerald-800">
|
||||
{notice}
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
{error ? (
|
||||
<section className="rounded-xl border border-red-300 bg-red-50 px-4 py-3 text-sm text-red-800">
|
||||
{error}
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
<section className="grid gap-4 lg:grid-cols-2">
|
||||
<article className="rounded-xl border border-neutral-200 p-6">
|
||||
<h2 className="text-xl font-medium">Create Menu</h2>
|
||||
<CreateMenuForm action={createMenuAction} />
|
||||
</article>
|
||||
|
||||
<article className="rounded-xl border border-neutral-200 p-6">
|
||||
<h2 className="text-xl font-medium">Create Navigation Item</h2>
|
||||
<CreateNavigationItemForm action={createItemAction} menus={menus} pages={pages} />
|
||||
</article>
|
||||
</section>
|
||||
|
||||
<section className="space-y-4">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{SUPPORTED_LOCALES.map((locale) => (
|
||||
<a
|
||||
key={locale}
|
||||
href={`/navigation?locale=${locale}`}
|
||||
className={`inline-flex rounded border px-3 py-1.5 text-xs ${
|
||||
selectedLocale === locale
|
||||
? "border-neutral-800 bg-neutral-900 text-white"
|
||||
: "border-neutral-300 text-neutral-700"
|
||||
}`}
|
||||
>
|
||||
{locale.toUpperCase()}
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{menus.length === 0 ? (
|
||||
<article className="rounded-xl border border-neutral-200 p-6 text-sm text-neutral-600">
|
||||
No navigation menus yet.
|
||||
</article>
|
||||
) : (
|
||||
menus.map((menu) => (
|
||||
<article key={menu.id} className="rounded-xl border border-neutral-200 p-6">
|
||||
<form action={updateMenuAction} className="rounded border border-neutral-200 p-3">
|
||||
<input type="hidden" name="id" value={menu.id} />
|
||||
<div className="grid gap-3 md:grid-cols-4">
|
||||
<label className="space-y-1">
|
||||
<span className="text-xs text-neutral-600">Menu name</span>
|
||||
<input
|
||||
name="name"
|
||||
defaultValue={menu.name}
|
||||
className="w-full rounded border border-neutral-300 px-3 py-2 text-sm"
|
||||
/>
|
||||
</label>
|
||||
<label className="space-y-1">
|
||||
<span className="text-xs text-neutral-600">Slug</span>
|
||||
<input
|
||||
name="slug"
|
||||
defaultValue={menu.slug}
|
||||
className="w-full rounded border border-neutral-300 px-3 py-2 text-sm"
|
||||
/>
|
||||
</label>
|
||||
<label className="space-y-1">
|
||||
<span className="text-xs text-neutral-600">Location</span>
|
||||
<input
|
||||
name="location"
|
||||
defaultValue={menu.location}
|
||||
className="w-full rounded border border-neutral-300 px-3 py-2 text-sm"
|
||||
/>
|
||||
</label>
|
||||
<label className="space-y-1">
|
||||
<span className="text-xs text-neutral-600">Visible</span>
|
||||
<select
|
||||
name="isVisible"
|
||||
defaultValue={menu.isVisible ? "true" : "false"}
|
||||
className="w-full rounded border border-neutral-300 px-3 py-2 text-sm"
|
||||
>
|
||||
<option value="true">Visible</option>
|
||||
<option value="false">Hidden</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
<div className="mt-3 flex items-center gap-2">
|
||||
<Button type="submit" size="sm">
|
||||
Save menu
|
||||
</Button>
|
||||
<button
|
||||
type="submit"
|
||||
formAction={deleteMenuAction}
|
||||
className="rounded-md border border-red-300 px-3 py-2 text-sm text-red-700"
|
||||
>
|
||||
Delete menu
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div className="mt-4 space-y-3">
|
||||
{menu.items.length === 0 ? (
|
||||
<p className="text-sm text-neutral-600">No items in this menu.</p>
|
||||
) : (
|
||||
menu.items.map((item) => {
|
||||
const translation = item.translations.find(
|
||||
(entry) => entry.locale === selectedLocale,
|
||||
)
|
||||
|
||||
return (
|
||||
<div key={item.id} className="rounded-lg border border-neutral-200 p-3">
|
||||
<form action={updateItemAction}>
|
||||
<input type="hidden" name="id" value={item.id} />
|
||||
<div className="grid gap-3 md:grid-cols-5">
|
||||
<label className="space-y-1 md:col-span-2">
|
||||
<span className="text-xs text-neutral-600">Label</span>
|
||||
<input
|
||||
name="label"
|
||||
defaultValue={item.label}
|
||||
className="w-full rounded border border-neutral-300 px-3 py-2 text-sm"
|
||||
/>
|
||||
</label>
|
||||
<label className="space-y-1 md:col-span-2">
|
||||
<span className="text-xs text-neutral-600">Href</span>
|
||||
<input
|
||||
name="href"
|
||||
defaultValue={item.href ?? ""}
|
||||
className="w-full rounded border border-neutral-300 px-3 py-2 text-sm"
|
||||
/>
|
||||
</label>
|
||||
<label className="space-y-1">
|
||||
<span className="text-xs text-neutral-600">Sort</span>
|
||||
<input
|
||||
name="sortOrder"
|
||||
type="number"
|
||||
min={0}
|
||||
defaultValue={item.sortOrder}
|
||||
className="w-full rounded border border-neutral-300 px-3 py-2 text-sm"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="mt-3 grid gap-3 md:grid-cols-2">
|
||||
<label className="space-y-1">
|
||||
<span className="text-xs text-neutral-600">Linked page</span>
|
||||
<select
|
||||
name="pageId"
|
||||
defaultValue={item.pageId ?? ""}
|
||||
className="w-full rounded border border-neutral-300 px-3 py-2 text-sm"
|
||||
>
|
||||
<option value="">(none)</option>
|
||||
{pages.map((page) => (
|
||||
<option key={page.id} value={page.id}>
|
||||
{page.title} (/{page.slug})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label className="space-y-1">
|
||||
<span className="text-xs text-neutral-600">Parent id</span>
|
||||
<select
|
||||
name="parentId"
|
||||
defaultValue={item.parentId ?? ""}
|
||||
className="w-full rounded border border-neutral-300 px-3 py-2 text-sm"
|
||||
>
|
||||
<option value="">(none)</option>
|
||||
{menu.items
|
||||
.filter((entry) => entry.id !== item.id)
|
||||
.map((entry) => (
|
||||
<option key={`${item.id}-parent-${entry.id}`} value={entry.id}>
|
||||
{entry.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="mt-3 flex flex-wrap items-center justify-between gap-3">
|
||||
<label className="inline-flex items-center gap-2 text-sm text-neutral-700">
|
||||
<input
|
||||
type="checkbox"
|
||||
name="isVisible"
|
||||
value="true"
|
||||
defaultChecked={item.isVisible}
|
||||
className="size-4"
|
||||
/>
|
||||
Visible
|
||||
</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button type="submit" size="sm">
|
||||
Save item
|
||||
</Button>
|
||||
<button
|
||||
type="submit"
|
||||
formAction={deleteItemAction}
|
||||
className="rounded-md border border-red-300 px-3 py-2 text-sm text-red-700"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<form
|
||||
action={upsertItemTranslationAction}
|
||||
className="mt-3 rounded border border-neutral-200 p-3"
|
||||
>
|
||||
<input type="hidden" name="navigationItemId" value={item.id} />
|
||||
<input type="hidden" name="locale" value={selectedLocale} />
|
||||
|
||||
<p className="text-xs text-neutral-600">
|
||||
Translation ({selectedLocale.toUpperCase()}) - saved locales:{" "}
|
||||
{item.translations.length > 0
|
||||
? item.translations
|
||||
.map((entry) => entry.locale.toUpperCase())
|
||||
.join(", ")
|
||||
: "none"}
|
||||
</p>
|
||||
|
||||
<div className="mt-2 flex gap-2">
|
||||
<input
|
||||
name="label"
|
||||
defaultValue={translation?.label ?? item.label}
|
||||
className="w-full rounded border border-neutral-300 px-3 py-2 text-sm"
|
||||
/>
|
||||
<Button type="submit" size="sm" variant="secondary">
|
||||
Save translation
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</article>
|
||||
))
|
||||
)}
|
||||
</section>
|
||||
</AdminShell>
|
||||
)
|
||||
}
|
||||
399
apps/admin/src/app/news/page.tsx
Normal file
399
apps/admin/src/app/news/page.tsx
Normal file
@@ -0,0 +1,399 @@
|
||||
import {
|
||||
createPost,
|
||||
deletePost,
|
||||
listPostsWithTranslations,
|
||||
updatePost,
|
||||
upsertPostTranslation,
|
||||
} from "@cms/db"
|
||||
import { Button } from "@cms/ui/button"
|
||||
import { revalidatePath } from "next/cache"
|
||||
import { redirect } from "next/navigation"
|
||||
|
||||
import { AdminShell } from "@/components/admin-shell"
|
||||
import { requirePermissionForRoute } from "@/lib/route-guards"
|
||||
|
||||
export const dynamic = "force-dynamic"
|
||||
|
||||
type SearchParamsInput = Record<string, string | string[] | undefined>
|
||||
const SUPPORTED_LOCALES = ["de", "en", "es", "fr"] as const
|
||||
|
||||
type SupportedLocale = (typeof SUPPORTED_LOCALES)[number]
|
||||
|
||||
function readFirstValue(value: string | string[] | undefined): string | null {
|
||||
if (Array.isArray(value)) {
|
||||
return value[0] ?? null
|
||||
}
|
||||
|
||||
return value ?? null
|
||||
}
|
||||
|
||||
function readInputString(formData: FormData, field: string): string {
|
||||
const value = formData.get(field)
|
||||
return typeof value === "string" ? value.trim() : ""
|
||||
}
|
||||
|
||||
function readNullableString(formData: FormData, field: string): string | undefined {
|
||||
const value = readInputString(formData, field)
|
||||
return value.length > 0 ? value : undefined
|
||||
}
|
||||
|
||||
function normalizeLocale(input: string | null): SupportedLocale {
|
||||
if (input && SUPPORTED_LOCALES.includes(input as SupportedLocale)) {
|
||||
return input as SupportedLocale
|
||||
}
|
||||
|
||||
return "en"
|
||||
}
|
||||
|
||||
function redirectWithState(params: { notice?: string; error?: string }) {
|
||||
const query = new URLSearchParams()
|
||||
|
||||
if (params.notice) {
|
||||
query.set("notice", params.notice)
|
||||
}
|
||||
|
||||
if (params.error) {
|
||||
query.set("error", params.error)
|
||||
}
|
||||
|
||||
const value = query.toString()
|
||||
redirect(value ? `/news?${value}` : "/news")
|
||||
}
|
||||
|
||||
async function createNewsAction(formData: FormData) {
|
||||
"use server"
|
||||
|
||||
await requirePermissionForRoute({
|
||||
nextPath: "/news",
|
||||
permission: "news:write",
|
||||
scope: "team",
|
||||
})
|
||||
|
||||
try {
|
||||
await createPost({
|
||||
title: readInputString(formData, "title"),
|
||||
slug: readInputString(formData, "slug"),
|
||||
excerpt: readNullableString(formData, "excerpt"),
|
||||
body: readInputString(formData, "body"),
|
||||
status: readInputString(formData, "status") === "published" ? "published" : "draft",
|
||||
})
|
||||
} catch {
|
||||
redirectWithState({ error: "Failed to create post." })
|
||||
}
|
||||
|
||||
revalidatePath("/news")
|
||||
revalidatePath("/")
|
||||
redirectWithState({ notice: "Post created." })
|
||||
}
|
||||
|
||||
async function updateNewsAction(formData: FormData) {
|
||||
"use server"
|
||||
|
||||
await requirePermissionForRoute({
|
||||
nextPath: "/news",
|
||||
permission: "news:write",
|
||||
scope: "team",
|
||||
})
|
||||
|
||||
try {
|
||||
await updatePost(readInputString(formData, "id"), {
|
||||
title: readInputString(formData, "title"),
|
||||
slug: readInputString(formData, "slug"),
|
||||
excerpt: readNullableString(formData, "excerpt"),
|
||||
body: readInputString(formData, "body"),
|
||||
status: readInputString(formData, "status") === "published" ? "published" : "draft",
|
||||
})
|
||||
} catch {
|
||||
redirectWithState({ error: "Failed to update post." })
|
||||
}
|
||||
|
||||
revalidatePath("/news")
|
||||
revalidatePath("/")
|
||||
redirectWithState({ notice: "Post updated." })
|
||||
}
|
||||
|
||||
async function deleteNewsAction(formData: FormData) {
|
||||
"use server"
|
||||
|
||||
await requirePermissionForRoute({
|
||||
nextPath: "/news",
|
||||
permission: "news:write",
|
||||
scope: "team",
|
||||
})
|
||||
|
||||
try {
|
||||
await deletePost(readInputString(formData, "id"))
|
||||
} catch {
|
||||
redirectWithState({ error: "Failed to delete post." })
|
||||
}
|
||||
|
||||
revalidatePath("/news")
|
||||
revalidatePath("/")
|
||||
redirectWithState({ notice: "Post deleted." })
|
||||
}
|
||||
|
||||
async function upsertNewsTranslationAction(formData: FormData) {
|
||||
"use server"
|
||||
|
||||
await requirePermissionForRoute({
|
||||
nextPath: "/news",
|
||||
permission: "news:write",
|
||||
scope: "team",
|
||||
})
|
||||
|
||||
const locale = normalizeLocale(readInputString(formData, "locale"))
|
||||
|
||||
try {
|
||||
await upsertPostTranslation({
|
||||
postId: readInputString(formData, "postId"),
|
||||
locale,
|
||||
title: readInputString(formData, "title"),
|
||||
excerpt: readNullableString(formData, "excerpt") ?? null,
|
||||
body: readInputString(formData, "body"),
|
||||
})
|
||||
} catch {
|
||||
redirectWithState({ error: "Failed to save translation." })
|
||||
}
|
||||
|
||||
revalidatePath("/news")
|
||||
revalidatePath("/")
|
||||
redirectWithState({ notice: "Post translation saved." })
|
||||
}
|
||||
|
||||
export default async function NewsManagementPage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<SearchParamsInput>
|
||||
}) {
|
||||
const role = await requirePermissionForRoute({
|
||||
nextPath: "/news",
|
||||
permission: "news:read",
|
||||
scope: "team",
|
||||
})
|
||||
|
||||
const [resolvedSearchParams, posts] = await Promise.all([
|
||||
searchParams,
|
||||
listPostsWithTranslations(),
|
||||
])
|
||||
|
||||
const notice = readFirstValue(resolvedSearchParams.notice)
|
||||
const error = readFirstValue(resolvedSearchParams.error)
|
||||
const selectedLocale = normalizeLocale(readFirstValue(resolvedSearchParams.locale))
|
||||
|
||||
return (
|
||||
<AdminShell
|
||||
role={role}
|
||||
activePath="/news"
|
||||
badge="Admin App"
|
||||
title="News"
|
||||
description="Manage blog/news posts for public updates and announcements archive."
|
||||
>
|
||||
{notice ? (
|
||||
<section className="rounded-xl border border-emerald-300 bg-emerald-50 px-4 py-3 text-sm text-emerald-800">
|
||||
{notice}
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
{error ? (
|
||||
<section className="rounded-xl border border-red-300 bg-red-50 px-4 py-3 text-sm text-red-800">
|
||||
{error}
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
<section className="rounded-xl border border-neutral-200 p-6">
|
||||
<h2 className="text-xl font-medium">Create Post</h2>
|
||||
<form action={createNewsAction} className="mt-4 space-y-3">
|
||||
<div className="grid gap-3 md:grid-cols-2">
|
||||
<label className="space-y-1">
|
||||
<span className="text-xs text-neutral-600">Title</span>
|
||||
<input
|
||||
name="title"
|
||||
required
|
||||
className="w-full rounded border border-neutral-300 px-3 py-2 text-sm"
|
||||
/>
|
||||
</label>
|
||||
<label className="space-y-1">
|
||||
<span className="text-xs text-neutral-600">Slug</span>
|
||||
<input
|
||||
name="slug"
|
||||
required
|
||||
className="w-full rounded border border-neutral-300 px-3 py-2 text-sm"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<label className="space-y-1">
|
||||
<span className="text-xs text-neutral-600">Excerpt</span>
|
||||
<input
|
||||
name="excerpt"
|
||||
className="w-full rounded border border-neutral-300 px-3 py-2 text-sm"
|
||||
/>
|
||||
</label>
|
||||
<label className="space-y-1">
|
||||
<span className="text-xs text-neutral-600">Body</span>
|
||||
<textarea
|
||||
name="body"
|
||||
rows={5}
|
||||
required
|
||||
className="w-full rounded border border-neutral-300 px-3 py-2 text-sm"
|
||||
/>
|
||||
</label>
|
||||
<label className="space-y-1">
|
||||
<span className="text-xs text-neutral-600">Status</span>
|
||||
<select
|
||||
name="status"
|
||||
defaultValue="draft"
|
||||
className="w-full rounded border border-neutral-300 px-3 py-2 text-sm"
|
||||
>
|
||||
<option value="draft">draft</option>
|
||||
<option value="published">published</option>
|
||||
</select>
|
||||
</label>
|
||||
<Button type="submit">Create post</Button>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section className="space-y-3">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{SUPPORTED_LOCALES.map((locale) => (
|
||||
<a
|
||||
key={locale}
|
||||
href={`/news?locale=${locale}`}
|
||||
className={`inline-flex rounded border px-3 py-1.5 text-xs ${
|
||||
selectedLocale === locale
|
||||
? "border-neutral-800 bg-neutral-900 text-white"
|
||||
: "border-neutral-300 text-neutral-700"
|
||||
}`}
|
||||
>
|
||||
{locale.toUpperCase()}
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{posts.map((post) => {
|
||||
const translation = post.translations.find((entry) => entry.locale === selectedLocale)
|
||||
|
||||
return (
|
||||
<div key={post.id} className="rounded-xl border border-neutral-200 p-6">
|
||||
<form action={updateNewsAction}>
|
||||
<input type="hidden" name="id" value={post.id} />
|
||||
<div className="grid gap-3 md:grid-cols-2">
|
||||
<label className="space-y-1">
|
||||
<span className="text-xs text-neutral-600">Title</span>
|
||||
<input
|
||||
name="title"
|
||||
defaultValue={post.title}
|
||||
className="w-full rounded border border-neutral-300 px-3 py-2 text-sm"
|
||||
/>
|
||||
</label>
|
||||
<label className="space-y-1">
|
||||
<span className="text-xs text-neutral-600">Slug</span>
|
||||
<input
|
||||
name="slug"
|
||||
defaultValue={post.slug}
|
||||
className="w-full rounded border border-neutral-300 px-3 py-2 text-sm"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<label className="mt-3 block space-y-1">
|
||||
<span className="text-xs text-neutral-600">Excerpt</span>
|
||||
<input
|
||||
name="excerpt"
|
||||
defaultValue={post.excerpt ?? ""}
|
||||
className="w-full rounded border border-neutral-300 px-3 py-2 text-sm"
|
||||
/>
|
||||
</label>
|
||||
<label className="mt-3 block space-y-1">
|
||||
<span className="text-xs text-neutral-600">Body</span>
|
||||
<textarea
|
||||
name="body"
|
||||
rows={4}
|
||||
defaultValue={post.body}
|
||||
className="w-full rounded border border-neutral-300 px-3 py-2 text-sm"
|
||||
/>
|
||||
</label>
|
||||
<div className="mt-3 flex flex-wrap items-center justify-between gap-3">
|
||||
<select
|
||||
name="status"
|
||||
defaultValue={post.status}
|
||||
className="rounded border border-neutral-300 px-3 py-2 text-sm"
|
||||
>
|
||||
<option value="draft">draft</option>
|
||||
<option value="published">published</option>
|
||||
</select>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button type="submit" size="sm">
|
||||
Save
|
||||
</Button>
|
||||
<button
|
||||
type="submit"
|
||||
formAction={deleteNewsAction}
|
||||
className="rounded-md border border-red-300 px-3 py-2 text-sm text-red-700"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<form
|
||||
action={upsertNewsTranslationAction}
|
||||
className="mt-4 rounded-lg border border-neutral-200 p-4"
|
||||
>
|
||||
<input type="hidden" name="postId" value={post.id} />
|
||||
<input type="hidden" name="locale" value={selectedLocale} />
|
||||
|
||||
<h3 className="text-sm font-medium">
|
||||
Translation ({selectedLocale.toUpperCase()})
|
||||
</h3>
|
||||
<p className="mt-1 text-xs text-neutral-600">
|
||||
Missing fields fall back to base post content on public pages.
|
||||
</p>
|
||||
{post.translations.length > 0 ? (
|
||||
<p className="mt-2 text-xs text-neutral-600">
|
||||
Saved locales:{" "}
|
||||
{post.translations.map((entry) => entry.locale.toUpperCase()).join(", ")}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
<div className="mt-3 grid gap-3 md:grid-cols-2">
|
||||
<label className="space-y-1">
|
||||
<span className="text-xs text-neutral-600">Title</span>
|
||||
<input
|
||||
name="title"
|
||||
defaultValue={translation?.title ?? post.title}
|
||||
className="w-full rounded border border-neutral-300 px-3 py-2 text-sm"
|
||||
/>
|
||||
</label>
|
||||
<label className="space-y-1">
|
||||
<span className="text-xs text-neutral-600">Excerpt</span>
|
||||
<input
|
||||
name="excerpt"
|
||||
defaultValue={translation?.excerpt ?? post.excerpt ?? ""}
|
||||
className="w-full rounded border border-neutral-300 px-3 py-2 text-sm"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<label className="mt-3 block space-y-1">
|
||||
<span className="text-xs text-neutral-600">Body</span>
|
||||
<textarea
|
||||
name="body"
|
||||
rows={4}
|
||||
defaultValue={translation?.body ?? post.body}
|
||||
className="w-full rounded border border-neutral-300 px-3 py-2 text-sm"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<div className="mt-3">
|
||||
<Button type="submit" size="sm">
|
||||
Save translation
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</section>
|
||||
</AdminShell>
|
||||
)
|
||||
}
|
||||
@@ -1,54 +1,389 @@
|
||||
import { hasPermission } from "@cms/content/rbac"
|
||||
import { listPosts } from "@cms/db"
|
||||
import { createPost, deletePost, listPosts, updatePost } from "@cms/db"
|
||||
import { Button } from "@cms/ui/button"
|
||||
import { revalidatePath } from "next/cache"
|
||||
import Link from "next/link"
|
||||
import { redirect } from "next/navigation"
|
||||
|
||||
import { resolveRoleFromServerContext } from "@/lib/access-server"
|
||||
import { LogoutButton } from "./logout-button"
|
||||
import { AdminShell } from "@/components/admin-shell"
|
||||
import { translateMessage } from "@/i18n/messages"
|
||||
import { getAdminMessages, resolveAdminLocale } from "@/i18n/server"
|
||||
import { requirePermissionForRoute } from "@/lib/route-guards"
|
||||
|
||||
export const dynamic = "force-dynamic"
|
||||
|
||||
export default async function AdminHomePage() {
|
||||
const role = await resolveRoleFromServerContext()
|
||||
type SearchParamsInput = Record<string, string | string[] | undefined>
|
||||
|
||||
if (!role) {
|
||||
redirect("/login?next=/")
|
||||
function readFirstValue(value: string | string[] | undefined): string | null {
|
||||
if (Array.isArray(value)) {
|
||||
return value[0] ?? null
|
||||
}
|
||||
|
||||
if (!hasPermission(role, "news:read", "team")) {
|
||||
redirect("/unauthorized?required=news:read&scope=team")
|
||||
return value ?? null
|
||||
}
|
||||
|
||||
function readRequiredField(formData: FormData, field: string): string {
|
||||
const value = formData.get(field)
|
||||
|
||||
if (typeof value !== "string") {
|
||||
return ""
|
||||
}
|
||||
|
||||
return value.trim()
|
||||
}
|
||||
|
||||
function readOptionalField(formData: FormData, field: string): string | undefined {
|
||||
const value = readRequiredField(formData, field)
|
||||
return value.length > 0 ? value : undefined
|
||||
}
|
||||
|
||||
async function requireNewsWritePermission() {
|
||||
await requirePermissionForRoute({
|
||||
nextPath: "/",
|
||||
permission: "news:write",
|
||||
scope: "team",
|
||||
})
|
||||
}
|
||||
|
||||
function redirectWithState(params: { notice?: string; error?: string }) {
|
||||
const query = new URLSearchParams()
|
||||
|
||||
if (params.notice) {
|
||||
query.set("notice", params.notice)
|
||||
}
|
||||
|
||||
if (params.error) {
|
||||
query.set("error", params.error)
|
||||
}
|
||||
|
||||
const value = query.toString()
|
||||
redirect(value ? `/?${value}` : "/")
|
||||
}
|
||||
|
||||
async function getDashboardTranslator() {
|
||||
const locale = await resolveAdminLocale()
|
||||
const messages = await getAdminMessages(locale)
|
||||
|
||||
return (key: string, fallback: string) => translateMessage(messages, key, fallback)
|
||||
}
|
||||
|
||||
async function createPostAction(formData: FormData) {
|
||||
"use server"
|
||||
|
||||
await requireNewsWritePermission()
|
||||
const t = await getDashboardTranslator()
|
||||
|
||||
const status = readRequiredField(formData, "status")
|
||||
|
||||
try {
|
||||
await createPost({
|
||||
title: readRequiredField(formData, "title"),
|
||||
slug: readRequiredField(formData, "slug"),
|
||||
excerpt: readOptionalField(formData, "excerpt"),
|
||||
body: readRequiredField(formData, "body"),
|
||||
status: status === "published" ? "published" : "draft",
|
||||
})
|
||||
} catch {
|
||||
redirectWithState({
|
||||
error: t("dashboard.posts.errors.createFailed", "Create failed. Please check your input."),
|
||||
})
|
||||
}
|
||||
|
||||
revalidatePath("/")
|
||||
redirectWithState({ notice: t("dashboard.posts.success.created", "Post created.") })
|
||||
}
|
||||
|
||||
async function updatePostAction(formData: FormData) {
|
||||
"use server"
|
||||
|
||||
await requireNewsWritePermission()
|
||||
const t = await getDashboardTranslator()
|
||||
|
||||
const id = readRequiredField(formData, "id")
|
||||
const status = readRequiredField(formData, "status")
|
||||
|
||||
if (!id) {
|
||||
redirectWithState({
|
||||
error: t("dashboard.posts.errors.updateMissingId", "Update failed. Missing post id."),
|
||||
})
|
||||
}
|
||||
|
||||
try {
|
||||
await updatePost(id, {
|
||||
title: readRequiredField(formData, "title"),
|
||||
slug: readRequiredField(formData, "slug"),
|
||||
excerpt: readOptionalField(formData, "excerpt"),
|
||||
body: readRequiredField(formData, "body"),
|
||||
status: status === "published" ? "published" : "draft",
|
||||
})
|
||||
} catch {
|
||||
redirectWithState({
|
||||
error: t("dashboard.posts.errors.updateFailed", "Update failed. Please check your input."),
|
||||
})
|
||||
}
|
||||
|
||||
revalidatePath("/")
|
||||
redirectWithState({ notice: t("dashboard.posts.success.updated", "Post updated.") })
|
||||
}
|
||||
|
||||
async function deletePostAction(formData: FormData) {
|
||||
"use server"
|
||||
|
||||
await requireNewsWritePermission()
|
||||
const t = await getDashboardTranslator()
|
||||
|
||||
const id = readRequiredField(formData, "id")
|
||||
|
||||
if (!id) {
|
||||
redirectWithState({
|
||||
error: t("dashboard.posts.errors.deleteMissingId", "Delete failed. Missing post id."),
|
||||
})
|
||||
}
|
||||
|
||||
try {
|
||||
await deletePost(id)
|
||||
} catch {
|
||||
redirectWithState({ error: t("dashboard.posts.errors.deleteFailed", "Delete failed.") })
|
||||
}
|
||||
|
||||
revalidatePath("/")
|
||||
redirectWithState({ notice: t("dashboard.posts.success.deleted", "Post deleted.") })
|
||||
}
|
||||
|
||||
export default async function AdminHomePage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<SearchParamsInput>
|
||||
}) {
|
||||
const role = await requirePermissionForRoute({
|
||||
nextPath: "/",
|
||||
permission: "news:read",
|
||||
scope: "team",
|
||||
})
|
||||
|
||||
const [resolvedSearchParams, locale, posts] = await Promise.all([
|
||||
searchParams,
|
||||
resolveAdminLocale(),
|
||||
listPosts(),
|
||||
])
|
||||
const messages = await getAdminMessages(locale)
|
||||
const t = (key: string, fallback: string) => translateMessage(messages, key, fallback)
|
||||
|
||||
const notice = readFirstValue(resolvedSearchParams.notice)
|
||||
const error = readFirstValue(resolvedSearchParams.error)
|
||||
const canCreatePost = hasPermission(role, "news:write", "team")
|
||||
const posts = await listPosts()
|
||||
|
||||
return (
|
||||
<main className="mx-auto flex min-h-screen w-full max-w-4xl flex-col gap-8 px-6 py-16">
|
||||
<header className="space-y-3">
|
||||
<p className="text-sm uppercase tracking-[0.2em] text-neutral-500">Admin App</p>
|
||||
<h1 className="text-4xl font-semibold tracking-tight">Content Dashboard</h1>
|
||||
<p className="text-neutral-600">Manage posts from a dedicated admin surface.</p>
|
||||
<div className="flex items-center gap-3 pt-2">
|
||||
<AdminShell
|
||||
role={role}
|
||||
activePath="/"
|
||||
badge={t("dashboard.badge", "Admin App")}
|
||||
title={t("dashboard.title", "Content Dashboard")}
|
||||
description={t("dashboard.description", "Manage posts from a dedicated admin surface.")}
|
||||
actions={
|
||||
<>
|
||||
<Link
|
||||
href="/todo"
|
||||
className="inline-flex rounded-md border border-neutral-300 px-4 py-2 text-sm font-medium hover:bg-neutral-100"
|
||||
>
|
||||
Open roadmap and progress
|
||||
{t("dashboard.actions.openRoadmap", "Open roadmap and progress")}
|
||||
</Link>
|
||||
<LogoutButton />
|
||||
</div>
|
||||
</header>
|
||||
<Link
|
||||
href="/settings"
|
||||
className="inline-flex rounded-md border border-neutral-300 px-4 py-2 text-sm font-medium hover:bg-neutral-100"
|
||||
>
|
||||
{t("settings.title", "Settings")}
|
||||
</Link>
|
||||
</>
|
||||
}
|
||||
>
|
||||
{notice ? (
|
||||
<section className="rounded-xl border border-emerald-300 bg-emerald-50 px-4 py-3 text-sm text-emerald-800">
|
||||
{notice}
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
{error ? (
|
||||
<section className="rounded-xl border border-red-300 bg-red-50 px-4 py-3 text-sm text-red-800">
|
||||
{error}
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
<section className="rounded-xl border border-neutral-200 p-6">
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<h2 className="text-xl font-medium">Posts</h2>
|
||||
<Button disabled={!canCreatePost}>Create post</Button>
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-xl font-medium">
|
||||
{t("dashboard.posts.title", "Posts CRUD Sandbox")}
|
||||
</h2>
|
||||
<p className="text-xs uppercase tracking-wide text-neutral-500">
|
||||
{t("dashboard.notices.crudSandboxTag", "MVP0 functional test")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{canCreatePost ? (
|
||||
<form
|
||||
action={createPostAction}
|
||||
className="space-y-3 rounded-lg border border-neutral-200 p-4"
|
||||
>
|
||||
<h3 className="text-sm font-semibold">
|
||||
{t("dashboard.posts.createTitle", "Create post")}
|
||||
</h3>
|
||||
<div className="grid gap-3 md:grid-cols-2">
|
||||
<label className="space-y-1">
|
||||
<span className="text-xs text-neutral-600">
|
||||
{t("dashboard.posts.fields.title", "Title")}
|
||||
</span>
|
||||
<input
|
||||
name="title"
|
||||
required
|
||||
minLength={3}
|
||||
className="w-full rounded border border-neutral-300 px-3 py-2 text-sm"
|
||||
/>
|
||||
</label>
|
||||
<label className="space-y-1">
|
||||
<span className="text-xs text-neutral-600">
|
||||
{t("dashboard.posts.fields.slug", "Slug")}
|
||||
</span>
|
||||
<input
|
||||
name="slug"
|
||||
required
|
||||
minLength={3}
|
||||
className="w-full rounded border border-neutral-300 px-3 py-2 text-sm"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<label className="space-y-1">
|
||||
<span className="text-xs text-neutral-600">
|
||||
{t("dashboard.posts.fields.excerpt", "Excerpt")}
|
||||
</span>
|
||||
<input
|
||||
name="excerpt"
|
||||
className="w-full rounded border border-neutral-300 px-3 py-2 text-sm"
|
||||
/>
|
||||
</label>
|
||||
<label className="space-y-1">
|
||||
<span className="text-xs text-neutral-600">
|
||||
{t("dashboard.posts.fields.body", "Body")}
|
||||
</span>
|
||||
<textarea
|
||||
name="body"
|
||||
required
|
||||
minLength={1}
|
||||
rows={4}
|
||||
className="w-full rounded border border-neutral-300 px-3 py-2 text-sm"
|
||||
/>
|
||||
</label>
|
||||
<label className="space-y-1">
|
||||
<span className="text-xs text-neutral-600">
|
||||
{t("dashboard.posts.fields.status", "Status")}
|
||||
</span>
|
||||
<select
|
||||
name="status"
|
||||
defaultValue="draft"
|
||||
className="w-full rounded border border-neutral-300 px-3 py-2 text-sm"
|
||||
>
|
||||
<option value="draft">{t("dashboard.posts.status.draft", "Draft")}</option>
|
||||
<option value="published">
|
||||
{t("dashboard.posts.status.published", "Published")}
|
||||
</option>
|
||||
</select>
|
||||
</label>
|
||||
<Button type="submit">{t("dashboard.posts.actions.create", "Create post")}</Button>
|
||||
</form>
|
||||
) : (
|
||||
<div className="rounded-lg border border-amber-300 bg-amber-50 px-4 py-3 text-sm text-amber-800">
|
||||
{t(
|
||||
"dashboard.notices.noCrudPermission",
|
||||
"You can read posts, but your role cannot create/update/delete posts.",
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
{posts.map((post) => (
|
||||
<article key={post.id} className="rounded-lg border border-neutral-200 p-4">
|
||||
{canCreatePost ? (
|
||||
<>
|
||||
<form action={updatePostAction} className="space-y-3">
|
||||
<input type="hidden" name="id" value={post.id} />
|
||||
<div className="grid gap-3 md:grid-cols-2">
|
||||
<label className="space-y-1">
|
||||
<span className="text-xs text-neutral-600">
|
||||
{t("dashboard.posts.fields.title", "Title")}
|
||||
</span>
|
||||
<input
|
||||
name="title"
|
||||
required
|
||||
minLength={3}
|
||||
defaultValue={post.title}
|
||||
className="w-full rounded border border-neutral-300 px-3 py-2 text-sm"
|
||||
/>
|
||||
</label>
|
||||
<label className="space-y-1">
|
||||
<span className="text-xs text-neutral-600">
|
||||
{t("dashboard.posts.fields.slug", "Slug")}
|
||||
</span>
|
||||
<input
|
||||
name="slug"
|
||||
required
|
||||
minLength={3}
|
||||
defaultValue={post.slug}
|
||||
className="w-full rounded border border-neutral-300 px-3 py-2 text-sm"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<label className="space-y-1">
|
||||
<span className="text-xs text-neutral-600">
|
||||
{t("dashboard.posts.fields.excerpt", "Excerpt")}
|
||||
</span>
|
||||
<input
|
||||
name="excerpt"
|
||||
defaultValue={post.excerpt ?? ""}
|
||||
className="w-full rounded border border-neutral-300 px-3 py-2 text-sm"
|
||||
/>
|
||||
</label>
|
||||
<label className="space-y-1">
|
||||
<span className="text-xs text-neutral-600">
|
||||
{t("dashboard.posts.fields.body", "Body")}
|
||||
</span>
|
||||
<textarea
|
||||
name="body"
|
||||
required
|
||||
minLength={1}
|
||||
rows={4}
|
||||
defaultValue={post.body}
|
||||
className="w-full rounded border border-neutral-300 px-3 py-2 text-sm"
|
||||
/>
|
||||
</label>
|
||||
<label className="space-y-1">
|
||||
<span className="text-xs text-neutral-600">
|
||||
{t("dashboard.posts.fields.status", "Status")}
|
||||
</span>
|
||||
<select
|
||||
name="status"
|
||||
defaultValue={post.status}
|
||||
className="w-full rounded border border-neutral-300 px-3 py-2 text-sm"
|
||||
>
|
||||
<option value="draft">{t("dashboard.posts.status.draft", "Draft")}</option>
|
||||
<option value="published">
|
||||
{t("dashboard.posts.status.published", "Published")}
|
||||
</option>
|
||||
</select>
|
||||
</label>
|
||||
<Button type="submit">
|
||||
{t("dashboard.posts.actions.save", "Save changes")}
|
||||
</Button>
|
||||
</form>
|
||||
<form action={deletePostAction} className="mt-3">
|
||||
<input type="hidden" name="id" value={post.id} />
|
||||
<Button type="submit" variant="secondary">
|
||||
{t("dashboard.posts.actions.delete", "Delete")}
|
||||
</Button>
|
||||
</form>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<h3 className="text-lg font-medium">{post.title}</h3>
|
||||
<span className="rounded-full bg-neutral-100 px-3 py-1 text-xs uppercase tracking-wide">
|
||||
@@ -56,10 +391,15 @@ export default async function AdminHomePage() {
|
||||
</span>
|
||||
</div>
|
||||
<p className="mt-2 text-sm text-neutral-600">{post.slug}</p>
|
||||
<p className="mt-2 text-sm text-neutral-600">
|
||||
{post.excerpt ?? t("dashboard.posts.fallback.noExcerpt", "No excerpt")}
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
</AdminShell>
|
||||
)
|
||||
}
|
||||
|
||||
405
apps/admin/src/app/pages/[id]/page.tsx
Normal file
405
apps/admin/src/app/pages/[id]/page.tsx
Normal file
@@ -0,0 +1,405 @@
|
||||
import {
|
||||
deletePage,
|
||||
getPageById,
|
||||
listPageTranslations,
|
||||
updatePage,
|
||||
upsertPageTranslation,
|
||||
} from "@cms/db"
|
||||
import { Button } from "@cms/ui/button"
|
||||
import Link from "next/link"
|
||||
import { redirect } from "next/navigation"
|
||||
|
||||
import { AdminShell } from "@/components/admin-shell"
|
||||
import { PageBlockEditor } from "@/components/pages/page-block-editor"
|
||||
import { requirePermissionForRoute } from "@/lib/route-guards"
|
||||
|
||||
export const dynamic = "force-dynamic"
|
||||
|
||||
type SearchParamsInput = Record<string, string | string[] | undefined>
|
||||
const SUPPORTED_LOCALES = ["de", "en", "es", "fr"] as const
|
||||
type SupportedLocale = (typeof SUPPORTED_LOCALES)[number]
|
||||
|
||||
type PageProps = {
|
||||
params: Promise<{ id: string }>
|
||||
searchParams: Promise<SearchParamsInput>
|
||||
}
|
||||
|
||||
function readFirstValue(value: string | string[] | undefined): string | null {
|
||||
if (Array.isArray(value)) {
|
||||
return value[0] ?? null
|
||||
}
|
||||
|
||||
return value ?? null
|
||||
}
|
||||
|
||||
function readInputString(formData: FormData, field: string): string {
|
||||
const value = formData.get(field)
|
||||
return typeof value === "string" ? value.trim() : ""
|
||||
}
|
||||
|
||||
function readNullableString(formData: FormData, field: string): string | null {
|
||||
const value = readInputString(formData, field)
|
||||
return value.length > 0 ? value : null
|
||||
}
|
||||
|
||||
function redirectWithState(pageId: string, params: { notice?: string; error?: string }) {
|
||||
const query = new URLSearchParams()
|
||||
|
||||
if (params.notice) {
|
||||
query.set("notice", params.notice)
|
||||
}
|
||||
|
||||
if (params.error) {
|
||||
query.set("error", params.error)
|
||||
}
|
||||
|
||||
const value = query.toString()
|
||||
redirect(value ? `/pages/${pageId}?${value}` : `/pages/${pageId}`)
|
||||
}
|
||||
|
||||
function normalizeLocale(input: string | null): SupportedLocale {
|
||||
if (input && SUPPORTED_LOCALES.includes(input as SupportedLocale)) {
|
||||
return input as SupportedLocale
|
||||
}
|
||||
|
||||
return "en"
|
||||
}
|
||||
|
||||
export default async function PageEditorPage({ params, searchParams }: PageProps) {
|
||||
const role = await requirePermissionForRoute({
|
||||
nextPath: "/pages",
|
||||
permission: "pages:read",
|
||||
scope: "team",
|
||||
})
|
||||
const resolvedParams = await params
|
||||
const pageId = resolvedParams.id
|
||||
|
||||
const [resolvedSearchParams, pageRecord, translations] = await Promise.all([
|
||||
searchParams,
|
||||
getPageById(pageId),
|
||||
listPageTranslations(pageId),
|
||||
])
|
||||
|
||||
if (!pageRecord) {
|
||||
redirect("/pages?error=Page+not+found")
|
||||
}
|
||||
|
||||
const page = pageRecord
|
||||
const notice = readFirstValue(resolvedSearchParams.notice)
|
||||
const error = readFirstValue(resolvedSearchParams.error)
|
||||
const selectedLocale = normalizeLocale(readFirstValue(resolvedSearchParams.locale))
|
||||
const selectedTranslation = translations.find((entry) => entry.locale === selectedLocale)
|
||||
|
||||
async function updatePageAction(formData: FormData) {
|
||||
"use server"
|
||||
|
||||
await requirePermissionForRoute({
|
||||
nextPath: "/pages",
|
||||
permission: "pages:write",
|
||||
scope: "team",
|
||||
})
|
||||
|
||||
try {
|
||||
await updatePage({
|
||||
id: pageId,
|
||||
title: readInputString(formData, "title"),
|
||||
slug: readInputString(formData, "slug"),
|
||||
status: readInputString(formData, "status"),
|
||||
summary: readNullableString(formData, "summary"),
|
||||
content: readInputString(formData, "content"),
|
||||
seoTitle: readNullableString(formData, "seoTitle"),
|
||||
seoDescription: readNullableString(formData, "seoDescription"),
|
||||
})
|
||||
} catch {
|
||||
redirectWithState(pageId, {
|
||||
error: "Failed to update page. Validate values and try again.",
|
||||
})
|
||||
}
|
||||
|
||||
redirectWithState(pageId, {
|
||||
notice: "Page updated.",
|
||||
})
|
||||
}
|
||||
|
||||
async function deletePageAction() {
|
||||
"use server"
|
||||
|
||||
await requirePermissionForRoute({
|
||||
nextPath: "/pages",
|
||||
permission: "pages:write",
|
||||
scope: "team",
|
||||
})
|
||||
|
||||
try {
|
||||
await deletePage(pageId)
|
||||
} catch {
|
||||
redirectWithState(pageId, {
|
||||
error: "Failed to delete page. Remove linked navigation references first.",
|
||||
})
|
||||
}
|
||||
|
||||
redirect("/pages?notice=Page+deleted")
|
||||
}
|
||||
|
||||
async function upsertPageTranslationAction(formData: FormData) {
|
||||
"use server"
|
||||
|
||||
await requirePermissionForRoute({
|
||||
nextPath: "/pages",
|
||||
permission: "pages:write",
|
||||
scope: "team",
|
||||
})
|
||||
|
||||
const locale = normalizeLocale(readInputString(formData, "locale"))
|
||||
|
||||
try {
|
||||
await upsertPageTranslation({
|
||||
pageId,
|
||||
locale,
|
||||
title: readInputString(formData, "title"),
|
||||
summary: readNullableString(formData, "summary"),
|
||||
content: readInputString(formData, "content"),
|
||||
seoTitle: readNullableString(formData, "seoTitle"),
|
||||
seoDescription: readNullableString(formData, "seoDescription"),
|
||||
})
|
||||
} catch {
|
||||
redirect(`/pages/${pageId}?error=Failed+to+save+translation.&locale=${locale}`)
|
||||
}
|
||||
|
||||
redirect(`/pages/${pageId}?notice=Translation+saved.&locale=${locale}`)
|
||||
}
|
||||
|
||||
return (
|
||||
<AdminShell
|
||||
role={role}
|
||||
activePath="/pages"
|
||||
badge="Admin App"
|
||||
title="Page Editor"
|
||||
description="Edit page metadata, content, and publication status."
|
||||
>
|
||||
{notice ? (
|
||||
<section className="rounded-xl border border-emerald-300 bg-emerald-50 px-4 py-3 text-sm text-emerald-800">
|
||||
{notice}
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
{error ? (
|
||||
<section className="rounded-xl border border-red-300 bg-red-50 px-4 py-3 text-sm text-red-800">
|
||||
{error}
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
<section className="rounded-xl border border-neutral-200 p-6">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<h2 className="text-xl font-medium">{page.title}</h2>
|
||||
<p className="mt-1 text-xs text-neutral-600">ID: {page.id}</p>
|
||||
</div>
|
||||
<Link href="/pages" className="text-sm text-neutral-700 underline underline-offset-2">
|
||||
Back to pages
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<form action={updatePageAction} className="mt-6 space-y-3">
|
||||
<div className="grid gap-3 md:grid-cols-3">
|
||||
<label className="space-y-1 md:col-span-2">
|
||||
<span className="text-xs text-neutral-600">Title</span>
|
||||
<input
|
||||
name="title"
|
||||
defaultValue={page.title}
|
||||
required
|
||||
className="w-full rounded border border-neutral-300 px-3 py-2 text-sm"
|
||||
/>
|
||||
</label>
|
||||
<label className="space-y-1">
|
||||
<span className="text-xs text-neutral-600">Status</span>
|
||||
<select
|
||||
name="status"
|
||||
defaultValue={page.status}
|
||||
className="w-full rounded border border-neutral-300 px-3 py-2 text-sm"
|
||||
>
|
||||
<option value="draft">draft</option>
|
||||
<option value="published">published</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<label className="space-y-1">
|
||||
<span className="text-xs text-neutral-600">Slug</span>
|
||||
<input
|
||||
name="slug"
|
||||
defaultValue={page.slug}
|
||||
required
|
||||
className="w-full rounded border border-neutral-300 px-3 py-2 text-sm"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="space-y-1">
|
||||
<span className="text-xs text-neutral-600">Summary</span>
|
||||
<input
|
||||
name="summary"
|
||||
defaultValue={page.summary ?? ""}
|
||||
className="w-full rounded border border-neutral-300 px-3 py-2 text-sm"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<PageBlockEditor name="content" initialContent={page.content} />
|
||||
|
||||
<div className="grid gap-3 md:grid-cols-2">
|
||||
<label className="space-y-1">
|
||||
<span className="text-xs text-neutral-600">SEO title</span>
|
||||
<input
|
||||
name="seoTitle"
|
||||
defaultValue={page.seoTitle ?? ""}
|
||||
className="w-full rounded border border-neutral-300 px-3 py-2 text-sm"
|
||||
/>
|
||||
</label>
|
||||
<label className="space-y-1">
|
||||
<span className="text-xs text-neutral-600">SEO description</span>
|
||||
<input
|
||||
name="seoDescription"
|
||||
defaultValue={page.seoDescription ?? ""}
|
||||
className="w-full rounded border border-neutral-300 px-3 py-2 text-sm"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<Button type="submit">Save page</Button>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section className="rounded-xl border border-neutral-200 p-6">
|
||||
<div className="space-y-1">
|
||||
<h3 className="text-xl font-medium">Translations</h3>
|
||||
<p className="text-sm text-neutral-600">
|
||||
Add locale-specific page content. Missing locales fall back to base page fields.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex flex-wrap gap-2">
|
||||
{SUPPORTED_LOCALES.map((locale) => {
|
||||
const isActive = locale === selectedLocale
|
||||
const hasTranslation = translations.some((entry) => entry.locale === locale)
|
||||
|
||||
return (
|
||||
<Link
|
||||
key={locale}
|
||||
href={`/pages/${pageId}?locale=${locale}`}
|
||||
className={`inline-flex items-center gap-2 rounded border px-3 py-1.5 text-xs ${
|
||||
isActive
|
||||
? "border-neutral-800 bg-neutral-900 text-white"
|
||||
: "border-neutral-300 text-neutral-700"
|
||||
}`}
|
||||
>
|
||||
<span>{locale.toUpperCase()}</span>
|
||||
<span className={isActive ? "text-neutral-200" : "text-neutral-500"}>
|
||||
{hasTranslation ? "saved" : "missing"}
|
||||
</span>
|
||||
</Link>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{translations.length > 0 ? (
|
||||
<div className="mt-4 rounded border border-neutral-200">
|
||||
<table className="min-w-full text-left text-sm">
|
||||
<thead className="text-xs uppercase tracking-wide text-neutral-500">
|
||||
<tr>
|
||||
<th className="px-3 py-2">Locale</th>
|
||||
<th className="px-3 py-2">Title</th>
|
||||
<th className="px-3 py-2">Updated</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{translations.map((translation) => (
|
||||
<tr key={translation.id} className="border-t border-neutral-200">
|
||||
<td className="px-3 py-2">{translation.locale.toUpperCase()}</td>
|
||||
<td className="px-3 py-2">{translation.title}</td>
|
||||
<td className="px-3 py-2 text-neutral-600">
|
||||
{translation.updatedAt.toLocaleDateString("en-US")}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<form action={upsertPageTranslationAction} className="mt-6 space-y-3">
|
||||
<label className="space-y-1">
|
||||
<span className="text-xs text-neutral-600">Locale</span>
|
||||
<select
|
||||
name="locale"
|
||||
defaultValue={selectedLocale}
|
||||
className="w-full rounded border border-neutral-300 px-3 py-2 text-sm"
|
||||
>
|
||||
{SUPPORTED_LOCALES.map((locale) => (
|
||||
<option key={locale} value={locale}>
|
||||
{locale.toUpperCase()}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label className="space-y-1">
|
||||
<span className="text-xs text-neutral-600">Title</span>
|
||||
<input
|
||||
name="title"
|
||||
defaultValue={selectedTranslation?.title ?? page.title}
|
||||
required
|
||||
className="w-full rounded border border-neutral-300 px-3 py-2 text-sm"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="space-y-1">
|
||||
<span className="text-xs text-neutral-600">Summary</span>
|
||||
<input
|
||||
name="summary"
|
||||
defaultValue={selectedTranslation?.summary ?? page.summary ?? ""}
|
||||
className="w-full rounded border border-neutral-300 px-3 py-2 text-sm"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<PageBlockEditor
|
||||
name="content"
|
||||
initialContent={selectedTranslation?.content ?? page.content}
|
||||
label="Translation Blocks"
|
||||
/>
|
||||
|
||||
<div className="grid gap-3 md:grid-cols-2">
|
||||
<label className="space-y-1">
|
||||
<span className="text-xs text-neutral-600">SEO title</span>
|
||||
<input
|
||||
name="seoTitle"
|
||||
defaultValue={selectedTranslation?.seoTitle ?? page.seoTitle ?? ""}
|
||||
className="w-full rounded border border-neutral-300 px-3 py-2 text-sm"
|
||||
/>
|
||||
</label>
|
||||
<label className="space-y-1">
|
||||
<span className="text-xs text-neutral-600">SEO description</span>
|
||||
<input
|
||||
name="seoDescription"
|
||||
defaultValue={selectedTranslation?.seoDescription ?? page.seoDescription ?? ""}
|
||||
className="w-full rounded border border-neutral-300 px-3 py-2 text-sm"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<Button type="submit">Save translation</Button>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section className="rounded-xl border border-red-300 bg-red-50 p-6">
|
||||
<h3 className="text-lg font-medium text-red-800">Danger Zone</h3>
|
||||
<p className="mt-1 text-sm text-red-700">
|
||||
Deleting this page is permanent and may break linked navigation items.
|
||||
</p>
|
||||
<form action={deletePageAction} className="mt-4">
|
||||
<Button type="submit" variant="secondary" className="border border-red-300 text-red-800">
|
||||
Delete page
|
||||
</Button>
|
||||
</form>
|
||||
</section>
|
||||
</AdminShell>
|
||||
)
|
||||
}
|
||||
162
apps/admin/src/app/pages/page.tsx
Normal file
162
apps/admin/src/app/pages/page.tsx
Normal file
@@ -0,0 +1,162 @@
|
||||
import { createPage, listPages } from "@cms/db"
|
||||
import { revalidatePath } from "next/cache"
|
||||
import Link from "next/link"
|
||||
import { redirect } from "next/navigation"
|
||||
|
||||
import { AdminShell } from "@/components/admin-shell"
|
||||
import { CreatePageForm } from "@/components/pages/create-page-form"
|
||||
import { requirePermissionForRoute } from "@/lib/route-guards"
|
||||
|
||||
export const dynamic = "force-dynamic"
|
||||
|
||||
type SearchParamsInput = Record<string, string | string[] | undefined>
|
||||
|
||||
function readFirstValue(value: string | string[] | undefined): string | null {
|
||||
if (Array.isArray(value)) {
|
||||
return value[0] ?? null
|
||||
}
|
||||
|
||||
return value ?? null
|
||||
}
|
||||
|
||||
function readInputString(formData: FormData, field: string): string {
|
||||
const value = formData.get(field)
|
||||
return typeof value === "string" ? value.trim() : ""
|
||||
}
|
||||
|
||||
function readNullableString(formData: FormData, field: string): string | null {
|
||||
const value = readInputString(formData, field)
|
||||
return value.length > 0 ? value : null
|
||||
}
|
||||
|
||||
function redirectWithState(params: { notice?: string; error?: string }) {
|
||||
const query = new URLSearchParams()
|
||||
|
||||
if (params.notice) {
|
||||
query.set("notice", params.notice)
|
||||
}
|
||||
|
||||
if (params.error) {
|
||||
query.set("error", params.error)
|
||||
}
|
||||
|
||||
const value = query.toString()
|
||||
redirect(value ? `/pages?${value}` : "/pages")
|
||||
}
|
||||
|
||||
async function createPageAction(formData: FormData) {
|
||||
"use server"
|
||||
|
||||
await requirePermissionForRoute({
|
||||
nextPath: "/pages",
|
||||
permission: "pages:write",
|
||||
scope: "team",
|
||||
})
|
||||
|
||||
try {
|
||||
await createPage({
|
||||
title: readInputString(formData, "title"),
|
||||
slug: readInputString(formData, "slug"),
|
||||
status: readInputString(formData, "status"),
|
||||
summary: readNullableString(formData, "summary"),
|
||||
content: readInputString(formData, "content"),
|
||||
seoTitle: readNullableString(formData, "seoTitle"),
|
||||
seoDescription: readNullableString(formData, "seoDescription"),
|
||||
})
|
||||
} catch {
|
||||
redirectWithState({
|
||||
error: "Failed to create page. Validate slug/title/content and try again.",
|
||||
})
|
||||
}
|
||||
|
||||
revalidatePath("/pages")
|
||||
revalidatePath("/navigation")
|
||||
redirectWithState({ notice: "Page created." })
|
||||
}
|
||||
|
||||
export default async function PagesManagementPage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<SearchParamsInput>
|
||||
}) {
|
||||
const role = await requirePermissionForRoute({
|
||||
nextPath: "/pages",
|
||||
permission: "pages:read",
|
||||
scope: "team",
|
||||
})
|
||||
const [resolvedSearchParams, pages] = await Promise.all([searchParams, listPages(100)])
|
||||
const notice = readFirstValue(resolvedSearchParams.notice)
|
||||
const error = readFirstValue(resolvedSearchParams.error)
|
||||
|
||||
return (
|
||||
<AdminShell
|
||||
role={role}
|
||||
activePath="/pages"
|
||||
badge="Admin App"
|
||||
title="Pages"
|
||||
description="Create, update, and manage published page entities."
|
||||
>
|
||||
{notice ? (
|
||||
<section className="rounded-xl border border-emerald-300 bg-emerald-50 px-4 py-3 text-sm text-emerald-800">
|
||||
{notice}
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
{error ? (
|
||||
<section className="rounded-xl border border-red-300 bg-red-50 px-4 py-3 text-sm text-red-800">
|
||||
{error}
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
<section className="rounded-xl border border-neutral-200 p-6">
|
||||
<h2 className="text-xl font-medium">Create Page</h2>
|
||||
<CreatePageForm action={createPageAction} />
|
||||
</section>
|
||||
|
||||
<section className="rounded-xl border border-neutral-200 p-6">
|
||||
<h2 className="text-xl font-medium">Pages</h2>
|
||||
<div className="mt-4 overflow-x-auto">
|
||||
<table className="min-w-full text-left text-sm">
|
||||
<thead className="text-xs uppercase tracking-wide text-neutral-500">
|
||||
<tr>
|
||||
<th className="py-2 pr-4">Title</th>
|
||||
<th className="py-2 pr-4">Slug</th>
|
||||
<th className="py-2 pr-4">Status</th>
|
||||
<th className="py-2 pr-4">Updated</th>
|
||||
<th className="py-2 pr-4">Action</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{pages.length === 0 ? (
|
||||
<tr>
|
||||
<td className="py-3 text-neutral-500" colSpan={5}>
|
||||
No pages yet.
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
pages.map((page) => (
|
||||
<tr key={page.id} className="border-t border-neutral-200">
|
||||
<td className="py-3 pr-4">{page.title}</td>
|
||||
<td className="py-3 pr-4 text-neutral-600">/{page.slug}</td>
|
||||
<td className="py-3 pr-4">{page.status}</td>
|
||||
<td className="py-3 pr-4 text-neutral-600">
|
||||
{page.updatedAt.toLocaleDateString("en-US")}
|
||||
</td>
|
||||
<td className="py-3 pr-4">
|
||||
<Link
|
||||
href={`/pages/${page.id}`}
|
||||
className="text-xs font-medium text-neutral-700 underline underline-offset-2"
|
||||
>
|
||||
Open
|
||||
</Link>
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
</AdminShell>
|
||||
)
|
||||
}
|
||||
860
apps/admin/src/app/portfolio/page.tsx
Normal file
860
apps/admin/src/app/portfolio/page.tsx
Normal file
@@ -0,0 +1,860 @@
|
||||
import {
|
||||
attachArtworkRendition,
|
||||
createAlbum,
|
||||
createArtwork,
|
||||
createCategory,
|
||||
createGallery,
|
||||
createTag,
|
||||
deleteArtworkRendition,
|
||||
deleteGrouping,
|
||||
linkArtworkToGrouping,
|
||||
listArtworks,
|
||||
listMediaAssets,
|
||||
listMediaFoundationGroups,
|
||||
updateArtwork,
|
||||
updateGrouping,
|
||||
} from "@cms/db"
|
||||
import { Button } from "@cms/ui/button"
|
||||
import { revalidatePath } from "next/cache"
|
||||
import { redirect } from "next/navigation"
|
||||
|
||||
import { AdminShell } from "@/components/admin-shell"
|
||||
import { requirePermissionForRoute } from "@/lib/route-guards"
|
||||
|
||||
export const dynamic = "force-dynamic"
|
||||
|
||||
type SearchParamsInput = Record<string, string | string[] | undefined>
|
||||
type GroupType = "gallery" | "album" | "category" | "tag"
|
||||
|
||||
function readField(formData: FormData, key: string): string {
|
||||
const value = formData.get(key)
|
||||
return typeof value === "string" ? value.trim() : ""
|
||||
}
|
||||
|
||||
function readOptionalField(formData: FormData, key: string): string | undefined {
|
||||
const value = readField(formData, key)
|
||||
return value.length > 0 ? value : undefined
|
||||
}
|
||||
|
||||
function readOptionalNullableField(formData: FormData, key: string): string | null {
|
||||
const value = readField(formData, key)
|
||||
return value.length > 0 ? value : null
|
||||
}
|
||||
|
||||
function readNonNegativeInt(formData: FormData, key: string): number {
|
||||
const raw = readField(formData, key)
|
||||
const value = Number(raw)
|
||||
return Number.isFinite(value) && value >= 0 ? Math.floor(value) : 0
|
||||
}
|
||||
|
||||
function readOptionalNonNegativeInt(formData: FormData, key: string): number | undefined {
|
||||
const raw = readField(formData, key)
|
||||
if (!raw) {
|
||||
return undefined
|
||||
}
|
||||
const value = Number(raw)
|
||||
return Number.isFinite(value) && value >= 0 ? Math.floor(value) : undefined
|
||||
}
|
||||
|
||||
function readBooleanField(formData: FormData, key: string): boolean {
|
||||
return formData.get(key) === "on" || readField(formData, key) === "true"
|
||||
}
|
||||
|
||||
function readFirstValue(value: string | string[] | undefined): string | null {
|
||||
if (Array.isArray(value)) {
|
||||
return value[0] ?? null
|
||||
}
|
||||
|
||||
return value ?? null
|
||||
}
|
||||
|
||||
function slugify(input: string): string {
|
||||
return input
|
||||
.toLowerCase()
|
||||
.trim()
|
||||
.replace(/[^a-z0-9]+/g, "-")
|
||||
.replace(/^-+|-+$/g, "")
|
||||
.slice(0, 180)
|
||||
}
|
||||
|
||||
function redirectWithState(params: { notice?: string; error?: string }) {
|
||||
const query = new URLSearchParams()
|
||||
|
||||
if (params.notice) {
|
||||
query.set("notice", params.notice)
|
||||
}
|
||||
|
||||
if (params.error) {
|
||||
query.set("error", params.error)
|
||||
}
|
||||
|
||||
const value = query.toString()
|
||||
redirect(value ? `/portfolio?${value}` : "/portfolio")
|
||||
}
|
||||
|
||||
async function requireWritePermission() {
|
||||
await requirePermissionForRoute({
|
||||
nextPath: "/portfolio",
|
||||
permission: "media:write",
|
||||
scope: "team",
|
||||
})
|
||||
}
|
||||
|
||||
async function createArtworkAction(formData: FormData) {
|
||||
"use server"
|
||||
|
||||
await requireWritePermission()
|
||||
|
||||
const title = readField(formData, "title")
|
||||
const slug = slugify(readField(formData, "slug") || title)
|
||||
|
||||
try {
|
||||
await createArtwork({
|
||||
title,
|
||||
slug,
|
||||
description: readOptionalField(formData, "description"),
|
||||
medium: readOptionalField(formData, "medium"),
|
||||
dimensions: readOptionalField(formData, "dimensions"),
|
||||
framing: readOptionalField(formData, "framing"),
|
||||
availability: readOptionalField(formData, "availability"),
|
||||
priceAmountCents: (() => {
|
||||
const raw = readField(formData, "priceAmount")
|
||||
return raw ? Math.round(Number(raw) * 100) : undefined
|
||||
})(),
|
||||
priceCurrency: (() => {
|
||||
const raw = readField(formData, "priceCurrency").toUpperCase()
|
||||
return raw.length === 3 ? raw : undefined
|
||||
})(),
|
||||
isPriceVisible: readBooleanField(formData, "isPriceVisible"),
|
||||
year: (() => {
|
||||
const raw = readField(formData, "year")
|
||||
return raw ? Number(raw) : undefined
|
||||
})(),
|
||||
})
|
||||
} catch {
|
||||
redirectWithState({ error: "Failed to create artwork." })
|
||||
}
|
||||
|
||||
revalidatePath("/portfolio")
|
||||
redirectWithState({ notice: "Artwork created." })
|
||||
}
|
||||
|
||||
async function updateArtworkAction(formData: FormData) {
|
||||
"use server"
|
||||
|
||||
await requireWritePermission()
|
||||
|
||||
try {
|
||||
await updateArtwork({
|
||||
id: readField(formData, "artworkId"),
|
||||
medium: readOptionalNullableField(formData, "medium"),
|
||||
dimensions: readOptionalNullableField(formData, "dimensions"),
|
||||
year: (() => {
|
||||
const raw = readField(formData, "year")
|
||||
return raw ? Number(raw) : null
|
||||
})(),
|
||||
framing: readOptionalNullableField(formData, "framing"),
|
||||
availability: readOptionalNullableField(formData, "availability"),
|
||||
priceAmountCents: (() => {
|
||||
const value = readOptionalNonNegativeInt(formData, "priceAmountCents")
|
||||
return value ?? null
|
||||
})(),
|
||||
priceCurrency: (() => {
|
||||
const raw = readField(formData, "priceCurrency").toUpperCase()
|
||||
return raw.length === 3 ? raw : null
|
||||
})(),
|
||||
isPriceVisible: readBooleanField(formData, "isPriceVisible"),
|
||||
isPublished: readBooleanField(formData, "isPublished"),
|
||||
})
|
||||
} catch {
|
||||
redirectWithState({ error: "Failed to update artwork refinement fields." })
|
||||
}
|
||||
|
||||
revalidatePath("/portfolio")
|
||||
redirectWithState({ notice: "Artwork refinement updated." })
|
||||
}
|
||||
|
||||
async function createGroupAction(formData: FormData) {
|
||||
"use server"
|
||||
|
||||
await requireWritePermission()
|
||||
|
||||
const type = readField(formData, "groupType") as GroupType
|
||||
const name = readField(formData, "name")
|
||||
const slug = slugify(readField(formData, "slug") || name)
|
||||
|
||||
try {
|
||||
if (type === "gallery") {
|
||||
await createGallery({
|
||||
name,
|
||||
slug,
|
||||
description: readOptionalField(formData, "description"),
|
||||
sortOrder: readNonNegativeInt(formData, "sortOrder"),
|
||||
isVisible: readBooleanField(formData, "isVisible"),
|
||||
})
|
||||
} else if (type === "album") {
|
||||
await createAlbum({
|
||||
name,
|
||||
slug,
|
||||
description: readOptionalField(formData, "description"),
|
||||
sortOrder: readNonNegativeInt(formData, "sortOrder"),
|
||||
isVisible: readBooleanField(formData, "isVisible"),
|
||||
})
|
||||
} else if (type === "category") {
|
||||
await createCategory({
|
||||
name,
|
||||
slug,
|
||||
description: readOptionalField(formData, "description"),
|
||||
sortOrder: readNonNegativeInt(formData, "sortOrder"),
|
||||
isVisible: readBooleanField(formData, "isVisible"),
|
||||
})
|
||||
} else {
|
||||
await createTag({
|
||||
name,
|
||||
slug,
|
||||
description: readOptionalField(formData, "description"),
|
||||
sortOrder: readNonNegativeInt(formData, "sortOrder"),
|
||||
isVisible: readBooleanField(formData, "isVisible"),
|
||||
})
|
||||
}
|
||||
} catch {
|
||||
redirectWithState({ error: "Failed to create grouping entity." })
|
||||
}
|
||||
|
||||
revalidatePath("/portfolio")
|
||||
redirectWithState({ notice: `${type} created.` })
|
||||
}
|
||||
|
||||
async function updateGroupAction(formData: FormData) {
|
||||
"use server"
|
||||
|
||||
await requireWritePermission()
|
||||
|
||||
try {
|
||||
await updateGrouping({
|
||||
groupType: readField(formData, "groupType"),
|
||||
groupId: readField(formData, "groupId"),
|
||||
name: readField(formData, "name"),
|
||||
slug: slugify(readField(formData, "slug")),
|
||||
description: readOptionalNullableField(formData, "description"),
|
||||
sortOrder: readNonNegativeInt(formData, "sortOrder"),
|
||||
isVisible: readBooleanField(formData, "isVisible"),
|
||||
})
|
||||
} catch {
|
||||
redirectWithState({ error: "Failed to update grouping entity." })
|
||||
}
|
||||
|
||||
revalidatePath("/portfolio")
|
||||
redirectWithState({ notice: "Grouping entity updated." })
|
||||
}
|
||||
|
||||
async function deleteGroupAction(formData: FormData) {
|
||||
"use server"
|
||||
|
||||
await requireWritePermission()
|
||||
|
||||
try {
|
||||
await deleteGrouping({
|
||||
groupType: readField(formData, "groupType"),
|
||||
groupId: readField(formData, "groupId"),
|
||||
})
|
||||
} catch {
|
||||
redirectWithState({ error: "Failed to delete grouping entity." })
|
||||
}
|
||||
|
||||
revalidatePath("/portfolio")
|
||||
redirectWithState({ notice: "Grouping entity deleted." })
|
||||
}
|
||||
|
||||
async function linkArtworkGroupAction(formData: FormData) {
|
||||
"use server"
|
||||
|
||||
await requireWritePermission()
|
||||
|
||||
const artworkId = readField(formData, "artworkId")
|
||||
const groupType = readField(formData, "groupType") as GroupType
|
||||
const groupId = readField(formData, "groupId")
|
||||
|
||||
try {
|
||||
await linkArtworkToGrouping({
|
||||
artworkId,
|
||||
groupType,
|
||||
groupId,
|
||||
})
|
||||
} catch {
|
||||
redirectWithState({ error: "Failed to link artwork to grouping." })
|
||||
}
|
||||
|
||||
revalidatePath("/portfolio")
|
||||
redirectWithState({ notice: "Artwork linked to grouping." })
|
||||
}
|
||||
|
||||
async function attachRenditionAction(formData: FormData) {
|
||||
"use server"
|
||||
|
||||
await requireWritePermission()
|
||||
|
||||
try {
|
||||
await attachArtworkRendition({
|
||||
artworkId: readField(formData, "artworkId"),
|
||||
mediaAssetId: readField(formData, "mediaAssetId"),
|
||||
slot: readField(formData, "slot"),
|
||||
width: (() => {
|
||||
const raw = readField(formData, "width")
|
||||
return raw ? Number(raw) : undefined
|
||||
})(),
|
||||
height: (() => {
|
||||
const raw = readField(formData, "height")
|
||||
return raw ? Number(raw) : undefined
|
||||
})(),
|
||||
isPrimary: readField(formData, "isPrimary") === "true",
|
||||
})
|
||||
} catch {
|
||||
redirectWithState({ error: "Failed to attach artwork rendition." })
|
||||
}
|
||||
|
||||
revalidatePath("/portfolio")
|
||||
redirectWithState({ notice: "Rendition attached." })
|
||||
}
|
||||
|
||||
async function deleteRenditionAction(formData: FormData) {
|
||||
"use server"
|
||||
|
||||
await requireWritePermission()
|
||||
|
||||
try {
|
||||
await deleteArtworkRendition(readField(formData, "renditionId"))
|
||||
} catch {
|
||||
redirectWithState({ error: "Failed to delete rendition." })
|
||||
}
|
||||
|
||||
revalidatePath("/portfolio")
|
||||
redirectWithState({ notice: "Rendition deleted." })
|
||||
}
|
||||
|
||||
export default async function PortfolioPage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<SearchParamsInput>
|
||||
}) {
|
||||
const role = await requirePermissionForRoute({
|
||||
nextPath: "/portfolio",
|
||||
permission: "media:read",
|
||||
scope: "team",
|
||||
})
|
||||
const [resolvedSearchParams, artworks, mediaAssets, groups] = await Promise.all([
|
||||
searchParams,
|
||||
listArtworks(30),
|
||||
listMediaAssets(200),
|
||||
listMediaFoundationGroups(),
|
||||
])
|
||||
const notice = readFirstValue(resolvedSearchParams.notice)
|
||||
const error = readFirstValue(resolvedSearchParams.error)
|
||||
|
||||
return (
|
||||
<AdminShell
|
||||
role={role}
|
||||
activePath="/portfolio"
|
||||
badge="Admin App"
|
||||
title="Portfolio"
|
||||
description="Artwork foundation with rendition slots and grouping relations."
|
||||
>
|
||||
{notice ? (
|
||||
<section className="rounded-xl border border-emerald-300 bg-emerald-50 px-4 py-3 text-sm text-emerald-800">
|
||||
{notice}
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
{error ? (
|
||||
<section className="rounded-xl border border-red-300 bg-red-50 px-4 py-3 text-sm text-red-800">
|
||||
{error}
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
<section className="rounded-xl border border-neutral-200 p-6">
|
||||
<h2 className="text-xl font-medium">Create Artwork</h2>
|
||||
<form action={createArtworkAction} className="mt-4 space-y-3">
|
||||
<div className="grid gap-3 md:grid-cols-2">
|
||||
<label className="space-y-1">
|
||||
<span className="text-xs text-neutral-600">Title</span>
|
||||
<input
|
||||
name="title"
|
||||
required
|
||||
className="w-full rounded border border-neutral-300 px-3 py-2 text-sm"
|
||||
/>
|
||||
</label>
|
||||
<label className="space-y-1">
|
||||
<span className="text-xs text-neutral-600">Slug (optional)</span>
|
||||
<input
|
||||
name="slug"
|
||||
className="w-full rounded border border-neutral-300 px-3 py-2 text-sm"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<label className="space-y-1">
|
||||
<span className="text-xs text-neutral-600">Description</span>
|
||||
<textarea
|
||||
name="description"
|
||||
rows={3}
|
||||
className="w-full rounded border border-neutral-300 px-3 py-2 text-sm"
|
||||
/>
|
||||
</label>
|
||||
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-4">
|
||||
<input
|
||||
name="medium"
|
||||
placeholder="Medium"
|
||||
className="rounded border border-neutral-300 px-3 py-2 text-sm"
|
||||
/>
|
||||
<input
|
||||
name="dimensions"
|
||||
placeholder="Dimensions"
|
||||
className="rounded border border-neutral-300 px-3 py-2 text-sm"
|
||||
/>
|
||||
<input
|
||||
name="year"
|
||||
type="number"
|
||||
placeholder="Year"
|
||||
className="rounded border border-neutral-300 px-3 py-2 text-sm"
|
||||
/>
|
||||
<input
|
||||
name="framing"
|
||||
placeholder="Framing"
|
||||
className="rounded border border-neutral-300 px-3 py-2 text-sm"
|
||||
/>
|
||||
</div>
|
||||
<input
|
||||
name="availability"
|
||||
placeholder="Availability"
|
||||
className="w-full rounded border border-neutral-300 px-3 py-2 text-sm"
|
||||
/>
|
||||
<div className="grid gap-3 md:grid-cols-3">
|
||||
<input
|
||||
name="priceAmount"
|
||||
type="number"
|
||||
step="0.01"
|
||||
min={0}
|
||||
placeholder="Price amount (e.g. 199.99)"
|
||||
className="rounded border border-neutral-300 px-3 py-2 text-sm"
|
||||
/>
|
||||
<input
|
||||
name="priceCurrency"
|
||||
maxLength={3}
|
||||
placeholder="Currency (USD)"
|
||||
className="rounded border border-neutral-300 px-3 py-2 text-sm uppercase"
|
||||
/>
|
||||
<label className="flex items-center gap-2 rounded border border-neutral-300 px-3 py-2 text-sm">
|
||||
<input type="checkbox" name="isPriceVisible" />
|
||||
Price visible
|
||||
</label>
|
||||
</div>
|
||||
<Button type="submit">Create artwork</Button>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section className="rounded-xl border border-neutral-200 p-6">
|
||||
<h2 className="text-xl font-medium">Create Group Entity</h2>
|
||||
<form action={createGroupAction} className="mt-4 space-y-3">
|
||||
<div className="grid gap-3 md:grid-cols-5">
|
||||
<select
|
||||
name="groupType"
|
||||
defaultValue="gallery"
|
||||
className="rounded border border-neutral-300 px-3 py-2 text-sm"
|
||||
>
|
||||
<option value="gallery">gallery</option>
|
||||
<option value="album">album</option>
|
||||
<option value="category">category</option>
|
||||
<option value="tag">tag</option>
|
||||
</select>
|
||||
<input
|
||||
name="name"
|
||||
required
|
||||
placeholder="Name"
|
||||
className="rounded border border-neutral-300 px-3 py-2 text-sm"
|
||||
/>
|
||||
<input
|
||||
name="slug"
|
||||
placeholder="Slug (optional)"
|
||||
className="rounded border border-neutral-300 px-3 py-2 text-sm"
|
||||
/>
|
||||
<input
|
||||
name="sortOrder"
|
||||
type="number"
|
||||
min={0}
|
||||
defaultValue={0}
|
||||
placeholder="Sort order"
|
||||
className="rounded border border-neutral-300 px-3 py-2 text-sm"
|
||||
/>
|
||||
<label className="flex items-center gap-2 rounded border border-neutral-300 px-3 py-2 text-sm">
|
||||
<input type="checkbox" name="isVisible" defaultChecked />
|
||||
Visible
|
||||
</label>
|
||||
</div>
|
||||
<textarea
|
||||
name="description"
|
||||
rows={2}
|
||||
placeholder="Description (optional)"
|
||||
className="w-full rounded border border-neutral-300 px-3 py-2 text-sm"
|
||||
/>
|
||||
<Button type="submit">Create group</Button>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section className="rounded-xl border border-neutral-200 p-6">
|
||||
<h2 className="text-xl font-medium">Manage Group Entities</h2>
|
||||
<div className="mt-4 grid gap-4">
|
||||
{(
|
||||
[
|
||||
{ type: "gallery" as const, label: "Gallery", items: groups.galleries },
|
||||
{ type: "album" as const, label: "Album", items: groups.albums },
|
||||
{ type: "category" as const, label: "Category", items: groups.categories },
|
||||
{ type: "tag" as const, label: "Tag", items: groups.tags },
|
||||
] as const
|
||||
).map((groupConfig) => (
|
||||
<section
|
||||
key={`manage-${groupConfig.type}`}
|
||||
className="rounded border border-neutral-200 p-4"
|
||||
>
|
||||
<h3 className="text-sm font-semibold">{groupConfig.label} Entities</h3>
|
||||
<div className="mt-3 space-y-3">
|
||||
{groupConfig.items.length === 0 ? (
|
||||
<p className="text-xs text-neutral-500">No entities created yet.</p>
|
||||
) : (
|
||||
groupConfig.items.map((group) => (
|
||||
<form
|
||||
key={`manage-${groupConfig.type}-${group.id}`}
|
||||
action={updateGroupAction}
|
||||
className="space-y-3 rounded border border-neutral-200 p-3"
|
||||
>
|
||||
<input type="hidden" name="groupType" value={groupConfig.type} />
|
||||
<input type="hidden" name="groupId" value={group.id} />
|
||||
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-4">
|
||||
<input
|
||||
name="name"
|
||||
required
|
||||
defaultValue={group.name}
|
||||
className="rounded border border-neutral-300 px-3 py-2 text-sm"
|
||||
/>
|
||||
<input
|
||||
name="slug"
|
||||
required
|
||||
defaultValue={group.slug}
|
||||
className="rounded border border-neutral-300 px-3 py-2 text-sm"
|
||||
/>
|
||||
<input
|
||||
name="sortOrder"
|
||||
type="number"
|
||||
min={0}
|
||||
defaultValue={group.sortOrder}
|
||||
className="rounded border border-neutral-300 px-3 py-2 text-sm"
|
||||
/>
|
||||
<label className="flex items-center gap-2 rounded border border-neutral-300 px-3 py-2 text-sm">
|
||||
<input
|
||||
type="checkbox"
|
||||
name="isVisible"
|
||||
defaultChecked={group.isVisible}
|
||||
/>
|
||||
Visible
|
||||
</label>
|
||||
</div>
|
||||
<textarea
|
||||
name="description"
|
||||
rows={2}
|
||||
defaultValue={group.description ?? ""}
|
||||
placeholder="Description"
|
||||
className="w-full rounded border border-neutral-300 px-3 py-2 text-sm"
|
||||
/>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button type="submit">Save group</Button>
|
||||
<button
|
||||
type="submit"
|
||||
formAction={deleteGroupAction}
|
||||
className="rounded border border-red-300 px-3 py-2 text-sm text-red-700 hover:bg-red-50"
|
||||
>
|
||||
Delete group
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="rounded-xl border border-neutral-200 p-6">
|
||||
<h2 className="text-xl font-medium">Link Artwork To Group</h2>
|
||||
<div className="mt-4 grid gap-4 lg:grid-cols-2">
|
||||
{(
|
||||
[
|
||||
{ type: "gallery" as const, label: "Gallery", items: groups.galleries },
|
||||
{ type: "album" as const, label: "Album", items: groups.albums },
|
||||
{ type: "category" as const, label: "Category", items: groups.categories },
|
||||
{ type: "tag" as const, label: "Tag", items: groups.tags },
|
||||
] as const
|
||||
).map((groupConfig) => (
|
||||
<form
|
||||
key={groupConfig.type}
|
||||
action={linkArtworkGroupAction}
|
||||
className="space-y-3 rounded border border-neutral-200 p-4"
|
||||
>
|
||||
<h3 className="text-sm font-semibold">{groupConfig.label} Link</h3>
|
||||
<input type="hidden" name="groupType" value={groupConfig.type} />
|
||||
<select
|
||||
name="artworkId"
|
||||
className="w-full rounded border border-neutral-300 px-3 py-2 text-sm"
|
||||
>
|
||||
{artworks.map((artwork) => (
|
||||
<option key={`${groupConfig.type}-${artwork.id}`} value={artwork.id}>
|
||||
{artwork.title}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<select
|
||||
name="groupId"
|
||||
className="w-full rounded border border-neutral-300 px-3 py-2 text-sm"
|
||||
>
|
||||
{groupConfig.items.map((group) => (
|
||||
<option key={`${groupConfig.type}-${group.id}`} value={group.id}>
|
||||
{group.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<Button type="submit">Link artwork</Button>
|
||||
</form>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="rounded-xl border border-neutral-200 p-6">
|
||||
<h2 className="text-xl font-medium">Attach Artwork Rendition Slot</h2>
|
||||
<form
|
||||
action={attachRenditionAction}
|
||||
className="mt-4 grid gap-3 md:grid-cols-3 xl:grid-cols-6"
|
||||
>
|
||||
<select name="artworkId" className="rounded border border-neutral-300 px-3 py-2 text-sm">
|
||||
{artworks.map((artwork) => (
|
||||
<option key={artwork.id} value={artwork.id}>
|
||||
{artwork.title}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<select
|
||||
name="mediaAssetId"
|
||||
className="rounded border border-neutral-300 px-3 py-2 text-sm"
|
||||
>
|
||||
{mediaAssets.map((asset) => (
|
||||
<option key={asset.id} value={asset.id}>
|
||||
{asset.title}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<select
|
||||
name="slot"
|
||||
defaultValue="thumbnail"
|
||||
className="rounded border border-neutral-300 px-3 py-2 text-sm"
|
||||
>
|
||||
<option value="thumbnail">thumbnail</option>
|
||||
<option value="card">card</option>
|
||||
<option value="full">full</option>
|
||||
<option value="retina">retina</option>
|
||||
<option value="custom">custom</option>
|
||||
</select>
|
||||
<input
|
||||
name="width"
|
||||
type="number"
|
||||
placeholder="width"
|
||||
className="rounded border border-neutral-300 px-3 py-2 text-sm"
|
||||
/>
|
||||
<input
|
||||
name="height"
|
||||
type="number"
|
||||
placeholder="height"
|
||||
className="rounded border border-neutral-300 px-3 py-2 text-sm"
|
||||
/>
|
||||
<select
|
||||
name="isPrimary"
|
||||
defaultValue="false"
|
||||
className="rounded border border-neutral-300 px-3 py-2 text-sm"
|
||||
>
|
||||
<option value="false">not primary</option>
|
||||
<option value="true">primary</option>
|
||||
</select>
|
||||
<div className="md:col-span-3 xl:col-span-6">
|
||||
<Button type="submit">Attach rendition</Button>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section className="rounded-xl border border-neutral-200 p-6">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<h2 className="text-xl font-medium">Artworks</h2>
|
||||
<span className="text-xs uppercase tracking-[0.2em] text-neutral-500">
|
||||
MVP1 Foundation
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-4 overflow-x-auto">
|
||||
<table className="min-w-full text-left text-sm">
|
||||
<thead className="text-xs uppercase tracking-wide text-neutral-500">
|
||||
<tr>
|
||||
<th className="py-2 pr-4">Title</th>
|
||||
<th className="py-2 pr-4">Slug</th>
|
||||
<th className="py-2 pr-4">Published</th>
|
||||
<th className="py-2 pr-4">Refinement</th>
|
||||
<th className="py-2 pr-4">Renditions</th>
|
||||
<th className="py-2 pr-4">Groups</th>
|
||||
<th className="py-2 pr-4">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{artworks.length === 0 ? (
|
||||
<tr>
|
||||
<td className="py-3 text-neutral-500" colSpan={7}>
|
||||
No artworks yet. Add creation flows after media upload pipeline lands.
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
artworks.map((artwork) => (
|
||||
<tr key={artwork.id} className="border-t border-neutral-200">
|
||||
<td className="py-3 pr-4">{artwork.title}</td>
|
||||
<td className="py-3 pr-4 font-mono text-xs">{artwork.slug}</td>
|
||||
<td className="py-3 pr-4">{artwork.isPublished ? "yes" : "no"}</td>
|
||||
<td className="py-3 pr-4 text-xs text-neutral-600">
|
||||
{artwork.medium ? `medium: ${artwork.medium}` : "medium: -"}
|
||||
<br />
|
||||
{artwork.dimensions ? `dimensions: ${artwork.dimensions}` : "dimensions: -"}
|
||||
<br />
|
||||
{artwork.year ? `year: ${artwork.year}` : "year: -"}
|
||||
<br />
|
||||
{artwork.framing ? `framing: ${artwork.framing}` : "framing: -"}
|
||||
<br />
|
||||
{artwork.availability
|
||||
? `availability: ${artwork.availability}`
|
||||
: "availability: -"}
|
||||
<br />
|
||||
{artwork.priceAmountCents && artwork.priceCurrency
|
||||
? `price: ${(artwork.priceAmountCents / 100).toFixed(2)} ${artwork.priceCurrency} (${artwork.isPriceVisible ? "visible" : "hidden"})`
|
||||
: "price: -"}
|
||||
</td>
|
||||
<td className="py-3 pr-4">
|
||||
<div className="space-y-1">
|
||||
{artwork.renditions.length === 0 ? (
|
||||
<span className="text-xs text-neutral-500">0</span>
|
||||
) : (
|
||||
artwork.renditions.map((rendition) => (
|
||||
<form
|
||||
key={rendition.id}
|
||||
action={deleteRenditionAction}
|
||||
className="flex items-center gap-2 text-xs"
|
||||
>
|
||||
<input type="hidden" name="renditionId" value={rendition.id} />
|
||||
<span className="rounded bg-neutral-100 px-2 py-1 font-mono">
|
||||
{rendition.slot}
|
||||
</span>
|
||||
<span className="text-neutral-500">
|
||||
{rendition.width ?? "-"}x{rendition.height ?? "-"}
|
||||
</span>
|
||||
{rendition.isPrimary ? (
|
||||
<span className="rounded bg-emerald-100 px-2 py-1 text-emerald-700">
|
||||
primary
|
||||
</span>
|
||||
) : null}
|
||||
<button
|
||||
type="submit"
|
||||
className="rounded border border-red-300 px-2 py-1 text-red-700 hover:bg-red-50"
|
||||
>
|
||||
delete
|
||||
</button>
|
||||
</form>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
<td className="py-3 pr-4 text-neutral-600">
|
||||
g:{artwork.galleryLinks.length} a:{artwork.albumLinks.length} c:
|
||||
{artwork.categoryLinks.length} t:{artwork.tagLinks.length}
|
||||
</td>
|
||||
<td className="py-3 pr-4">
|
||||
<form action={updateArtworkAction} className="grid min-w-80 gap-2">
|
||||
<input type="hidden" name="artworkId" value={artwork.id} />
|
||||
<input
|
||||
name="medium"
|
||||
defaultValue={artwork.medium ?? ""}
|
||||
placeholder="Medium"
|
||||
className="rounded border border-neutral-300 px-2 py-1 text-xs"
|
||||
/>
|
||||
<input
|
||||
name="dimensions"
|
||||
defaultValue={artwork.dimensions ?? ""}
|
||||
placeholder="Dimensions"
|
||||
className="rounded border border-neutral-300 px-2 py-1 text-xs"
|
||||
/>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<input
|
||||
name="year"
|
||||
type="number"
|
||||
defaultValue={artwork.year ?? ""}
|
||||
placeholder="Year"
|
||||
className="rounded border border-neutral-300 px-2 py-1 text-xs"
|
||||
/>
|
||||
<input
|
||||
name="framing"
|
||||
defaultValue={artwork.framing ?? ""}
|
||||
placeholder="Framing"
|
||||
className="rounded border border-neutral-300 px-2 py-1 text-xs"
|
||||
/>
|
||||
</div>
|
||||
<input
|
||||
name="availability"
|
||||
defaultValue={artwork.availability ?? ""}
|
||||
placeholder="Availability"
|
||||
className="rounded border border-neutral-300 px-2 py-1 text-xs"
|
||||
/>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<input
|
||||
name="priceAmountCents"
|
||||
type="number"
|
||||
min={0}
|
||||
defaultValue={artwork.priceAmountCents ?? ""}
|
||||
placeholder="Price cents"
|
||||
className="rounded border border-neutral-300 px-2 py-1 text-xs"
|
||||
/>
|
||||
<input
|
||||
name="priceCurrency"
|
||||
maxLength={3}
|
||||
defaultValue={artwork.priceCurrency ?? ""}
|
||||
placeholder="USD"
|
||||
className="rounded border border-neutral-300 px-2 py-1 text-xs uppercase"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 text-xs">
|
||||
<label className="inline-flex items-center gap-1">
|
||||
<input
|
||||
type="checkbox"
|
||||
name="isPriceVisible"
|
||||
defaultChecked={artwork.isPriceVisible}
|
||||
/>
|
||||
price visible
|
||||
</label>
|
||||
<label className="inline-flex items-center gap-1">
|
||||
<input
|
||||
type="checkbox"
|
||||
name="isPublished"
|
||||
defaultChecked={artwork.isPublished}
|
||||
/>
|
||||
published
|
||||
</label>
|
||||
</div>
|
||||
<Button type="submit">Save</Button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
</AdminShell>
|
||||
)
|
||||
}
|
||||
@@ -1,9 +1,24 @@
|
||||
"use client"
|
||||
|
||||
import type { AppLocale } from "@cms/i18n"
|
||||
import type { ReactNode } from "react"
|
||||
|
||||
import type { AdminMessages } from "@/i18n/messages"
|
||||
import { AdminI18nProvider } from "@/providers/admin-i18n-provider"
|
||||
import { QueryProvider } from "@/providers/query-provider"
|
||||
|
||||
export function Providers({ children }: { children: ReactNode }) {
|
||||
return <QueryProvider>{children}</QueryProvider>
|
||||
export function Providers({
|
||||
children,
|
||||
locale,
|
||||
messages,
|
||||
}: {
|
||||
children: ReactNode
|
||||
locale: AppLocale
|
||||
messages: AdminMessages
|
||||
}) {
|
||||
return (
|
||||
<AdminI18nProvider locale={locale} messages={messages}>
|
||||
<QueryProvider>{children}</QueryProvider>
|
||||
</AdminI18nProvider>
|
||||
)
|
||||
}
|
||||
|
||||
91
apps/admin/src/app/register/page.test.tsx
Normal file
91
apps/admin/src/app/register/page.test.tsx
Normal file
@@ -0,0 +1,91 @@
|
||||
import type { ReactElement } from "react"
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest"
|
||||
|
||||
const {
|
||||
redirectMock,
|
||||
resolveRoleFromServerContextMock,
|
||||
hasOwnerUserMock,
|
||||
isSelfRegistrationEnabledMock,
|
||||
} = vi.hoisted(() => ({
|
||||
redirectMock: vi.fn((path: string) => {
|
||||
throw new Error(`REDIRECT:${path}`)
|
||||
}),
|
||||
resolveRoleFromServerContextMock: vi.fn(),
|
||||
hasOwnerUserMock: vi.fn(),
|
||||
isSelfRegistrationEnabledMock: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock("next/navigation", () => ({
|
||||
redirect: redirectMock,
|
||||
}))
|
||||
|
||||
vi.mock("@/lib/access-server", () => ({
|
||||
resolveRoleFromServerContext: resolveRoleFromServerContextMock,
|
||||
}))
|
||||
|
||||
vi.mock("@/lib/auth/server", () => ({
|
||||
hasOwnerUser: hasOwnerUserMock,
|
||||
isSelfRegistrationEnabled: isSelfRegistrationEnabledMock,
|
||||
}))
|
||||
|
||||
vi.mock("@/app/login/login-form", () => ({
|
||||
LoginForm: ({ mode }: { mode: string }) => ({ type: "login-form", props: { mode } }),
|
||||
}))
|
||||
|
||||
import RegisterPage from "./page"
|
||||
|
||||
function expectRedirect(call: () => Promise<unknown>, path: string) {
|
||||
return expect(call()).rejects.toThrow(`REDIRECT:${path}`)
|
||||
}
|
||||
|
||||
describe("register page", () => {
|
||||
beforeEach(() => {
|
||||
redirectMock.mockClear()
|
||||
resolveRoleFromServerContextMock.mockReset()
|
||||
hasOwnerUserMock.mockReset()
|
||||
isSelfRegistrationEnabledMock.mockReset()
|
||||
})
|
||||
|
||||
it("redirects authenticated users to dashboard", async () => {
|
||||
resolveRoleFromServerContextMock.mockResolvedValue("admin")
|
||||
|
||||
await expectRedirect(
|
||||
() => RegisterPage({ searchParams: Promise.resolve({ next: "/pages" }) }),
|
||||
"/",
|
||||
)
|
||||
})
|
||||
|
||||
it("redirects to welcome when no owner exists", async () => {
|
||||
resolveRoleFromServerContextMock.mockResolvedValue(null)
|
||||
hasOwnerUserMock.mockResolvedValue(false)
|
||||
|
||||
await expectRedirect(
|
||||
() => RegisterPage({ searchParams: Promise.resolve({ next: "/pages" }) }),
|
||||
"/welcome?next=%2Fpages",
|
||||
)
|
||||
})
|
||||
|
||||
it("shows disabled mode when self registration is off", async () => {
|
||||
resolveRoleFromServerContextMock.mockResolvedValue(null)
|
||||
hasOwnerUserMock.mockResolvedValue(true)
|
||||
isSelfRegistrationEnabledMock.mockResolvedValue(false)
|
||||
|
||||
const page = (await RegisterPage({ searchParams: Promise.resolve({}) })) as ReactElement<{
|
||||
mode: string
|
||||
}>
|
||||
|
||||
expect(page.props.mode).toBe("signup-disabled")
|
||||
})
|
||||
|
||||
it("shows sign-up mode when self registration is enabled", async () => {
|
||||
resolveRoleFromServerContextMock.mockResolvedValue(null)
|
||||
hasOwnerUserMock.mockResolvedValue(true)
|
||||
isSelfRegistrationEnabledMock.mockResolvedValue(true)
|
||||
|
||||
const page = (await RegisterPage({ searchParams: Promise.resolve({}) })) as ReactElement<{
|
||||
mode: string
|
||||
}>
|
||||
|
||||
expect(page.props.mode).toBe("signup-user")
|
||||
})
|
||||
})
|
||||
@@ -33,7 +33,7 @@ export default async function RegisterPage({ searchParams }: { searchParams: Sea
|
||||
const enabled = await isSelfRegistrationEnabled()
|
||||
|
||||
if (!enabled) {
|
||||
redirect(`/login?next=${encodeURIComponent(nextPath)}`)
|
||||
return <LoginForm mode="signup-disabled" />
|
||||
}
|
||||
|
||||
return <LoginForm mode="signup-user" />
|
||||
|
||||
299
apps/admin/src/app/settings/page.tsx
Normal file
299
apps/admin/src/app/settings/page.tsx
Normal file
@@ -0,0 +1,299 @@
|
||||
import {
|
||||
getPublicHeaderBannerConfig,
|
||||
isAdminSelfRegistrationEnabled,
|
||||
setAdminSelfRegistrationEnabled,
|
||||
setPublicHeaderBannerConfig,
|
||||
} from "@cms/db"
|
||||
import { Button } from "@cms/ui/button"
|
||||
import { revalidatePath } from "next/cache"
|
||||
import Link from "next/link"
|
||||
import { redirect } from "next/navigation"
|
||||
|
||||
import { AdminShell } from "@/components/admin-shell"
|
||||
import { translateMessage } from "@/i18n/messages"
|
||||
import { getAdminMessages, resolveAdminLocale } from "@/i18n/server"
|
||||
import { requirePermissionForRoute } from "@/lib/route-guards"
|
||||
|
||||
type SearchParamsInput = Promise<Record<string, string | string[] | undefined>>
|
||||
|
||||
function toSingleValue(input: string | string[] | undefined): string | null {
|
||||
if (Array.isArray(input)) {
|
||||
return input[0] ?? null
|
||||
}
|
||||
|
||||
return input ?? null
|
||||
}
|
||||
|
||||
async function requireSettingsPermission() {
|
||||
await requirePermissionForRoute({
|
||||
nextPath: "/settings",
|
||||
permission: "users:manage_roles",
|
||||
scope: "global",
|
||||
})
|
||||
}
|
||||
|
||||
async function getSettingsTranslator() {
|
||||
const locale = await resolveAdminLocale()
|
||||
const messages = await getAdminMessages(locale)
|
||||
|
||||
return (key: string, fallback: string) => translateMessage(messages, key, fallback)
|
||||
}
|
||||
|
||||
async function updateRegistrationPolicyAction(formData: FormData) {
|
||||
"use server"
|
||||
|
||||
await requireSettingsPermission()
|
||||
const t = await getSettingsTranslator()
|
||||
const enabled = formData.get("enabled") === "on"
|
||||
|
||||
try {
|
||||
await setAdminSelfRegistrationEnabled(enabled)
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : ""
|
||||
const normalizedMessage = errorMessage.toLowerCase()
|
||||
const isDatabaseUnavailable = errorMessage.includes("P1001")
|
||||
const isSchemaMissing =
|
||||
errorMessage.includes("P2021") ||
|
||||
normalizedMessage.includes("system_setting") ||
|
||||
normalizedMessage.includes("does not exist")
|
||||
|
||||
const userMessage = isDatabaseUnavailable
|
||||
? t(
|
||||
"settings.registration.errors.databaseUnavailable",
|
||||
"Saving settings failed. The database is currently unreachable.",
|
||||
)
|
||||
: isSchemaMissing
|
||||
? t(
|
||||
"settings.registration.errors.schemaMissing",
|
||||
"Saving settings failed. Apply the latest database migrations and try again.",
|
||||
)
|
||||
: t(
|
||||
"settings.registration.errors.updateFailed",
|
||||
"Saving settings failed. Ensure database migrations are applied.",
|
||||
)
|
||||
|
||||
redirect(`/settings?error=${encodeURIComponent(userMessage)}`)
|
||||
}
|
||||
|
||||
revalidatePath("/settings")
|
||||
revalidatePath("/register")
|
||||
redirect(
|
||||
`/settings?notice=${encodeURIComponent(
|
||||
t("settings.registration.success.updated", "Registration policy updated."),
|
||||
)}`,
|
||||
)
|
||||
}
|
||||
|
||||
async function updatePublicHeaderBannerAction(formData: FormData) {
|
||||
"use server"
|
||||
|
||||
await requireSettingsPermission()
|
||||
const t = await getSettingsTranslator()
|
||||
const enabled = formData.get("bannerEnabled") === "on"
|
||||
const message = toSingleValue(formData.get("bannerMessage")?.toString())?.trim() ?? ""
|
||||
const ctaLabel = toSingleValue(formData.get("bannerCtaLabel")?.toString())?.trim() ?? ""
|
||||
const ctaHref = toSingleValue(formData.get("bannerCtaHref")?.toString())?.trim() ?? ""
|
||||
|
||||
if (enabled && message.length === 0) {
|
||||
redirect(
|
||||
`/settings?error=${encodeURIComponent(
|
||||
t(
|
||||
"settings.banner.errors.messageRequired",
|
||||
"Banner message is required while banner is enabled.",
|
||||
),
|
||||
)}`,
|
||||
)
|
||||
}
|
||||
|
||||
try {
|
||||
await setPublicHeaderBannerConfig({
|
||||
enabled,
|
||||
message,
|
||||
ctaLabel: ctaLabel || null,
|
||||
ctaHref: ctaHref || null,
|
||||
})
|
||||
} catch {
|
||||
redirect(
|
||||
`/settings?error=${encodeURIComponent(
|
||||
t(
|
||||
"settings.banner.errors.updateFailed",
|
||||
"Saving banner settings failed. Ensure database migrations are applied.",
|
||||
),
|
||||
)}`,
|
||||
)
|
||||
}
|
||||
|
||||
revalidatePath("/settings")
|
||||
redirect(
|
||||
`/settings?notice=${encodeURIComponent(
|
||||
t("settings.banner.success.updated", "Public header banner settings updated."),
|
||||
)}`,
|
||||
)
|
||||
}
|
||||
|
||||
export default async function SettingsPage({ searchParams }: { searchParams: SearchParamsInput }) {
|
||||
const role = await requirePermissionForRoute({
|
||||
nextPath: "/settings",
|
||||
permission: "users:manage_roles",
|
||||
scope: "global",
|
||||
})
|
||||
|
||||
const [params, locale, isRegistrationEnabled, publicBanner] = await Promise.all([
|
||||
searchParams,
|
||||
resolveAdminLocale(),
|
||||
isAdminSelfRegistrationEnabled(),
|
||||
getPublicHeaderBannerConfig(),
|
||||
])
|
||||
const messages = await getAdminMessages(locale)
|
||||
const t = (key: string, fallback: string) => translateMessage(messages, key, fallback)
|
||||
|
||||
const notice = toSingleValue(params.notice)
|
||||
const error = toSingleValue(params.error)
|
||||
|
||||
return (
|
||||
<AdminShell
|
||||
role={role}
|
||||
activePath="/settings"
|
||||
badge={t("settings.badge", "Admin Settings")}
|
||||
title={t("settings.title", "Settings")}
|
||||
description={t(
|
||||
"settings.description",
|
||||
"Manage runtime policies for the admin authentication and onboarding flow.",
|
||||
)}
|
||||
actions={
|
||||
<Link
|
||||
href="/"
|
||||
className="inline-flex rounded-md border border-neutral-300 px-4 py-2 text-sm font-medium hover:bg-neutral-100"
|
||||
>
|
||||
{t("settings.actions.backToDashboard", "Back to dashboard")}
|
||||
</Link>
|
||||
}
|
||||
>
|
||||
{notice ? (
|
||||
<section className="rounded-xl border border-emerald-300 bg-emerald-50 px-4 py-3 text-sm text-emerald-800">
|
||||
{notice}
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
{error ? (
|
||||
<section className="rounded-xl border border-red-300 bg-red-50 px-4 py-3 text-sm text-red-800">
|
||||
{error}
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
<section className="rounded-xl border border-neutral-200 p-6">
|
||||
<div className="space-y-5">
|
||||
<div className="space-y-2">
|
||||
<h2 className="text-xl font-medium">
|
||||
{t("settings.registration.title", "Admin self-registration")}
|
||||
</h2>
|
||||
<p className="text-sm text-neutral-600">
|
||||
{t(
|
||||
"settings.registration.description",
|
||||
"When enabled, /register can create additional admin accounts after initial owner bootstrap.",
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border border-neutral-200 p-4 text-sm text-neutral-700">
|
||||
<p>
|
||||
{t("settings.registration.currentStatusLabel", "Current status")}:{" "}
|
||||
<strong>
|
||||
{isRegistrationEnabled
|
||||
? t("settings.registration.status.enabled", "Enabled")
|
||||
: t("settings.registration.status.disabled", "Disabled")}
|
||||
</strong>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<form action={updateRegistrationPolicyAction} className="space-y-4">
|
||||
<label className="flex items-center gap-3 text-sm">
|
||||
<input
|
||||
type="checkbox"
|
||||
name="enabled"
|
||||
defaultChecked={isRegistrationEnabled}
|
||||
className="h-4 w-4 rounded border-neutral-300"
|
||||
/>
|
||||
<span>
|
||||
{t(
|
||||
"settings.registration.checkboxLabel",
|
||||
"Allow self-registration on /register for admin users",
|
||||
)}
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<Button type="submit">
|
||||
{t("settings.registration.actions.save", "Save registration policy")}
|
||||
</Button>
|
||||
</form>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="rounded-xl border border-neutral-200 p-6">
|
||||
<div className="space-y-5">
|
||||
<div className="space-y-2">
|
||||
<h2 className="text-xl font-medium">
|
||||
{t("settings.banner.title", "Public header banner")}
|
||||
</h2>
|
||||
<p className="text-sm text-neutral-600">
|
||||
{t(
|
||||
"settings.banner.description",
|
||||
"Control the top banner shown on the public app header.",
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<form action={updatePublicHeaderBannerAction} className="space-y-4">
|
||||
<label className="flex items-center gap-3 text-sm">
|
||||
<input
|
||||
type="checkbox"
|
||||
name="bannerEnabled"
|
||||
defaultChecked={publicBanner.enabled}
|
||||
className="h-4 w-4 rounded border-neutral-300"
|
||||
/>
|
||||
<span>{t("settings.banner.enabledLabel", "Enable public header banner")}</span>
|
||||
</label>
|
||||
|
||||
<label className="space-y-1 text-sm">
|
||||
<span className="text-xs text-neutral-600">
|
||||
{t("settings.banner.messageLabel", "Message")}
|
||||
</span>
|
||||
<input
|
||||
name="bannerMessage"
|
||||
defaultValue={publicBanner.message}
|
||||
className="w-full rounded border border-neutral-300 px-3 py-2 text-sm"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<div className="grid gap-3 md:grid-cols-2">
|
||||
<label className="space-y-1 text-sm">
|
||||
<span className="text-xs text-neutral-600">
|
||||
{t("settings.banner.ctaLabelLabel", "CTA label (optional)")}
|
||||
</span>
|
||||
<input
|
||||
name="bannerCtaLabel"
|
||||
defaultValue={publicBanner.ctaLabel ?? ""}
|
||||
className="w-full rounded border border-neutral-300 px-3 py-2 text-sm"
|
||||
/>
|
||||
</label>
|
||||
<label className="space-y-1 text-sm">
|
||||
<span className="text-xs text-neutral-600">
|
||||
{t("settings.banner.ctaHrefLabel", "CTA URL (optional)")}
|
||||
</span>
|
||||
<input
|
||||
name="bannerCtaHref"
|
||||
defaultValue={publicBanner.ctaHref ?? ""}
|
||||
className="w-full rounded border border-neutral-300 px-3 py-2 text-sm"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<Button type="submit">
|
||||
{t("settings.banner.actions.save", "Save banner settings")}
|
||||
</Button>
|
||||
</form>
|
||||
</div>
|
||||
</section>
|
||||
</AdminShell>
|
||||
)
|
||||
}
|
||||
@@ -1,10 +1,9 @@
|
||||
import { readFile } from "node:fs/promises"
|
||||
import path from "node:path"
|
||||
import { hasPermission } from "@cms/content/rbac"
|
||||
import Link from "next/link"
|
||||
import { redirect } from "next/navigation"
|
||||
|
||||
import { resolveRoleFromServerContext } from "@/lib/access-server"
|
||||
import { AdminShell } from "@/components/admin-shell"
|
||||
import { requirePermissionForRoute } from "@/lib/route-guards"
|
||||
|
||||
export const dynamic = "force-dynamic"
|
||||
|
||||
@@ -405,15 +404,11 @@ function filterButtonClass(active: boolean): string {
|
||||
export default async function AdminTodoPage(props: {
|
||||
searchParams?: SearchParamsInput | Promise<SearchParamsInput>
|
||||
}) {
|
||||
const role = await resolveRoleFromServerContext()
|
||||
|
||||
if (!role) {
|
||||
redirect("/login?next=/todo")
|
||||
}
|
||||
|
||||
if (!hasPermission(role, "roadmap:read", "global")) {
|
||||
redirect("/unauthorized?required=roadmap:read&scope=global")
|
||||
}
|
||||
const role = await requirePermissionForRoute({
|
||||
nextPath: "/todo",
|
||||
permission: "roadmap:read",
|
||||
scope: "global",
|
||||
})
|
||||
|
||||
const content = await getTodoMarkdown()
|
||||
const sections = parseTodo(content)
|
||||
@@ -434,26 +429,21 @@ export default async function AdminTodoPage(props: {
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="mx-auto flex min-h-screen w-full max-w-6xl flex-col gap-8 px-6 py-12">
|
||||
<header className="space-y-4">
|
||||
<p className="text-sm uppercase tracking-[0.2em] text-neutral-500">Admin App</p>
|
||||
<div className="flex flex-wrap items-end justify-between gap-4">
|
||||
<div className="space-y-2">
|
||||
<h1 className="text-4xl font-semibold tracking-tight">Roadmap and Progress</h1>
|
||||
<p className="text-neutral-600">
|
||||
Structured view from root `TODO.md` (single source of truth).
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<AdminShell
|
||||
role={role}
|
||||
activePath="/todo"
|
||||
badge="Admin App"
|
||||
title="Roadmap and Progress"
|
||||
description="Structured view from root TODO.md (single source of truth)."
|
||||
actions={
|
||||
<Link
|
||||
href="/"
|
||||
className="inline-flex rounded-md border border-neutral-300 px-4 py-2 text-sm font-medium hover:bg-neutral-100"
|
||||
>
|
||||
Back to dashboard
|
||||
</Link>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
}
|
||||
>
|
||||
<section className="rounded-xl border border-neutral-200 bg-neutral-50 p-5">
|
||||
<div className="mb-4 flex items-center justify-between gap-4">
|
||||
<p className="text-sm font-medium text-neutral-600">Weighted completion</p>
|
||||
@@ -607,6 +597,6 @@ export default async function AdminTodoPage(props: {
|
||||
{content}
|
||||
</pre>
|
||||
</details>
|
||||
</main>
|
||||
</AdminShell>
|
||||
)
|
||||
}
|
||||
|
||||
425
apps/admin/src/app/users/page.tsx
Normal file
425
apps/admin/src/app/users/page.tsx
Normal file
@@ -0,0 +1,425 @@
|
||||
import { hasPermission, normalizeRole, type Role } from "@cms/content/rbac"
|
||||
import { db } from "@cms/db"
|
||||
import { Button } from "@cms/ui/button"
|
||||
import { revalidatePath } from "next/cache"
|
||||
import { headers } from "next/headers"
|
||||
import { redirect } from "next/navigation"
|
||||
|
||||
import { AdminShell } from "@/components/admin-shell"
|
||||
import {
|
||||
auth,
|
||||
canDeleteUserAccount,
|
||||
createManagedUserAccount,
|
||||
enforceOwnerInvariant,
|
||||
} from "@/lib/auth/server"
|
||||
import { requirePermissionForRoute } from "@/lib/route-guards"
|
||||
|
||||
export const dynamic = "force-dynamic"
|
||||
|
||||
const MANAGED_ROLES: Role[] = ["admin", "editor", "manager"]
|
||||
|
||||
type SearchParamsInput = Record<string, string | string[] | undefined>
|
||||
|
||||
function readFirstValue(value: string | string[] | undefined): string | null {
|
||||
if (Array.isArray(value)) {
|
||||
return value[0] ?? null
|
||||
}
|
||||
|
||||
return value ?? null
|
||||
}
|
||||
|
||||
function readInputString(formData: FormData, field: string): string {
|
||||
const value = formData.get(field)
|
||||
return typeof value === "string" ? value.trim() : ""
|
||||
}
|
||||
|
||||
function redirectWithState(params: { notice?: string; error?: string }) {
|
||||
const query = new URLSearchParams()
|
||||
|
||||
if (params.notice) {
|
||||
query.set("notice", params.notice)
|
||||
}
|
||||
|
||||
if (params.error) {
|
||||
query.set("error", params.error)
|
||||
}
|
||||
|
||||
const value = query.toString()
|
||||
redirect(value ? `/users?${value}` : "/users")
|
||||
}
|
||||
|
||||
async function createUserAction(formData: FormData) {
|
||||
"use server"
|
||||
|
||||
await requirePermissionForRoute({
|
||||
nextPath: "/users",
|
||||
permission: "users:write",
|
||||
scope: "team",
|
||||
})
|
||||
|
||||
const role = normalizeRole(readInputString(formData, "role"))
|
||||
|
||||
if (!role || !MANAGED_ROLES.includes(role)) {
|
||||
return redirectWithState({ error: "Invalid role for managed user creation." })
|
||||
}
|
||||
|
||||
try {
|
||||
await createManagedUserAccount({
|
||||
email: readInputString(formData, "email"),
|
||||
username: readInputString(formData, "username") || undefined,
|
||||
name: readInputString(formData, "name"),
|
||||
password: readInputString(formData, "password"),
|
||||
role,
|
||||
})
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Failed to create user."
|
||||
redirectWithState({ error: message })
|
||||
}
|
||||
|
||||
revalidatePath("/users")
|
||||
redirectWithState({ notice: "User account created." })
|
||||
}
|
||||
|
||||
async function updateUserRoleAction(formData: FormData) {
|
||||
"use server"
|
||||
|
||||
await requirePermissionForRoute({
|
||||
nextPath: "/users",
|
||||
permission: "users:manage_roles",
|
||||
scope: "global",
|
||||
})
|
||||
|
||||
const userId = readInputString(formData, "userId")
|
||||
const role = normalizeRole(readInputString(formData, "role"))
|
||||
|
||||
if (!role || !MANAGED_ROLES.includes(role)) {
|
||||
return redirectWithState({ error: "Only admin/editor/manager can be assigned here." })
|
||||
}
|
||||
|
||||
const user = await db.user.findUnique({
|
||||
where: { id: userId },
|
||||
select: { id: true, isProtected: true, isSystem: true },
|
||||
})
|
||||
|
||||
if (!user) {
|
||||
return redirectWithState({ error: "User not found." })
|
||||
}
|
||||
|
||||
if (user.isProtected || user.isSystem) {
|
||||
return redirectWithState({ error: "Protected/system users cannot be role-edited." })
|
||||
}
|
||||
|
||||
try {
|
||||
await db.user.update({
|
||||
where: { id: userId },
|
||||
data: { role },
|
||||
})
|
||||
await enforceOwnerInvariant()
|
||||
} catch {
|
||||
redirectWithState({ error: "Failed to update user role." })
|
||||
}
|
||||
|
||||
revalidatePath("/users")
|
||||
redirectWithState({ notice: "User role updated." })
|
||||
}
|
||||
|
||||
async function updateUserBanAction(formData: FormData) {
|
||||
"use server"
|
||||
|
||||
await requirePermissionForRoute({
|
||||
nextPath: "/users",
|
||||
permission: "users:write",
|
||||
scope: "team",
|
||||
})
|
||||
|
||||
const userId = readInputString(formData, "userId")
|
||||
const isBanned = readInputString(formData, "isBanned") === "true"
|
||||
|
||||
const user = await db.user.findUnique({
|
||||
where: { id: userId },
|
||||
select: { id: true, isProtected: true, isSystem: true },
|
||||
})
|
||||
|
||||
if (!user) {
|
||||
return redirectWithState({ error: "User not found." })
|
||||
}
|
||||
|
||||
if ((user.isProtected || user.isSystem) && isBanned) {
|
||||
return redirectWithState({ error: "Protected/system users cannot be banned." })
|
||||
}
|
||||
|
||||
try {
|
||||
await db.user.update({
|
||||
where: { id: userId },
|
||||
data: { isBanned },
|
||||
})
|
||||
await enforceOwnerInvariant()
|
||||
} catch {
|
||||
redirectWithState({ error: "Failed to update user status." })
|
||||
}
|
||||
|
||||
revalidatePath("/users")
|
||||
redirectWithState({ notice: isBanned ? "User banned." : "User unbanned." })
|
||||
}
|
||||
|
||||
async function deleteUserAction(formData: FormData) {
|
||||
"use server"
|
||||
|
||||
await requirePermissionForRoute({
|
||||
nextPath: "/users",
|
||||
permission: "users:write",
|
||||
scope: "team",
|
||||
})
|
||||
|
||||
const userId = readInputString(formData, "userId")
|
||||
const isAllowed = await canDeleteUserAccount(userId)
|
||||
|
||||
if (!isAllowed) {
|
||||
return redirectWithState({
|
||||
error: "User cannot be deleted due to protection or owner constraints.",
|
||||
})
|
||||
}
|
||||
|
||||
try {
|
||||
await db.user.delete({
|
||||
where: { id: userId },
|
||||
})
|
||||
await enforceOwnerInvariant()
|
||||
} catch {
|
||||
redirectWithState({ error: "Failed to delete user." })
|
||||
}
|
||||
|
||||
revalidatePath("/users")
|
||||
redirectWithState({ notice: "User deleted." })
|
||||
}
|
||||
|
||||
export default async function UsersManagementPage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<SearchParamsInput>
|
||||
}) {
|
||||
const role = await requirePermissionForRoute({
|
||||
nextPath: "/users",
|
||||
permission: "users:read",
|
||||
scope: "own",
|
||||
})
|
||||
|
||||
const session = await auth.api
|
||||
.getSession({
|
||||
headers: await headers(),
|
||||
})
|
||||
.catch(() => null)
|
||||
const viewerId = session?.user?.id ?? null
|
||||
const canWriteUsers = hasPermission(role, "users:write", "team")
|
||||
const canManageRoles = hasPermission(role, "users:manage_roles", "global")
|
||||
const canReadGlobal = hasPermission(role, "users:read", "global")
|
||||
|
||||
const [resolvedSearchParams, users] = await Promise.all([
|
||||
searchParams,
|
||||
db.user.findMany({
|
||||
where: canReadGlobal
|
||||
? undefined
|
||||
: viewerId
|
||||
? {
|
||||
id: viewerId,
|
||||
}
|
||||
: {
|
||||
id: "__none__",
|
||||
},
|
||||
orderBy: [{ createdAt: "desc" }],
|
||||
select: {
|
||||
id: true,
|
||||
email: true,
|
||||
username: true,
|
||||
name: true,
|
||||
role: true,
|
||||
isBanned: true,
|
||||
isSystem: true,
|
||||
isHidden: true,
|
||||
isProtected: true,
|
||||
createdAt: true,
|
||||
},
|
||||
}),
|
||||
])
|
||||
|
||||
const notice = readFirstValue(resolvedSearchParams.notice)
|
||||
const error = readFirstValue(resolvedSearchParams.error)
|
||||
|
||||
return (
|
||||
<AdminShell
|
||||
role={role}
|
||||
activePath="/users"
|
||||
badge="Admin App"
|
||||
title="Users"
|
||||
description="Manage internal users, roles, and account status."
|
||||
>
|
||||
{notice ? (
|
||||
<section className="rounded-xl border border-emerald-300 bg-emerald-50 px-4 py-3 text-sm text-emerald-800">
|
||||
{notice}
|
||||
</section>
|
||||
) : null}
|
||||
{error ? (
|
||||
<section className="rounded-xl border border-red-300 bg-red-50 px-4 py-3 text-sm text-red-800">
|
||||
{error}
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
{canWriteUsers ? (
|
||||
<section className="rounded-xl border border-neutral-200 p-6">
|
||||
<h2 className="text-xl font-medium">Create managed user</h2>
|
||||
<form action={createUserAction} className="mt-4 grid gap-3 md:grid-cols-2 lg:grid-cols-3">
|
||||
<input
|
||||
name="name"
|
||||
required
|
||||
placeholder="Name"
|
||||
className="rounded border border-neutral-300 px-3 py-2 text-sm"
|
||||
/>
|
||||
<input
|
||||
name="email"
|
||||
required
|
||||
type="email"
|
||||
placeholder="Email"
|
||||
className="rounded border border-neutral-300 px-3 py-2 text-sm"
|
||||
/>
|
||||
<input
|
||||
name="username"
|
||||
placeholder="Username (optional)"
|
||||
className="rounded border border-neutral-300 px-3 py-2 text-sm"
|
||||
/>
|
||||
<input
|
||||
name="password"
|
||||
required
|
||||
type="password"
|
||||
placeholder="Temporary password"
|
||||
className="rounded border border-neutral-300 px-3 py-2 text-sm"
|
||||
/>
|
||||
<select
|
||||
name="role"
|
||||
defaultValue="editor"
|
||||
className="rounded border border-neutral-300 px-3 py-2 text-sm"
|
||||
>
|
||||
<option value="editor">editor</option>
|
||||
<option value="manager">manager</option>
|
||||
<option value="admin">admin</option>
|
||||
</select>
|
||||
<div className="md:col-span-2 lg:col-span-3">
|
||||
<Button type="submit">Create user</Button>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
<section className="rounded-xl border border-neutral-200 p-6">
|
||||
<h2 className="text-xl font-medium">User accounts</h2>
|
||||
<div className="mt-4 overflow-x-auto">
|
||||
<table className="min-w-full text-left text-sm">
|
||||
<thead className="text-xs uppercase tracking-wide text-neutral-500">
|
||||
<tr>
|
||||
<th className="py-2 pr-4">User</th>
|
||||
<th className="py-2 pr-4">Role</th>
|
||||
<th className="py-2 pr-4">Status</th>
|
||||
<th className="py-2 pr-4">Flags</th>
|
||||
<th className="py-2 pr-4">Created</th>
|
||||
<th className="py-2 pr-4">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{users.length === 0 ? (
|
||||
<tr>
|
||||
<td className="py-3 text-neutral-500" colSpan={6}>
|
||||
No users found.
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
users.map((user) => (
|
||||
<tr key={user.id} className="border-t border-neutral-200 align-top">
|
||||
<td className="py-3 pr-4">
|
||||
<p className="font-medium">{user.name}</p>
|
||||
<p className="text-xs text-neutral-600">{user.email}</p>
|
||||
<p className="text-xs text-neutral-500">@{user.username ?? "no-username"}</p>
|
||||
</td>
|
||||
<td className="py-3 pr-4">{user.role}</td>
|
||||
<td className="py-3 pr-4">{user.isBanned ? "banned" : "active"}</td>
|
||||
<td className="py-3 pr-4 text-xs text-neutral-600">
|
||||
{user.isProtected ? "protected " : ""}
|
||||
{user.isSystem ? "system " : ""}
|
||||
{user.isHidden ? "hidden" : ""}
|
||||
</td>
|
||||
<td className="py-3 pr-4 text-xs text-neutral-600">
|
||||
{user.createdAt.toLocaleString("en-US")}
|
||||
</td>
|
||||
<td className="py-3 pr-4">
|
||||
<div className="grid min-w-56 gap-2">
|
||||
{canManageRoles ? (
|
||||
<form action={updateUserRoleAction} className="flex gap-2">
|
||||
<input type="hidden" name="userId" value={user.id} />
|
||||
<select
|
||||
name="role"
|
||||
defaultValue={
|
||||
MANAGED_ROLES.includes(user.role as Role) ? user.role : "editor"
|
||||
}
|
||||
disabled={user.isProtected || user.isSystem}
|
||||
className="w-full rounded border border-neutral-300 px-2 py-1 text-xs"
|
||||
>
|
||||
<option value="editor">editor</option>
|
||||
<option value="manager">manager</option>
|
||||
<option value="admin">admin</option>
|
||||
</select>
|
||||
<Button
|
||||
type="submit"
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
disabled={user.isProtected || user.isSystem}
|
||||
>
|
||||
Role
|
||||
</Button>
|
||||
</form>
|
||||
) : null}
|
||||
|
||||
{canWriteUsers ? (
|
||||
<form action={updateUserBanAction} className="flex gap-2">
|
||||
<input type="hidden" name="userId" value={user.id} />
|
||||
<select
|
||||
name="isBanned"
|
||||
defaultValue={user.isBanned ? "true" : "false"}
|
||||
disabled={user.isProtected || user.isSystem}
|
||||
className="w-full rounded border border-neutral-300 px-2 py-1 text-xs"
|
||||
>
|
||||
<option value="false">active</option>
|
||||
<option value="true">banned</option>
|
||||
</select>
|
||||
<Button
|
||||
type="submit"
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
disabled={user.isProtected || user.isSystem}
|
||||
>
|
||||
Status
|
||||
</Button>
|
||||
</form>
|
||||
) : null}
|
||||
|
||||
{canWriteUsers ? (
|
||||
<form action={deleteUserAction}>
|
||||
<input type="hidden" name="userId" value={user.id} />
|
||||
<button
|
||||
type="submit"
|
||||
disabled={user.isProtected || user.isSystem}
|
||||
className="rounded border border-red-300 px-3 py-1.5 text-xs text-red-700 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
Delete user
|
||||
</button>
|
||||
</form>
|
||||
) : null}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
</AdminShell>
|
||||
)
|
||||
}
|
||||
70
apps/admin/src/app/welcome/page.test.tsx
Normal file
70
apps/admin/src/app/welcome/page.test.tsx
Normal file
@@ -0,0 +1,70 @@
|
||||
import type { ReactElement } from "react"
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest"
|
||||
|
||||
const { redirectMock, resolveRoleFromServerContextMock, hasOwnerUserMock } = vi.hoisted(() => ({
|
||||
redirectMock: vi.fn((path: string) => {
|
||||
throw new Error(`REDIRECT:${path}`)
|
||||
}),
|
||||
resolveRoleFromServerContextMock: vi.fn(),
|
||||
hasOwnerUserMock: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock("next/navigation", () => ({
|
||||
redirect: redirectMock,
|
||||
}))
|
||||
|
||||
vi.mock("@/lib/access-server", () => ({
|
||||
resolveRoleFromServerContext: resolveRoleFromServerContextMock,
|
||||
}))
|
||||
|
||||
vi.mock("@/lib/auth/server", () => ({
|
||||
hasOwnerUser: hasOwnerUserMock,
|
||||
}))
|
||||
|
||||
vi.mock("@/app/login/login-form", () => ({
|
||||
LoginForm: ({ mode }: { mode: string }) => ({ type: "login-form", props: { mode } }),
|
||||
}))
|
||||
|
||||
import WelcomePage from "./page"
|
||||
|
||||
function expectRedirect(call: () => Promise<unknown>, path: string) {
|
||||
return expect(call()).rejects.toThrow(`REDIRECT:${path}`)
|
||||
}
|
||||
|
||||
describe("welcome page", () => {
|
||||
beforeEach(() => {
|
||||
redirectMock.mockClear()
|
||||
resolveRoleFromServerContextMock.mockReset()
|
||||
hasOwnerUserMock.mockReset()
|
||||
})
|
||||
|
||||
it("redirects authenticated users to dashboard", async () => {
|
||||
resolveRoleFromServerContextMock.mockResolvedValue("admin")
|
||||
|
||||
await expectRedirect(
|
||||
() => WelcomePage({ searchParams: Promise.resolve({ next: "/media" }) }),
|
||||
"/",
|
||||
)
|
||||
})
|
||||
|
||||
it("redirects to login after owner exists", async () => {
|
||||
resolveRoleFromServerContextMock.mockResolvedValue(null)
|
||||
hasOwnerUserMock.mockResolvedValue(true)
|
||||
|
||||
await expectRedirect(
|
||||
() => WelcomePage({ searchParams: Promise.resolve({ next: "/media" }) }),
|
||||
"/login?next=%2Fmedia",
|
||||
)
|
||||
})
|
||||
|
||||
it("renders owner sign-up mode when owner is missing", async () => {
|
||||
resolveRoleFromServerContextMock.mockResolvedValue(null)
|
||||
hasOwnerUserMock.mockResolvedValue(false)
|
||||
|
||||
const page = (await WelcomePage({ searchParams: Promise.resolve({}) })) as ReactElement<{
|
||||
mode: string
|
||||
}>
|
||||
|
||||
expect(page.props.mode).toBe("signup-owner")
|
||||
})
|
||||
})
|
||||
41
apps/admin/src/components/admin-locale-switcher.tsx
Normal file
41
apps/admin/src/components/admin-locale-switcher.tsx
Normal file
@@ -0,0 +1,41 @@
|
||||
"use client"
|
||||
|
||||
import { type AppLocale, localeLabels, locales } from "@cms/i18n"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { useTransition } from "react"
|
||||
|
||||
import { ADMIN_LOCALE_COOKIE } from "@/i18n/shared"
|
||||
import { useAdminI18n, useAdminT } from "@/providers/admin-i18n-provider"
|
||||
|
||||
export function AdminLocaleSwitcher() {
|
||||
const router = useRouter()
|
||||
const [isPending, startTransition] = useTransition()
|
||||
const { locale } = useAdminI18n()
|
||||
const t = useAdminT()
|
||||
|
||||
return (
|
||||
<label className="inline-flex items-center gap-2 text-sm text-neutral-700">
|
||||
<span>{t("common.language", "Language")}</span>
|
||||
<select
|
||||
className="rounded-md border border-neutral-300 bg-white px-2 py-1 text-sm"
|
||||
value={locale}
|
||||
disabled={isPending}
|
||||
onChange={(event) => {
|
||||
const nextLocale = event.target.value as AppLocale
|
||||
// biome-ignore lint/suspicious/noDocumentCookie: locale preference is intentionally persisted client-side.
|
||||
document.cookie = `${ADMIN_LOCALE_COOKIE}=${nextLocale}; Path=/; Max-Age=31536000; SameSite=Lax`
|
||||
|
||||
startTransition(() => {
|
||||
router.refresh()
|
||||
})
|
||||
}}
|
||||
>
|
||||
{locales.map((value) => (
|
||||
<option key={value} value={value}>
|
||||
{t(`common.localeNames.${value}`, localeLabels[value])} ({localeLabels[value]})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
)
|
||||
}
|
||||
40
apps/admin/src/components/admin-section-placeholder.tsx
Normal file
40
apps/admin/src/components/admin-section-placeholder.tsx
Normal file
@@ -0,0 +1,40 @@
|
||||
import type { ReactNode } from "react"
|
||||
|
||||
type AdminSectionPlaceholderProps = {
|
||||
feature: string
|
||||
summary: string
|
||||
requiredPermission: string
|
||||
nextSteps: string[]
|
||||
children?: ReactNode
|
||||
}
|
||||
|
||||
export function AdminSectionPlaceholder({
|
||||
feature,
|
||||
summary,
|
||||
requiredPermission,
|
||||
nextSteps,
|
||||
children,
|
||||
}: AdminSectionPlaceholderProps) {
|
||||
return (
|
||||
<section className="space-y-5 rounded-xl border border-neutral-200 p-6">
|
||||
<div className="space-y-2">
|
||||
<h2 className="text-xl font-medium">{feature}</h2>
|
||||
<p className="text-sm text-neutral-600">{summary}</p>
|
||||
<p className="text-xs uppercase tracking-wide text-neutral-500">
|
||||
Required permission: {requiredPermission}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{children}
|
||||
|
||||
<div className="rounded-lg border border-neutral-200 bg-neutral-50 p-4">
|
||||
<p className="text-sm font-medium text-neutral-800">Planned next steps</p>
|
||||
<ul className="mt-2 list-disc space-y-1 pl-5 text-sm text-neutral-600">
|
||||
{nextSteps.map((step) => (
|
||||
<li key={step}>{step}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
128
apps/admin/src/components/admin-shell.tsx
Normal file
128
apps/admin/src/components/admin-shell.tsx
Normal file
@@ -0,0 +1,128 @@
|
||||
import { hasPermission, type Permission, type PermissionScope, type Role } from "@cms/content/rbac"
|
||||
import Link from "next/link"
|
||||
import type { ReactNode } from "react"
|
||||
|
||||
import { LogoutButton } from "@/app/logout-button"
|
||||
import { AdminLocaleSwitcher } from "@/components/admin-locale-switcher"
|
||||
import { getBuildInfo } from "@/lib/build-info"
|
||||
|
||||
type AdminShellProps = {
|
||||
role: Role
|
||||
activePath: string
|
||||
badge: string
|
||||
title: string
|
||||
description: string
|
||||
actions?: ReactNode
|
||||
children: ReactNode
|
||||
}
|
||||
|
||||
type NavItem = {
|
||||
href: string
|
||||
label: string
|
||||
permission: Permission
|
||||
scope: PermissionScope
|
||||
}
|
||||
|
||||
const navItems: NavItem[] = [
|
||||
{ href: "/", label: "Dashboard", permission: "dashboard:read", scope: "global" },
|
||||
{ href: "/pages", label: "Pages", permission: "pages:read", scope: "team" },
|
||||
{ href: "/navigation", label: "Navigation", permission: "navigation:read", scope: "team" },
|
||||
{ href: "/media", label: "Media", permission: "media:read", scope: "team" },
|
||||
{ href: "/portfolio", label: "Portfolio", permission: "media:read", scope: "team" },
|
||||
{ href: "/users", label: "Users", permission: "users:read", scope: "own" },
|
||||
{ href: "/commissions", label: "Commissions", permission: "commissions:read", scope: "own" },
|
||||
{ href: "/announcements", label: "Announcements", permission: "banner:read", scope: "global" },
|
||||
{ href: "/news", label: "News", permission: "news:read", scope: "team" },
|
||||
{ href: "/settings", label: "Settings", permission: "users:manage_roles", scope: "global" },
|
||||
{ href: "/todo", label: "Roadmap", permission: "roadmap:read", scope: "global" },
|
||||
]
|
||||
|
||||
function navItemClass(active: boolean): string {
|
||||
if (active) {
|
||||
return "bg-neutral-900 text-white border-neutral-900"
|
||||
}
|
||||
|
||||
return "bg-white text-neutral-700 border-neutral-300 hover:bg-neutral-100"
|
||||
}
|
||||
|
||||
function isActiveRoute(activePath: string, href: string): boolean {
|
||||
if (href === "/") {
|
||||
return activePath === "/"
|
||||
}
|
||||
|
||||
return activePath === href || activePath.startsWith(`${href}/`)
|
||||
}
|
||||
|
||||
export function AdminShell({
|
||||
role,
|
||||
activePath,
|
||||
badge,
|
||||
title,
|
||||
description,
|
||||
actions,
|
||||
children,
|
||||
}: AdminShellProps) {
|
||||
const buildInfo = getBuildInfo()
|
||||
|
||||
return (
|
||||
<div className="mx-auto flex min-h-screen w-full max-w-7xl gap-8 px-6 py-10">
|
||||
<aside className="sticky top-0 hidden h-fit w-64 shrink-0 space-y-4 lg:block">
|
||||
<div className="rounded-xl border border-neutral-200 bg-white p-4">
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-neutral-500">
|
||||
CMS Admin
|
||||
</p>
|
||||
<p className="mt-2 text-sm text-neutral-600">Role: {role}</p>
|
||||
</div>
|
||||
|
||||
<nav className="space-y-2">
|
||||
{navItems
|
||||
.filter((item) => hasPermission(role, item.permission, item.scope))
|
||||
.map((item) => (
|
||||
<Link
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
className={`block rounded-md border px-3 py-2 text-sm font-medium ${navItemClass(isActiveRoute(activePath, item.href))}`}
|
||||
>
|
||||
{item.label}
|
||||
</Link>
|
||||
))}
|
||||
</nav>
|
||||
</aside>
|
||||
|
||||
<div className="min-w-0 flex-1 space-y-8">
|
||||
<nav className="flex flex-wrap gap-2 lg:hidden">
|
||||
{navItems
|
||||
.filter((item) => hasPermission(role, item.permission, item.scope))
|
||||
.map((item) => (
|
||||
<Link
|
||||
key={`mobile-${item.href}`}
|
||||
href={item.href}
|
||||
className={`rounded-md border px-3 py-2 text-sm font-medium ${navItemClass(isActiveRoute(activePath, item.href))}`}
|
||||
>
|
||||
{item.label}
|
||||
</Link>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
<header className="space-y-3">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<p className="text-sm uppercase tracking-[0.2em] text-neutral-500">{badge}</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<AdminLocaleSwitcher />
|
||||
<LogoutButton />
|
||||
</div>
|
||||
</div>
|
||||
<h1 className="text-4xl font-semibold tracking-tight">{title}</h1>
|
||||
<p className="text-neutral-600">{description}</p>
|
||||
{actions ? <div className="flex flex-wrap items-center gap-3 pt-1">{actions}</div> : null}
|
||||
</header>
|
||||
|
||||
{children}
|
||||
|
||||
<footer className="border-t border-neutral-200 pt-4 text-xs text-neutral-500">
|
||||
Build v{buildInfo.version} +sha.{buildInfo.sha}
|
||||
</footer>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
19
apps/admin/src/components/media/flash-query-cleanup.tsx
Normal file
19
apps/admin/src/components/media/flash-query-cleanup.tsx
Normal file
@@ -0,0 +1,19 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect } from "react"
|
||||
|
||||
type FlashQueryCleanupProps = {
|
||||
enabled: boolean
|
||||
}
|
||||
|
||||
export function FlashQueryCleanup({ enabled }: FlashQueryCleanupProps) {
|
||||
useEffect(() => {
|
||||
if (!enabled) {
|
||||
return
|
||||
}
|
||||
|
||||
window.history.replaceState(window.history.state, "", "/media")
|
||||
}, [enabled])
|
||||
|
||||
return null
|
||||
}
|
||||
84
apps/admin/src/components/media/media-upload-form.test.tsx
Normal file
84
apps/admin/src/components/media/media-upload-form.test.tsx
Normal file
@@ -0,0 +1,84 @@
|
||||
/* @vitest-environment jsdom */
|
||||
|
||||
import { fireEvent, render, screen, waitFor } from "@testing-library/react"
|
||||
import { afterEach, describe, expect, it, vi } from "vitest"
|
||||
|
||||
import { MediaUploadForm } from "./media-upload-form"
|
||||
|
||||
describe("MediaUploadForm", () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it("updates accepted MIME list based on selected media type", () => {
|
||||
render(<MediaUploadForm />)
|
||||
|
||||
const fileInput = screen.getByLabelText("File") as HTMLInputElement
|
||||
const typeSelect = screen.getByLabelText("Type") as HTMLSelectElement
|
||||
|
||||
expect(fileInput.accept).toContain("image/jpeg")
|
||||
|
||||
fireEvent.change(typeSelect, { target: { value: "video" } })
|
||||
|
||||
expect(fileInput.accept).toContain("video/mp4")
|
||||
expect(fileInput.accept).not.toContain("image/jpeg")
|
||||
})
|
||||
|
||||
it("shows API error message when upload fails", async () => {
|
||||
const fetchMock = vi.spyOn(globalThis, "fetch").mockResolvedValue({
|
||||
ok: false,
|
||||
json: async () => ({ message: "Invalid file type" }),
|
||||
} as Response)
|
||||
|
||||
render(<MediaUploadForm />)
|
||||
|
||||
const form = screen.getByRole("button", { name: "Upload media" }).closest("form")
|
||||
|
||||
if (!form) {
|
||||
throw new Error("Upload form not found")
|
||||
}
|
||||
|
||||
const fileInput = screen.getByLabelText("File") as HTMLInputElement
|
||||
fireEvent.change(fileInput, {
|
||||
target: {
|
||||
files: [new File(["x"], "demo.png", { type: "image/png" })],
|
||||
},
|
||||
})
|
||||
|
||||
fireEvent.submit(form)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText("Invalid file type")).not.toBeNull()
|
||||
})
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
"/api/media/upload",
|
||||
expect.objectContaining({ method: "POST" }),
|
||||
)
|
||||
})
|
||||
|
||||
it("shows network error message when request throws", async () => {
|
||||
vi.spyOn(globalThis, "fetch").mockRejectedValue(new Error("network down"))
|
||||
|
||||
render(<MediaUploadForm />)
|
||||
|
||||
const form = screen.getByRole("button", { name: "Upload media" }).closest("form")
|
||||
|
||||
if (!form) {
|
||||
throw new Error("Upload form not found")
|
||||
}
|
||||
|
||||
const fileInput = screen.getByLabelText("File") as HTMLInputElement
|
||||
fireEvent.change(fileInput, {
|
||||
target: {
|
||||
files: [new File(["x"], "demo.png", { type: "image/png" })],
|
||||
},
|
||||
})
|
||||
|
||||
fireEvent.submit(form)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText("Upload request failed. Please retry.")).not.toBeNull()
|
||||
})
|
||||
})
|
||||
})
|
||||
213
apps/admin/src/components/media/media-upload-form.tsx
Normal file
213
apps/admin/src/components/media/media-upload-form.tsx
Normal file
@@ -0,0 +1,213 @@
|
||||
"use client"
|
||||
|
||||
import { Button } from "@cms/ui/button"
|
||||
import { type FormEvent, useState } from "react"
|
||||
|
||||
type MediaType = "artwork" | "banner" | "promotion" | "video" | "gif" | "generic"
|
||||
|
||||
const ACCEPT_BY_TYPE: Record<MediaType, string> = {
|
||||
artwork: "image/jpeg,image/png,image/webp,image/avif,image/gif",
|
||||
banner: "image/jpeg,image/png,image/webp,image/avif",
|
||||
promotion: "image/jpeg,image/png,image/webp,image/avif,image/gif,video/mp4,video/webm",
|
||||
video: "video/mp4,video/webm,video/quicktime",
|
||||
gif: "image/gif",
|
||||
generic: "image/*,video/*",
|
||||
}
|
||||
|
||||
export function MediaUploadForm() {
|
||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [mediaType, setMediaType] = useState<MediaType>("artwork")
|
||||
|
||||
async function handleSubmit(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault()
|
||||
const form = event.currentTarget
|
||||
const formData = new FormData(form)
|
||||
|
||||
setError(null)
|
||||
setIsSubmitting(true)
|
||||
|
||||
try {
|
||||
const response = await fetch("/api/media/upload", {
|
||||
method: "POST",
|
||||
body: formData,
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const payload = (await response.json().catch(() => null)) as {
|
||||
message?: string
|
||||
} | null
|
||||
|
||||
setError(payload?.message ?? "Upload failed. Please verify file and metadata.")
|
||||
return
|
||||
}
|
||||
|
||||
const payload = (await response.json().catch(() => null)) as {
|
||||
notice?: string
|
||||
provider?: "s3" | "local"
|
||||
warning?: string
|
||||
} | null
|
||||
|
||||
const notice = payload?.notice ?? "Media uploaded."
|
||||
const provider = payload?.provider ?? "local"
|
||||
const warning = payload?.warning
|
||||
const warningQuery = warning ? `&warning=${encodeURIComponent(warning)}` : ""
|
||||
window.location.href = `/media?notice=${encodeURIComponent(notice)}&uploadedVia=${encodeURIComponent(provider)}${warningQuery}`
|
||||
} catch {
|
||||
setError("Upload request failed. Please retry.")
|
||||
} finally {
|
||||
setIsSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="mt-4 space-y-3">
|
||||
{error ? (
|
||||
<p className="rounded border border-red-300 bg-red-50 px-3 py-2 text-sm text-red-700">
|
||||
{error}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
<div className="grid gap-3 md:grid-cols-2">
|
||||
<label className="space-y-1">
|
||||
<span className="text-xs text-neutral-600">Title</span>
|
||||
<input
|
||||
name="title"
|
||||
placeholder="Optional (defaults to file name)"
|
||||
className="w-full rounded border border-neutral-300 px-3 py-2 text-sm"
|
||||
/>
|
||||
</label>
|
||||
<label className="space-y-1">
|
||||
<span className="text-xs text-neutral-600">Type</span>
|
||||
<select
|
||||
name="type"
|
||||
value={mediaType}
|
||||
onChange={(event) => setMediaType(event.target.value as MediaType)}
|
||||
className="w-full rounded border border-neutral-300 px-3 py-2 text-sm"
|
||||
>
|
||||
<option value="artwork">artwork</option>
|
||||
<option value="banner">banner</option>
|
||||
<option value="promotion">promotion</option>
|
||||
<option value="video">video</option>
|
||||
<option value="gif">gif</option>
|
||||
<option value="generic">generic</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<label className="space-y-1">
|
||||
<span className="text-xs text-neutral-600">File</span>
|
||||
<input
|
||||
name="file"
|
||||
type="file"
|
||||
required
|
||||
accept={ACCEPT_BY_TYPE[mediaType]}
|
||||
className="w-full rounded border border-neutral-300 px-3 py-2 text-sm"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="space-y-1">
|
||||
<span className="text-xs text-neutral-600">Description</span>
|
||||
<textarea
|
||||
name="description"
|
||||
rows={3}
|
||||
className="w-full rounded border border-neutral-300 px-3 py-2 text-sm"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<div className="grid gap-3 md:grid-cols-2">
|
||||
<label className="space-y-1">
|
||||
<span className="text-xs text-neutral-600">Alt text</span>
|
||||
<input
|
||||
name="altText"
|
||||
className="w-full rounded border border-neutral-300 px-3 py-2 text-sm"
|
||||
/>
|
||||
</label>
|
||||
<label className="space-y-1">
|
||||
<span className="text-xs text-neutral-600">Author</span>
|
||||
<input
|
||||
name="author"
|
||||
className="w-full rounded border border-neutral-300 px-3 py-2 text-sm"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-3 md:grid-cols-2">
|
||||
<label className="space-y-1">
|
||||
<span className="text-xs text-neutral-600">Source</span>
|
||||
<input
|
||||
name="source"
|
||||
className="w-full rounded border border-neutral-300 px-3 py-2 text-sm"
|
||||
/>
|
||||
</label>
|
||||
<label className="space-y-1">
|
||||
<span className="text-xs text-neutral-600">Copyright</span>
|
||||
<input
|
||||
name="copyright"
|
||||
className="w-full rounded border border-neutral-300 px-3 py-2 text-sm"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-3 md:grid-cols-2">
|
||||
<label className="space-y-1">
|
||||
<span className="text-xs text-neutral-600">License type</span>
|
||||
<input
|
||||
name="licenseType"
|
||||
placeholder="e.g. CC BY-NC 4.0"
|
||||
className="w-full rounded border border-neutral-300 px-3 py-2 text-sm"
|
||||
/>
|
||||
</label>
|
||||
<label className="space-y-1">
|
||||
<span className="text-xs text-neutral-600">License URL</span>
|
||||
<input
|
||||
name="licenseUrl"
|
||||
className="w-full rounded border border-neutral-300 px-3 py-2 text-sm"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-3 md:grid-cols-2">
|
||||
<label className="space-y-1">
|
||||
<span className="text-xs text-neutral-600">Usage context</span>
|
||||
<input
|
||||
name="usageContext"
|
||||
placeholder="e.g. homepage hero, social preview"
|
||||
className="w-full rounded border border-neutral-300 px-3 py-2 text-sm"
|
||||
/>
|
||||
</label>
|
||||
<label className="space-y-1">
|
||||
<span className="text-xs text-neutral-600">Location</span>
|
||||
<input
|
||||
name="location"
|
||||
placeholder="e.g. Berlin studio"
|
||||
className="w-full rounded border border-neutral-300 px-3 py-2 text-sm"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<label className="space-y-1">
|
||||
<span className="text-xs text-neutral-600">Captured at</span>
|
||||
<input
|
||||
name="capturedAt"
|
||||
type="datetime-local"
|
||||
className="w-full rounded border border-neutral-300 px-3 py-2 text-sm"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="space-y-1">
|
||||
<span className="text-xs text-neutral-600">Tags (comma-separated)</span>
|
||||
<input name="tags" className="w-full rounded border border-neutral-300 px-3 py-2 text-sm" />
|
||||
</label>
|
||||
|
||||
<label className="inline-flex items-center gap-2 text-sm text-neutral-700">
|
||||
<input name="isPublished" type="checkbox" value="true" className="size-4" />
|
||||
Publish immediately
|
||||
</label>
|
||||
|
||||
<Button type="submit" disabled={isSubmitting}>
|
||||
{isSubmitting ? "Uploading..." : "Upload media"}
|
||||
</Button>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
/* @vitest-environment jsdom */
|
||||
|
||||
import { render, screen } from "@testing-library/react"
|
||||
import { describe, expect, it, vi } from "vitest"
|
||||
|
||||
import { CreateMenuForm } from "./create-menu-form"
|
||||
|
||||
describe("CreateMenuForm", () => {
|
||||
it("renders defaults for location and visibility", () => {
|
||||
render(<CreateMenuForm action={vi.fn()} />)
|
||||
|
||||
const location = screen.getByLabelText("Location") as HTMLInputElement
|
||||
expect(location.value).toBe("primary")
|
||||
|
||||
const visible = screen.getByLabelText("Visible") as HTMLInputElement
|
||||
expect(visible.checked).toBe(true)
|
||||
|
||||
expect(screen.getByRole("button", { name: "Create menu" })).not.toBeNull()
|
||||
})
|
||||
})
|
||||
41
apps/admin/src/components/navigation/create-menu-form.tsx
Normal file
41
apps/admin/src/components/navigation/create-menu-form.tsx
Normal file
@@ -0,0 +1,41 @@
|
||||
import { Button } from "@cms/ui/button"
|
||||
|
||||
type CreateMenuFormProps = {
|
||||
action: (formData: FormData) => void | Promise<void>
|
||||
}
|
||||
|
||||
export function CreateMenuForm({ action }: CreateMenuFormProps) {
|
||||
return (
|
||||
<form action={action} className="mt-4 space-y-3">
|
||||
<label className="space-y-1">
|
||||
<span className="text-xs text-neutral-600">Name</span>
|
||||
<input
|
||||
name="name"
|
||||
required
|
||||
className="w-full rounded border border-neutral-300 px-3 py-2 text-sm"
|
||||
/>
|
||||
</label>
|
||||
<label className="space-y-1">
|
||||
<span className="text-xs text-neutral-600">Slug</span>
|
||||
<input
|
||||
name="slug"
|
||||
required
|
||||
className="w-full rounded border border-neutral-300 px-3 py-2 text-sm"
|
||||
/>
|
||||
</label>
|
||||
<label className="space-y-1">
|
||||
<span className="text-xs text-neutral-600">Location</span>
|
||||
<input
|
||||
name="location"
|
||||
defaultValue="primary"
|
||||
className="w-full rounded border border-neutral-300 px-3 py-2 text-sm"
|
||||
/>
|
||||
</label>
|
||||
<label className="inline-flex items-center gap-2 text-sm text-neutral-700">
|
||||
<input name="isVisible" type="checkbox" value="true" defaultChecked className="size-4" />
|
||||
Visible
|
||||
</label>
|
||||
<Button type="submit">Create menu</Button>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
/* @vitest-environment jsdom */
|
||||
|
||||
import { render, screen } from "@testing-library/react"
|
||||
import { describe, expect, it, vi } from "vitest"
|
||||
|
||||
import { CreateNavigationItemForm } from "./create-navigation-item-form"
|
||||
|
||||
describe("CreateNavigationItemForm", () => {
|
||||
it("renders menu/page options and defaults", () => {
|
||||
render(
|
||||
<CreateNavigationItemForm
|
||||
action={vi.fn()}
|
||||
menus={[{ id: "menu-1", name: "Primary", location: "header" }]}
|
||||
pages={[{ id: "page-1", title: "Home", slug: "home" }]}
|
||||
/>,
|
||||
)
|
||||
|
||||
const menu = screen.getByLabelText("Menu") as HTMLSelectElement
|
||||
expect(menu.options.length).toBe(1)
|
||||
expect(menu.value).toBe("menu-1")
|
||||
|
||||
const page = screen.getByLabelText("Linked page") as HTMLSelectElement
|
||||
expect(page.options.length).toBe(2)
|
||||
expect(page.options[0]?.value).toBe("")
|
||||
|
||||
const sortOrder = screen.getByLabelText("Sort order") as HTMLInputElement
|
||||
expect(sortOrder.value).toBe("0")
|
||||
|
||||
const visible = screen.getByLabelText("Visible") as HTMLInputElement
|
||||
expect(visible.checked).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,94 @@
|
||||
import { Button } from "@cms/ui/button"
|
||||
|
||||
type MenuOption = {
|
||||
id: string
|
||||
name: string
|
||||
location: string
|
||||
}
|
||||
|
||||
type PageOption = {
|
||||
id: string
|
||||
title: string
|
||||
slug: string
|
||||
}
|
||||
|
||||
type CreateNavigationItemFormProps = {
|
||||
action: (formData: FormData) => void | Promise<void>
|
||||
menus: MenuOption[]
|
||||
pages: PageOption[]
|
||||
}
|
||||
|
||||
export function CreateNavigationItemForm({ action, menus, pages }: CreateNavigationItemFormProps) {
|
||||
return (
|
||||
<form action={action} className="mt-4 space-y-3">
|
||||
<label className="space-y-1">
|
||||
<span className="text-xs text-neutral-600">Menu</span>
|
||||
<select
|
||||
name="menuId"
|
||||
className="w-full rounded border border-neutral-300 px-3 py-2 text-sm"
|
||||
>
|
||||
{menus.map((menu) => (
|
||||
<option key={menu.id} value={menu.id}>
|
||||
{menu.name} ({menu.location})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label className="space-y-1">
|
||||
<span className="text-xs text-neutral-600">Label</span>
|
||||
<input
|
||||
name="label"
|
||||
required
|
||||
className="w-full rounded border border-neutral-300 px-3 py-2 text-sm"
|
||||
/>
|
||||
</label>
|
||||
<div className="grid gap-3 md:grid-cols-2">
|
||||
<label className="space-y-1">
|
||||
<span className="text-xs text-neutral-600">Custom href</span>
|
||||
<input
|
||||
name="href"
|
||||
className="w-full rounded border border-neutral-300 px-3 py-2 text-sm"
|
||||
/>
|
||||
</label>
|
||||
<label className="space-y-1">
|
||||
<span className="text-xs text-neutral-600">Linked page</span>
|
||||
<select
|
||||
name="pageId"
|
||||
className="w-full rounded border border-neutral-300 px-3 py-2 text-sm"
|
||||
>
|
||||
<option value="">(none)</option>
|
||||
{pages.map((page) => (
|
||||
<option key={page.id} value={page.id}>
|
||||
{page.title} (/{page.slug})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
<div className="grid gap-3 md:grid-cols-2">
|
||||
<label className="space-y-1">
|
||||
<span className="text-xs text-neutral-600">Parent item id</span>
|
||||
<input
|
||||
name="parentId"
|
||||
className="w-full rounded border border-neutral-300 px-3 py-2 text-sm"
|
||||
/>
|
||||
</label>
|
||||
<label className="space-y-1">
|
||||
<span className="text-xs text-neutral-600">Sort order</span>
|
||||
<input
|
||||
name="sortOrder"
|
||||
defaultValue="0"
|
||||
type="number"
|
||||
min={0}
|
||||
className="w-full rounded border border-neutral-300 px-3 py-2 text-sm"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<label className="inline-flex items-center gap-2 text-sm text-neutral-700">
|
||||
<input name="isVisible" type="checkbox" value="true" defaultChecked className="size-4" />
|
||||
Visible
|
||||
</label>
|
||||
<Button type="submit">Create item</Button>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
23
apps/admin/src/components/pages/create-page-form.test.tsx
Normal file
23
apps/admin/src/components/pages/create-page-form.test.tsx
Normal file
@@ -0,0 +1,23 @@
|
||||
/* @vitest-environment jsdom */
|
||||
|
||||
import { render, screen } from "@testing-library/react"
|
||||
import { describe, expect, it, vi } from "vitest"
|
||||
|
||||
import { CreatePageForm } from "./create-page-form"
|
||||
|
||||
describe("CreatePageForm", () => {
|
||||
it("renders required fields and draft default status", () => {
|
||||
render(<CreatePageForm action={vi.fn()} />)
|
||||
|
||||
expect((screen.getByLabelText("Title") as HTMLInputElement).name).toBe("title")
|
||||
expect((screen.getByLabelText("Slug") as HTMLInputElement).name).toBe("slug")
|
||||
const contentField = document.querySelector('input[name="content"]') as HTMLInputElement | null
|
||||
expect(contentField).not.toBeNull()
|
||||
expect(contentField?.value.startsWith("[")).toBe(true)
|
||||
|
||||
const status = screen.getByLabelText("Status") as HTMLSelectElement
|
||||
expect(status.value).toBe("draft")
|
||||
|
||||
expect(screen.getByRole("button", { name: "Create page" })).not.toBeNull()
|
||||
})
|
||||
})
|
||||
83
apps/admin/src/components/pages/create-page-form.tsx
Normal file
83
apps/admin/src/components/pages/create-page-form.tsx
Normal file
@@ -0,0 +1,83 @@
|
||||
import { serializePageBlocks } from "@cms/content"
|
||||
import { Button } from "@cms/ui/button"
|
||||
|
||||
import { PageBlockEditor } from "./page-block-editor"
|
||||
|
||||
type CreatePageFormProps = {
|
||||
action: (formData: FormData) => void | Promise<void>
|
||||
}
|
||||
|
||||
export function CreatePageForm({ action }: CreatePageFormProps) {
|
||||
return (
|
||||
<form action={action} className="mt-4 space-y-3">
|
||||
<div className="grid gap-3 md:grid-cols-3">
|
||||
<label className="space-y-1 md:col-span-2">
|
||||
<span className="text-xs text-neutral-600">Title</span>
|
||||
<input
|
||||
name="title"
|
||||
required
|
||||
className="w-full rounded border border-neutral-300 px-3 py-2 text-sm"
|
||||
/>
|
||||
</label>
|
||||
<label className="space-y-1">
|
||||
<span className="text-xs text-neutral-600">Status</span>
|
||||
<select
|
||||
name="status"
|
||||
defaultValue="draft"
|
||||
className="w-full rounded border border-neutral-300 px-3 py-2 text-sm"
|
||||
>
|
||||
<option value="draft">draft</option>
|
||||
<option value="published">published</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<label className="space-y-1">
|
||||
<span className="text-xs text-neutral-600">Slug</span>
|
||||
<input
|
||||
name="slug"
|
||||
required
|
||||
className="w-full rounded border border-neutral-300 px-3 py-2 text-sm"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="space-y-1">
|
||||
<span className="text-xs text-neutral-600">Summary</span>
|
||||
<input
|
||||
name="summary"
|
||||
className="w-full rounded border border-neutral-300 px-3 py-2 text-sm"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<PageBlockEditor
|
||||
name="content"
|
||||
initialContent={serializePageBlocks([
|
||||
{
|
||||
id: "initial-rich-text",
|
||||
type: "rich_text",
|
||||
body: "",
|
||||
},
|
||||
])}
|
||||
/>
|
||||
|
||||
<div className="grid gap-3 md:grid-cols-2">
|
||||
<label className="space-y-1">
|
||||
<span className="text-xs text-neutral-600">SEO title</span>
|
||||
<input
|
||||
name="seoTitle"
|
||||
className="w-full rounded border border-neutral-300 px-3 py-2 text-sm"
|
||||
/>
|
||||
</label>
|
||||
<label className="space-y-1">
|
||||
<span className="text-xs text-neutral-600">SEO description</span>
|
||||
<input
|
||||
name="seoDescription"
|
||||
className="w-full rounded border border-neutral-300 px-3 py-2 text-sm"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<Button type="submit">Create page</Button>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
410
apps/admin/src/components/pages/page-block-editor.tsx
Normal file
410
apps/admin/src/components/pages/page-block-editor.tsx
Normal file
@@ -0,0 +1,410 @@
|
||||
"use client"
|
||||
|
||||
import { type PageBlock, type PageBlocks, parsePageBlocks, serializePageBlocks } from "@cms/content"
|
||||
import { useMemo, useState } from "react"
|
||||
|
||||
type PageBlockEditorProps = {
|
||||
name: string
|
||||
initialContent: string
|
||||
label?: string
|
||||
}
|
||||
|
||||
function randomId(prefix: string): string {
|
||||
return `${prefix}-${Math.random().toString(36).slice(2, 10)}`
|
||||
}
|
||||
|
||||
function normalizeInitialBlocks(initialContent: string): PageBlocks {
|
||||
if (!initialContent.trim()) {
|
||||
return [
|
||||
{
|
||||
id: randomId("rich"),
|
||||
type: "rich_text",
|
||||
body: "",
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
try {
|
||||
return parsePageBlocks(initialContent)
|
||||
} catch {
|
||||
return [
|
||||
{
|
||||
id: randomId("rich"),
|
||||
type: "rich_text",
|
||||
body: initialContent,
|
||||
},
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
function updateBlock(blocks: PageBlocks, blockId: string, next: Partial<PageBlock>): PageBlocks {
|
||||
return blocks.map((block) =>
|
||||
block.id === blockId ? ({ ...block, ...next } as PageBlock) : block,
|
||||
)
|
||||
}
|
||||
|
||||
function moveBlock(blocks: PageBlocks, blockId: string, direction: "up" | "down"): PageBlocks {
|
||||
const index = blocks.findIndex((entry) => entry.id === blockId)
|
||||
|
||||
if (index < 0) {
|
||||
return blocks
|
||||
}
|
||||
|
||||
const nextIndex = direction === "up" ? index - 1 : index + 1
|
||||
if (nextIndex < 0 || nextIndex >= blocks.length) {
|
||||
return blocks
|
||||
}
|
||||
|
||||
const next = [...blocks]
|
||||
const current = next[index]
|
||||
next[index] = next[nextIndex]
|
||||
next[nextIndex] = current
|
||||
return next
|
||||
}
|
||||
|
||||
export function PageBlockEditor({
|
||||
name,
|
||||
initialContent,
|
||||
label = "Page Blocks",
|
||||
}: PageBlockEditorProps) {
|
||||
const [blocks, setBlocks] = useState<PageBlocks>(() => normalizeInitialBlocks(initialContent))
|
||||
|
||||
const serialized = useMemo(() => serializePageBlocks(blocks), [blocks])
|
||||
|
||||
function addBlock(type: PageBlock["type"]) {
|
||||
if (type === "hero") {
|
||||
setBlocks((prev) => [
|
||||
...prev,
|
||||
{
|
||||
id: randomId("hero"),
|
||||
type,
|
||||
heading: "Hero heading",
|
||||
subheading: null,
|
||||
ctaHref: null,
|
||||
ctaLabel: null,
|
||||
},
|
||||
])
|
||||
return
|
||||
}
|
||||
|
||||
if (type === "rich_text") {
|
||||
setBlocks((prev) => [...prev, { id: randomId("rich"), type, body: "" }])
|
||||
return
|
||||
}
|
||||
|
||||
if (type === "gallery") {
|
||||
setBlocks((prev) => [...prev, { id: randomId("gallery"), type, title: null, imageIds: [] }])
|
||||
return
|
||||
}
|
||||
|
||||
if (type === "cta") {
|
||||
setBlocks((prev) => [
|
||||
...prev,
|
||||
{ id: randomId("cta"), type, label: "Open", href: "/", variant: "primary" },
|
||||
])
|
||||
return
|
||||
}
|
||||
|
||||
if (type === "form") {
|
||||
setBlocks((prev) => [
|
||||
...prev,
|
||||
{ id: randomId("form"), type, formKey: "contact", title: null, description: null },
|
||||
])
|
||||
return
|
||||
}
|
||||
|
||||
setBlocks((prev) => [
|
||||
...prev,
|
||||
{ id: randomId("price"), type: "price_cards", title: null, cards: [] },
|
||||
])
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-3 rounded border border-neutral-200 p-3">
|
||||
<input type="hidden" name={name} value={serialized} />
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="text-xs text-neutral-600">{label}</span>
|
||||
<button
|
||||
type="button"
|
||||
className="rounded border px-2 py-1 text-xs"
|
||||
onClick={() => addBlock("hero")}
|
||||
>
|
||||
+ Hero
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="rounded border px-2 py-1 text-xs"
|
||||
onClick={() => addBlock("rich_text")}
|
||||
>
|
||||
+ Rich text
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="rounded border px-2 py-1 text-xs"
|
||||
onClick={() => addBlock("gallery")}
|
||||
>
|
||||
+ Gallery
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="rounded border px-2 py-1 text-xs"
|
||||
onClick={() => addBlock("cta")}
|
||||
>
|
||||
+ CTA
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="rounded border px-2 py-1 text-xs"
|
||||
onClick={() => addBlock("form")}
|
||||
>
|
||||
+ Form
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="rounded border px-2 py-1 text-xs"
|
||||
onClick={() => addBlock("price_cards")}
|
||||
>
|
||||
+ Price cards
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
{blocks.map((block, index) => (
|
||||
<article key={block.id} className="space-y-2 rounded border border-neutral-200 p-3">
|
||||
<div className="flex items-center justify-between text-xs text-neutral-600">
|
||||
<span>
|
||||
#{index + 1} {block.type}
|
||||
</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
className="rounded border px-2 py-1"
|
||||
onClick={() => setBlocks((prev) => moveBlock(prev, block.id, "up"))}
|
||||
>
|
||||
Up
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="rounded border px-2 py-1"
|
||||
onClick={() => setBlocks((prev) => moveBlock(prev, block.id, "down"))}
|
||||
>
|
||||
Down
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="rounded border px-2 py-1"
|
||||
onClick={() => setBlocks((prev) => prev.filter((entry) => entry.id !== block.id))}
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{block.type === "hero" ? (
|
||||
<div className="grid gap-2 md:grid-cols-2">
|
||||
<input
|
||||
value={block.heading}
|
||||
onChange={(event) =>
|
||||
setBlocks((prev) =>
|
||||
updateBlock(prev, block.id, { heading: event.target.value }),
|
||||
)
|
||||
}
|
||||
placeholder="Heading"
|
||||
className="rounded border border-neutral-300 px-2 py-1 text-sm"
|
||||
/>
|
||||
<input
|
||||
value={block.subheading ?? ""}
|
||||
onChange={(event) =>
|
||||
setBlocks((prev) =>
|
||||
updateBlock(prev, block.id, { subheading: event.target.value || null }),
|
||||
)
|
||||
}
|
||||
placeholder="Subheading"
|
||||
className="rounded border border-neutral-300 px-2 py-1 text-sm"
|
||||
/>
|
||||
<input
|
||||
value={block.ctaLabel ?? ""}
|
||||
onChange={(event) =>
|
||||
setBlocks((prev) =>
|
||||
updateBlock(prev, block.id, { ctaLabel: event.target.value || null }),
|
||||
)
|
||||
}
|
||||
placeholder="CTA label"
|
||||
className="rounded border border-neutral-300 px-2 py-1 text-sm"
|
||||
/>
|
||||
<input
|
||||
value={block.ctaHref ?? ""}
|
||||
onChange={(event) =>
|
||||
setBlocks((prev) =>
|
||||
updateBlock(prev, block.id, { ctaHref: event.target.value || null }),
|
||||
)
|
||||
}
|
||||
placeholder="CTA href"
|
||||
className="rounded border border-neutral-300 px-2 py-1 text-sm"
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{block.type === "rich_text" ? (
|
||||
<textarea
|
||||
rows={5}
|
||||
value={block.body}
|
||||
onChange={(event) =>
|
||||
setBlocks((prev) => updateBlock(prev, block.id, { body: event.target.value }))
|
||||
}
|
||||
placeholder="Text"
|
||||
className="w-full rounded border border-neutral-300 px-2 py-1 text-sm"
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{block.type === "gallery" ? (
|
||||
<div className="space-y-2">
|
||||
<input
|
||||
value={block.title ?? ""}
|
||||
onChange={(event) =>
|
||||
setBlocks((prev) =>
|
||||
updateBlock(prev, block.id, { title: event.target.value || null }),
|
||||
)
|
||||
}
|
||||
placeholder="Gallery title"
|
||||
className="w-full rounded border border-neutral-300 px-2 py-1 text-sm"
|
||||
/>
|
||||
<textarea
|
||||
rows={3}
|
||||
value={block.imageIds.join(",")}
|
||||
onChange={(event) =>
|
||||
setBlocks((prev) =>
|
||||
updateBlock(prev, block.id, {
|
||||
imageIds: event.target.value
|
||||
.split(",")
|
||||
.map((entry) => entry.trim())
|
||||
.filter((entry) => entry.length > 0),
|
||||
}),
|
||||
)
|
||||
}
|
||||
placeholder="Media asset IDs (comma separated UUIDs)"
|
||||
className="w-full rounded border border-neutral-300 px-2 py-1 text-sm"
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{block.type === "cta" ? (
|
||||
<div className="grid gap-2 md:grid-cols-2">
|
||||
<input
|
||||
value={block.label}
|
||||
onChange={(event) =>
|
||||
setBlocks((prev) => updateBlock(prev, block.id, { label: event.target.value }))
|
||||
}
|
||||
placeholder="Button label"
|
||||
className="rounded border border-neutral-300 px-2 py-1 text-sm"
|
||||
/>
|
||||
<input
|
||||
value={block.href}
|
||||
onChange={(event) =>
|
||||
setBlocks((prev) => updateBlock(prev, block.id, { href: event.target.value }))
|
||||
}
|
||||
placeholder="Link href"
|
||||
className="rounded border border-neutral-300 px-2 py-1 text-sm"
|
||||
/>
|
||||
<select
|
||||
value={block.variant}
|
||||
onChange={(event) =>
|
||||
setBlocks((prev) =>
|
||||
updateBlock(prev, block.id, {
|
||||
variant: event.target.value as "primary" | "secondary",
|
||||
}),
|
||||
)
|
||||
}
|
||||
className="rounded border border-neutral-300 px-2 py-1 text-sm"
|
||||
>
|
||||
<option value="primary">Primary</option>
|
||||
<option value="secondary">Secondary</option>
|
||||
</select>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{block.type === "form" ? (
|
||||
<div className="space-y-2">
|
||||
<input
|
||||
value={block.formKey}
|
||||
onChange={(event) =>
|
||||
setBlocks((prev) =>
|
||||
updateBlock(prev, block.id, { formKey: event.target.value }),
|
||||
)
|
||||
}
|
||||
placeholder="Form key (e.g. contact, commission)"
|
||||
className="w-full rounded border border-neutral-300 px-2 py-1 text-sm"
|
||||
/>
|
||||
<input
|
||||
value={block.title ?? ""}
|
||||
onChange={(event) =>
|
||||
setBlocks((prev) =>
|
||||
updateBlock(prev, block.id, { title: event.target.value || null }),
|
||||
)
|
||||
}
|
||||
placeholder="Form title"
|
||||
className="w-full rounded border border-neutral-300 px-2 py-1 text-sm"
|
||||
/>
|
||||
<textarea
|
||||
rows={2}
|
||||
value={block.description ?? ""}
|
||||
onChange={(event) =>
|
||||
setBlocks((prev) =>
|
||||
updateBlock(prev, block.id, { description: event.target.value || null }),
|
||||
)
|
||||
}
|
||||
placeholder="Form description"
|
||||
className="w-full rounded border border-neutral-300 px-2 py-1 text-sm"
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{block.type === "price_cards" ? (
|
||||
<div className="space-y-2">
|
||||
<input
|
||||
value={block.title ?? ""}
|
||||
onChange={(event) =>
|
||||
setBlocks((prev) =>
|
||||
updateBlock(prev, block.id, { title: event.target.value || null }),
|
||||
)
|
||||
}
|
||||
placeholder="Price card section title"
|
||||
className="w-full rounded border border-neutral-300 px-2 py-1 text-sm"
|
||||
/>
|
||||
<textarea
|
||||
rows={4}
|
||||
value={block.cards
|
||||
.map((card) => [card.name, card.price ?? "", card.description ?? ""].join("|"))
|
||||
.join("\n")}
|
||||
onChange={(event) =>
|
||||
setBlocks((prev) =>
|
||||
updateBlock(prev, block.id, {
|
||||
cards: event.target.value
|
||||
.split("\n")
|
||||
.map((line) => line.trim())
|
||||
.filter((line) => line.length > 0)
|
||||
.map((line, lineIndex) => {
|
||||
const [name, price, description] = line
|
||||
.split("|")
|
||||
.map((entry) => entry.trim())
|
||||
return {
|
||||
id: `card-${lineIndex}`,
|
||||
name: name || `Card ${lineIndex + 1}`,
|
||||
price: price || null,
|
||||
description: description || null,
|
||||
}
|
||||
}),
|
||||
}),
|
||||
)
|
||||
}
|
||||
placeholder="One card per line: Name|Price|Description"
|
||||
className="w-full rounded border border-neutral-300 px-2 py-1 text-sm"
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
147
apps/admin/src/i18n/messages.test.ts
Normal file
147
apps/admin/src/i18n/messages.test.ts
Normal file
@@ -0,0 +1,147 @@
|
||||
import { describe, expect, it } from "vitest"
|
||||
|
||||
import type { AdminMessages } from "./messages"
|
||||
import { translateMessage } from "./messages"
|
||||
|
||||
const messages: AdminMessages = {
|
||||
common: {
|
||||
language: "Language",
|
||||
localeNames: {
|
||||
de: "German",
|
||||
en: "English",
|
||||
es: "Spanish",
|
||||
fr: "French",
|
||||
},
|
||||
},
|
||||
auth: {
|
||||
badge: "Admin Auth",
|
||||
titles: {
|
||||
signIn: "Sign in",
|
||||
signUpOwner: "Welcome",
|
||||
signUpUser: "Create account",
|
||||
signUpDisabled: "Registration disabled",
|
||||
},
|
||||
descriptions: {
|
||||
signIn: "Sign in description",
|
||||
signUpOwner: "Owner description",
|
||||
signUpUser: "User description",
|
||||
signUpDisabled: "Disabled description",
|
||||
},
|
||||
fields: {
|
||||
name: "Name",
|
||||
emailOrUsername: "Email or username",
|
||||
email: "Email",
|
||||
username: "Username",
|
||||
password: "Password",
|
||||
},
|
||||
actions: {
|
||||
signInIdle: "Sign in",
|
||||
signInBusy: "Signing in...",
|
||||
signUpOwnerIdle: "Create owner account",
|
||||
signUpUserIdle: "Create account",
|
||||
signUpBusy: "Creating account...",
|
||||
},
|
||||
links: {
|
||||
needAccount: "Need an account?",
|
||||
register: "Register",
|
||||
alreadyHaveAccount: "Already have an account?",
|
||||
goToSignIn: "Go to sign in",
|
||||
},
|
||||
messages: {
|
||||
ownerCreated: "Owner account created.",
|
||||
accountCreated: "Account created.",
|
||||
registrationDisabled: "Registration is disabled.",
|
||||
},
|
||||
errors: {
|
||||
nameRequired: "Name is required.",
|
||||
signInFailed: "Sign in failed",
|
||||
signUpFailed: "Sign up failed",
|
||||
networkSignIn: "Network sign in error",
|
||||
networkSignUp: "Network sign up error",
|
||||
},
|
||||
},
|
||||
settings: {
|
||||
badge: "Admin Settings",
|
||||
title: "Settings",
|
||||
description: "Settings description",
|
||||
actions: {
|
||||
backToDashboard: "Back to dashboard",
|
||||
},
|
||||
registration: {
|
||||
title: "Registration",
|
||||
description: "Registration description",
|
||||
currentStatusLabel: "Current status",
|
||||
status: {
|
||||
enabled: "Enabled",
|
||||
disabled: "Disabled",
|
||||
},
|
||||
checkboxLabel: "Allow registration",
|
||||
actions: {
|
||||
save: "Save",
|
||||
},
|
||||
success: {
|
||||
updated: "Updated",
|
||||
},
|
||||
errors: {
|
||||
updateFailed: "Update failed",
|
||||
},
|
||||
},
|
||||
},
|
||||
dashboard: {
|
||||
badge: "Admin App",
|
||||
title: "Content Dashboard",
|
||||
description: "Manage content.",
|
||||
actions: {
|
||||
openRoadmap: "Open roadmap",
|
||||
},
|
||||
notices: {
|
||||
noCrudPermission: "No permission.",
|
||||
crudSandboxTag: "MVP0 functional test",
|
||||
},
|
||||
posts: {
|
||||
title: "Posts CRUD Sandbox",
|
||||
createTitle: "Create post",
|
||||
fields: {
|
||||
title: "Title",
|
||||
slug: "Slug",
|
||||
excerpt: "Excerpt",
|
||||
body: "Body",
|
||||
status: "Status",
|
||||
},
|
||||
status: {
|
||||
draft: "Draft",
|
||||
published: "Published",
|
||||
},
|
||||
actions: {
|
||||
create: "Create post",
|
||||
save: "Save changes",
|
||||
delete: "Delete",
|
||||
},
|
||||
errors: {
|
||||
createFailed: "Create failed.",
|
||||
updateFailed: "Update failed.",
|
||||
updateMissingId: "Missing post id.",
|
||||
deleteFailed: "Delete failed.",
|
||||
deleteMissingId: "Missing post id.",
|
||||
},
|
||||
success: {
|
||||
created: "Post created.",
|
||||
updated: "Post updated.",
|
||||
deleted: "Post deleted.",
|
||||
},
|
||||
fallback: {
|
||||
noExcerpt: "No excerpt",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
describe("translateMessage", () => {
|
||||
it("resolves nested keys", () => {
|
||||
expect(translateMessage(messages, "dashboard.title")).toBe("Content Dashboard")
|
||||
})
|
||||
|
||||
it("returns fallback for unknown keys", () => {
|
||||
expect(translateMessage(messages, "dashboard.unknown", "Fallback")).toBe("Fallback")
|
||||
})
|
||||
})
|
||||
27
apps/admin/src/i18n/messages.ts
Normal file
27
apps/admin/src/i18n/messages.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import type enMessages from "../messages/en.json"
|
||||
|
||||
export type AdminMessages = typeof enMessages
|
||||
|
||||
function resolveNestedValue(source: unknown, key: string): unknown {
|
||||
let current: unknown = source
|
||||
|
||||
for (const segment of key.split(".")) {
|
||||
if (!current || typeof current !== "object") {
|
||||
return null
|
||||
}
|
||||
|
||||
current = (current as Record<string, unknown>)[segment]
|
||||
}
|
||||
|
||||
return current
|
||||
}
|
||||
|
||||
export function translateMessage(messages: AdminMessages, key: string, fallback?: string): string {
|
||||
const resolved = resolveNestedValue(messages, key)
|
||||
|
||||
if (typeof resolved === "string") {
|
||||
return resolved
|
||||
}
|
||||
|
||||
return fallback ?? key
|
||||
}
|
||||
17
apps/admin/src/i18n/server.test.ts
Normal file
17
apps/admin/src/i18n/server.test.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { describe, expect, it } from "vitest"
|
||||
|
||||
import { resolveAdminLocaleFromCookieValue } from "./server"
|
||||
|
||||
describe("resolveAdminLocaleFromCookieValue", () => {
|
||||
it("accepts supported locales", () => {
|
||||
expect(resolveAdminLocaleFromCookieValue("de")).toBe("de")
|
||||
expect(resolveAdminLocaleFromCookieValue("en")).toBe("en")
|
||||
expect(resolveAdminLocaleFromCookieValue("es")).toBe("es")
|
||||
expect(resolveAdminLocaleFromCookieValue("fr")).toBe("fr")
|
||||
})
|
||||
|
||||
it("falls back to default locale for unknown values", () => {
|
||||
expect(resolveAdminLocaleFromCookieValue("it")).toBe("en")
|
||||
expect(resolveAdminLocaleFromCookieValue(undefined)).toBe("en")
|
||||
})
|
||||
})
|
||||
23
apps/admin/src/i18n/server.ts
Normal file
23
apps/admin/src/i18n/server.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import { type AppLocale, defaultLocale, isAppLocale } from "@cms/i18n"
|
||||
import { cookies } from "next/headers"
|
||||
|
||||
import type { AdminMessages } from "./messages"
|
||||
import { ADMIN_LOCALE_COOKIE } from "./shared"
|
||||
|
||||
export function resolveAdminLocaleFromCookieValue(value: string | undefined): AppLocale {
|
||||
if (value && isAppLocale(value)) {
|
||||
return value
|
||||
}
|
||||
|
||||
return defaultLocale
|
||||
}
|
||||
|
||||
export async function resolveAdminLocale(): Promise<AppLocale> {
|
||||
const cookieStore = await cookies()
|
||||
const value = cookieStore.get(ADMIN_LOCALE_COOKIE)?.value
|
||||
return resolveAdminLocaleFromCookieValue(value)
|
||||
}
|
||||
|
||||
export async function getAdminMessages(locale: AppLocale): Promise<AdminMessages> {
|
||||
return (await import(`../messages/${locale}.json`)).default as AdminMessages
|
||||
}
|
||||
1
apps/admin/src/i18n/shared.ts
Normal file
1
apps/admin/src/i18n/shared.ts
Normal file
@@ -0,0 +1 @@
|
||||
export const ADMIN_LOCALE_COOKIE = "cms_admin_locale"
|
||||
59
apps/admin/src/lib/access.test.ts
Normal file
59
apps/admin/src/lib/access.test.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
import { describe, expect, it } from "vitest"
|
||||
|
||||
import { canAccessRoute, getRequiredPermission, isPublicRoute } from "./access"
|
||||
|
||||
describe("admin route access rules", () => {
|
||||
it("treats support fallback route as public", () => {
|
||||
expect(isPublicRoute("/support/support-access")).toBe(true)
|
||||
expect(canAccessRoute("editor", "/support/support-access")).toBe(true)
|
||||
})
|
||||
|
||||
it("keeps settings route restricted to role with users:manage_roles", () => {
|
||||
expect(isPublicRoute("/settings")).toBe(false)
|
||||
expect(canAccessRoute("manager", "/settings")).toBe(false)
|
||||
expect(canAccessRoute("admin", "/settings")).toBe(true)
|
||||
expect(canAccessRoute("owner", "/settings")).toBe(true)
|
||||
})
|
||||
|
||||
it("resolves route-specific permission requirements", () => {
|
||||
expect(getRequiredPermission("/todo")).toEqual({
|
||||
permission: "roadmap:read",
|
||||
scope: "global",
|
||||
})
|
||||
})
|
||||
|
||||
it("maps new admin IA routes to dedicated permissions", () => {
|
||||
expect(getRequiredPermission("/pages")).toEqual({
|
||||
permission: "pages:read",
|
||||
scope: "team",
|
||||
})
|
||||
expect(getRequiredPermission("/navigation")).toEqual({
|
||||
permission: "navigation:read",
|
||||
scope: "team",
|
||||
})
|
||||
expect(getRequiredPermission("/media")).toEqual({
|
||||
permission: "media:read",
|
||||
scope: "team",
|
||||
})
|
||||
expect(getRequiredPermission("/portfolio")).toEqual({
|
||||
permission: "media:read",
|
||||
scope: "team",
|
||||
})
|
||||
expect(getRequiredPermission("/users")).toEqual({
|
||||
permission: "users:read",
|
||||
scope: "own",
|
||||
})
|
||||
expect(getRequiredPermission("/commissions")).toEqual({
|
||||
permission: "commissions:read",
|
||||
scope: "own",
|
||||
})
|
||||
expect(getRequiredPermission("/announcements")).toEqual({
|
||||
permission: "banner:read",
|
||||
scope: "global",
|
||||
})
|
||||
expect(getRequiredPermission("/news")).toEqual({
|
||||
permission: "news:read",
|
||||
scope: "team",
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -43,6 +43,69 @@ const guardRules: GuardRule[] = [
|
||||
scope: "global",
|
||||
},
|
||||
},
|
||||
{
|
||||
route: /^\/pages(?:\/|$)/,
|
||||
requirement: {
|
||||
permission: "pages:read",
|
||||
scope: "team",
|
||||
},
|
||||
},
|
||||
{
|
||||
route: /^\/navigation(?:\/|$)/,
|
||||
requirement: {
|
||||
permission: "navigation:read",
|
||||
scope: "team",
|
||||
},
|
||||
},
|
||||
{
|
||||
route: /^\/media(?:\/|$)/,
|
||||
requirement: {
|
||||
permission: "media:read",
|
||||
scope: "team",
|
||||
},
|
||||
},
|
||||
{
|
||||
route: /^\/portfolio(?:\/|$)/,
|
||||
requirement: {
|
||||
permission: "media:read",
|
||||
scope: "team",
|
||||
},
|
||||
},
|
||||
{
|
||||
route: /^\/users(?:\/|$)/,
|
||||
requirement: {
|
||||
permission: "users:read",
|
||||
scope: "own",
|
||||
},
|
||||
},
|
||||
{
|
||||
route: /^\/commissions(?:\/|$)/,
|
||||
requirement: {
|
||||
permission: "commissions:read",
|
||||
scope: "own",
|
||||
},
|
||||
},
|
||||
{
|
||||
route: /^\/announcements(?:\/|$)/,
|
||||
requirement: {
|
||||
permission: "banner:read",
|
||||
scope: "global",
|
||||
},
|
||||
},
|
||||
{
|
||||
route: /^\/news(?:\/|$)/,
|
||||
requirement: {
|
||||
permission: "news:read",
|
||||
scope: "team",
|
||||
},
|
||||
},
|
||||
{
|
||||
route: /^\/settings(?:\/|$)/,
|
||||
requirement: {
|
||||
permission: "users:manage_roles",
|
||||
scope: "global",
|
||||
},
|
||||
},
|
||||
{
|
||||
route: /^\/(?:$|\?)/,
|
||||
requirement: {
|
||||
|
||||
238
apps/admin/src/lib/auth/server.test.ts
Normal file
238
apps/admin/src/lib/auth/server.test.ts
Normal file
@@ -0,0 +1,238 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest"
|
||||
|
||||
const { mockDb, mockIsAdminSelfRegistrationEnabled, mockAuth, mockAuthRouteHandlers } = vi.hoisted(
|
||||
() => {
|
||||
const mockDb = {
|
||||
user: {
|
||||
count: vi.fn(),
|
||||
findUnique: vi.fn(),
|
||||
findMany: vi.fn(),
|
||||
findFirst: vi.fn(),
|
||||
update: vi.fn(),
|
||||
updateMany: vi.fn(),
|
||||
},
|
||||
$transaction: vi.fn(),
|
||||
}
|
||||
|
||||
return {
|
||||
mockDb,
|
||||
mockIsAdminSelfRegistrationEnabled: vi.fn(),
|
||||
mockAuth: {
|
||||
api: {
|
||||
getSession: vi.fn(),
|
||||
},
|
||||
$context: Promise.resolve({
|
||||
internalAdapter: {
|
||||
findUserByEmail: vi.fn(),
|
||||
linkAccount: vi.fn(),
|
||||
createUser: vi.fn(),
|
||||
},
|
||||
password: {
|
||||
hash: vi.fn(async (value: string) => `hashed:${value}`),
|
||||
},
|
||||
}),
|
||||
},
|
||||
mockAuthRouteHandlers: {
|
||||
GET: vi.fn(),
|
||||
POST: vi.fn(),
|
||||
PATCH: vi.fn(),
|
||||
PUT: vi.fn(),
|
||||
DELETE: vi.fn(),
|
||||
},
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
vi.mock("@cms/db", () => ({
|
||||
db: mockDb,
|
||||
isAdminSelfRegistrationEnabled: mockIsAdminSelfRegistrationEnabled,
|
||||
}))
|
||||
|
||||
vi.mock("better-auth", () => ({
|
||||
betterAuth: vi.fn(() => mockAuth),
|
||||
}))
|
||||
|
||||
vi.mock("better-auth/adapters/prisma", () => ({
|
||||
prismaAdapter: vi.fn(() => ({})),
|
||||
}))
|
||||
|
||||
vi.mock("better-auth/next-js", () => ({
|
||||
toNextJsHandler: vi.fn(() => mockAuthRouteHandlers),
|
||||
}))
|
||||
|
||||
import {
|
||||
canDeleteUserAccount,
|
||||
enforceOwnerInvariant,
|
||||
promoteFirstRegisteredUserToOwner,
|
||||
} from "./server"
|
||||
|
||||
describe("auth owner/support invariants", () => {
|
||||
beforeEach(() => {
|
||||
mockIsAdminSelfRegistrationEnabled.mockReset()
|
||||
mockDb.user.count.mockReset()
|
||||
mockDb.user.findUnique.mockReset()
|
||||
mockDb.user.findMany.mockReset()
|
||||
mockDb.user.findFirst.mockReset()
|
||||
mockDb.user.update.mockReset()
|
||||
mockDb.user.updateMany.mockReset()
|
||||
mockDb.$transaction.mockReset()
|
||||
})
|
||||
|
||||
it("blocks deletion of protected users", async () => {
|
||||
mockDb.user.findUnique.mockResolvedValue({
|
||||
role: "support",
|
||||
isProtected: true,
|
||||
})
|
||||
|
||||
const allowed = await canDeleteUserAccount("user-protected")
|
||||
|
||||
expect(allowed).toBe(false)
|
||||
})
|
||||
|
||||
it("allows deletion of non-owner non-protected users", async () => {
|
||||
mockDb.user.findUnique.mockResolvedValue({
|
||||
role: "editor",
|
||||
isProtected: false,
|
||||
})
|
||||
|
||||
const allowed = await canDeleteUserAccount("user-editor")
|
||||
|
||||
expect(allowed).toBe(true)
|
||||
})
|
||||
|
||||
it("keeps sole owner non-deletable", async () => {
|
||||
mockDb.user.findUnique.mockResolvedValue({
|
||||
role: "owner",
|
||||
isProtected: false,
|
||||
})
|
||||
mockDb.user.count.mockResolvedValue(1)
|
||||
|
||||
const allowed = await canDeleteUserAccount("owner-1")
|
||||
|
||||
expect(allowed).toBe(false)
|
||||
})
|
||||
|
||||
it("promotes earliest non-support user when no owner exists", async () => {
|
||||
const tx = {
|
||||
user: {
|
||||
findMany: vi.fn().mockResolvedValue([]),
|
||||
findFirst: vi.fn().mockResolvedValue({ id: "candidate-1" }),
|
||||
update: vi.fn().mockResolvedValue({ id: "candidate-1" }),
|
||||
updateMany: vi.fn(),
|
||||
},
|
||||
}
|
||||
|
||||
mockDb.$transaction.mockImplementation(async (callback: (trx: typeof tx) => unknown) =>
|
||||
callback(tx),
|
||||
)
|
||||
|
||||
const result = await enforceOwnerInvariant()
|
||||
|
||||
expect(result).toEqual({
|
||||
ownerId: "candidate-1",
|
||||
ownerCount: 1,
|
||||
repaired: true,
|
||||
})
|
||||
expect(tx.user.update).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it("demotes extra owners and repairs canonical owner protection", async () => {
|
||||
const tx = {
|
||||
user: {
|
||||
findMany: vi.fn().mockResolvedValue([
|
||||
{ id: "owner-a", isProtected: false, isBanned: true },
|
||||
{ id: "owner-b", isProtected: true, isBanned: false },
|
||||
]),
|
||||
findFirst: vi.fn(),
|
||||
update: vi.fn().mockResolvedValue({ id: "owner-a" }),
|
||||
updateMany: vi.fn().mockResolvedValue({ count: 1 }),
|
||||
},
|
||||
}
|
||||
|
||||
mockDb.$transaction.mockImplementation(async (callback: (trx: typeof tx) => unknown) =>
|
||||
callback(tx),
|
||||
)
|
||||
|
||||
const result = await enforceOwnerInvariant()
|
||||
|
||||
expect(result).toEqual({
|
||||
ownerId: "owner-a",
|
||||
ownerCount: 1,
|
||||
repaired: true,
|
||||
})
|
||||
expect(tx.user.updateMany).toHaveBeenCalledWith({
|
||||
where: { id: { in: ["owner-b"] } },
|
||||
data: { role: "admin", isProtected: false },
|
||||
})
|
||||
expect(tx.user.update).toHaveBeenCalledWith({
|
||||
where: { id: "owner-a" },
|
||||
data: { isProtected: true, isBanned: false },
|
||||
})
|
||||
})
|
||||
|
||||
it("does not promote first registration when an owner already exists", async () => {
|
||||
mockDb.$transaction.mockImplementationOnce(
|
||||
async (
|
||||
callback: (tx: {
|
||||
user: { findFirst: () => Promise<{ id: string }>; update: () => void }
|
||||
}) => unknown,
|
||||
) =>
|
||||
callback({
|
||||
user: {
|
||||
findFirst: vi.fn().mockResolvedValue({ id: "owner-existing" }),
|
||||
update: vi.fn(),
|
||||
},
|
||||
}),
|
||||
)
|
||||
|
||||
const promoted = await promoteFirstRegisteredUserToOwner("candidate")
|
||||
|
||||
expect(promoted).toBe(false)
|
||||
})
|
||||
|
||||
it("promotes first registration and re-enforces owner invariant", async () => {
|
||||
mockDb.$transaction
|
||||
.mockImplementationOnce(
|
||||
async (
|
||||
callback: (tx: {
|
||||
user: { findFirst: () => Promise<null>; update: () => Promise<{ id: string }> }
|
||||
}) => unknown,
|
||||
) =>
|
||||
callback({
|
||||
user: {
|
||||
findFirst: vi.fn().mockResolvedValue(null),
|
||||
update: vi.fn().mockResolvedValue({ id: "candidate" }),
|
||||
},
|
||||
}),
|
||||
)
|
||||
.mockImplementationOnce(
|
||||
async (
|
||||
callback: (tx: {
|
||||
user: {
|
||||
findMany: () => Promise<
|
||||
Array<{ id: string; isProtected: boolean; isBanned: boolean }>
|
||||
>
|
||||
findFirst: () => void
|
||||
update: () => void
|
||||
updateMany: () => void
|
||||
}
|
||||
}) => unknown,
|
||||
) =>
|
||||
callback({
|
||||
user: {
|
||||
findMany: vi
|
||||
.fn()
|
||||
.mockResolvedValue([{ id: "candidate", isProtected: true, isBanned: false }]),
|
||||
findFirst: vi.fn(),
|
||||
update: vi.fn(),
|
||||
updateMany: vi.fn(),
|
||||
},
|
||||
}),
|
||||
)
|
||||
|
||||
const promoted = await promoteFirstRegisteredUserToOwner("candidate")
|
||||
|
||||
expect(promoted).toBe(true)
|
||||
expect(mockDb.$transaction).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
})
|
||||
@@ -1,5 +1,5 @@
|
||||
import { normalizeRole, type Role } from "@cms/content/rbac"
|
||||
import { db } from "@cms/db"
|
||||
import { db, isAdminSelfRegistrationEnabled } from "@cms/db"
|
||||
import { betterAuth } from "better-auth"
|
||||
import { prismaAdapter } from "better-auth/adapters/prisma"
|
||||
import { toNextJsHandler } from "better-auth/next-js"
|
||||
@@ -43,8 +43,7 @@ export async function isInitialOwnerRegistrationOpen(): Promise<boolean> {
|
||||
}
|
||||
|
||||
export async function isSelfRegistrationEnabled(): Promise<boolean> {
|
||||
// Temporary fallback until registration policy is managed from admin settings.
|
||||
return process.env.CMS_ADMIN_SELF_REGISTRATION_ENABLED === "true"
|
||||
return isAdminSelfRegistrationEnabled()
|
||||
}
|
||||
|
||||
export async function canUserSelfRegister(): Promise<boolean> {
|
||||
@@ -267,11 +266,15 @@ type BootstrapUserConfig = {
|
||||
password: string
|
||||
role: Role
|
||||
isHidden: boolean
|
||||
isSystem?: boolean
|
||||
isProtected?: boolean
|
||||
}
|
||||
|
||||
async function ensureCredentialUser(config: BootstrapUserConfig): Promise<void> {
|
||||
const ctx = await auth.$context
|
||||
const normalizedEmail = config.email.toLowerCase()
|
||||
const isSystem = config.isSystem ?? true
|
||||
const isProtected = config.isProtected ?? true
|
||||
const existing = await ctx.internalAdapter.findUserByEmail(normalizedEmail, {
|
||||
includeAccounts: true,
|
||||
})
|
||||
@@ -283,9 +286,9 @@ async function ensureCredentialUser(config: BootstrapUserConfig): Promise<void>
|
||||
name: config.name,
|
||||
role: config.role,
|
||||
isBanned: false,
|
||||
isSystem: true,
|
||||
isSystem,
|
||||
isHidden: config.isHidden,
|
||||
isProtected: true,
|
||||
isProtected,
|
||||
},
|
||||
})
|
||||
|
||||
@@ -322,9 +325,9 @@ async function ensureCredentialUser(config: BootstrapUserConfig): Promise<void>
|
||||
emailVerified: true,
|
||||
role: config.role,
|
||||
isBanned: false,
|
||||
isSystem: true,
|
||||
isSystem,
|
||||
isHidden: config.isHidden,
|
||||
isProtected: true,
|
||||
isProtected,
|
||||
})
|
||||
|
||||
await ctx.internalAdapter.linkAccount({
|
||||
@@ -372,6 +375,86 @@ export async function ensureSupportUserBootstrap(): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
const MANAGED_USER_ROLE_ALLOWLIST = new Set<Role>(["admin", "editor", "manager"])
|
||||
|
||||
export async function createManagedUserAccount(input: {
|
||||
email: string
|
||||
username?: string | null
|
||||
name: string
|
||||
password: string
|
||||
role: string
|
||||
}): Promise<{ id: string; email: string; username: string | null; role: string }> {
|
||||
const normalizedEmail = input.email.trim().toLowerCase()
|
||||
const normalizedRole = normalizeRole(input.role)
|
||||
|
||||
if (!normalizedRole || !MANAGED_USER_ROLE_ALLOWLIST.has(normalizedRole)) {
|
||||
throw new Error("Unsupported role for managed user account")
|
||||
}
|
||||
|
||||
const existing = await db.user.findUnique({
|
||||
where: { email: normalizedEmail },
|
||||
select: { id: true, isProtected: true, isSystem: true },
|
||||
})
|
||||
|
||||
if (existing) {
|
||||
if (existing.isProtected || existing.isSystem) {
|
||||
throw new Error("Cannot mutate protected/system account via managed user provisioning")
|
||||
}
|
||||
|
||||
throw new Error("A user with this email already exists")
|
||||
}
|
||||
|
||||
const preferredUsername =
|
||||
normalizeUsernameCandidate(input.username) ??
|
||||
normalizeUsernameCandidate(extractEmailLocalPart(normalizedEmail)) ??
|
||||
"user"
|
||||
|
||||
await ensureCredentialUser({
|
||||
email: normalizedEmail,
|
||||
username: preferredUsername,
|
||||
name: input.name.trim(),
|
||||
password: input.password,
|
||||
role: normalizedRole,
|
||||
isHidden: false,
|
||||
isSystem: false,
|
||||
isProtected: false,
|
||||
})
|
||||
|
||||
const created = await db.user.findUnique({
|
||||
where: { email: normalizedEmail },
|
||||
select: { id: true, email: true, username: true, role: true },
|
||||
})
|
||||
|
||||
if (!created) {
|
||||
throw new Error("Managed user provisioning failed")
|
||||
}
|
||||
|
||||
return created
|
||||
}
|
||||
|
||||
const DEFAULT_E2E_ADMIN_EMAIL = "e2e-admin@cms.local"
|
||||
const DEFAULT_E2E_ADMIN_USERNAME = "e2e-admin"
|
||||
const DEFAULT_E2E_ADMIN_PASSWORD = "e2e-admin-password"
|
||||
const DEFAULT_E2E_ADMIN_NAME = "E2E Admin"
|
||||
|
||||
export async function ensureE2EAdminBootstrap(): Promise<void> {
|
||||
const email = resolveBootstrapValue("CMS_E2E_ADMIN_EMAIL", DEFAULT_E2E_ADMIN_EMAIL)
|
||||
const username = resolveBootstrapValue("CMS_E2E_ADMIN_USERNAME", DEFAULT_E2E_ADMIN_USERNAME)
|
||||
const password = resolveBootstrapValue("CMS_E2E_ADMIN_PASSWORD", DEFAULT_E2E_ADMIN_PASSWORD)
|
||||
const name = resolveBootstrapValue("CMS_E2E_ADMIN_NAME", DEFAULT_E2E_ADMIN_NAME)
|
||||
|
||||
await ensureCredentialUser({
|
||||
email,
|
||||
username,
|
||||
password,
|
||||
name,
|
||||
role: "admin",
|
||||
isHidden: false,
|
||||
isSystem: true,
|
||||
isProtected: false,
|
||||
})
|
||||
}
|
||||
|
||||
type OwnerInvariantState = {
|
||||
ownerId: string | null
|
||||
ownerCount: number
|
||||
|
||||
29
apps/admin/src/lib/build-info.test.ts
Normal file
29
apps/admin/src/lib/build-info.test.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest"
|
||||
|
||||
import { getBuildInfo } from "./build-info"
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs()
|
||||
})
|
||||
|
||||
describe("getBuildInfo (admin)", () => {
|
||||
it("returns fallback values when env is missing", () => {
|
||||
vi.stubEnv("NEXT_PUBLIC_APP_VERSION", "")
|
||||
vi.stubEnv("NEXT_PUBLIC_GIT_SHA", "")
|
||||
|
||||
expect(getBuildInfo()).toEqual({
|
||||
version: "0.0.1-dev",
|
||||
sha: "local",
|
||||
})
|
||||
})
|
||||
|
||||
it("uses env values and truncates git sha", () => {
|
||||
vi.stubEnv("NEXT_PUBLIC_APP_VERSION", "0.2.0")
|
||||
vi.stubEnv("NEXT_PUBLIC_GIT_SHA", "abcdef123456")
|
||||
|
||||
expect(getBuildInfo()).toEqual({
|
||||
version: "0.2.0",
|
||||
sha: "abcdef1",
|
||||
})
|
||||
})
|
||||
})
|
||||
21
apps/admin/src/lib/build-info.ts
Normal file
21
apps/admin/src/lib/build-info.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
const FALLBACK_VERSION = "0.0.1-dev"
|
||||
const FALLBACK_SHA = "local"
|
||||
|
||||
function shortenSha(input: string): string {
|
||||
const value = input.trim()
|
||||
if (!value) {
|
||||
return FALLBACK_SHA
|
||||
}
|
||||
|
||||
return value.slice(0, 7)
|
||||
}
|
||||
|
||||
export function getBuildInfo() {
|
||||
const version = process.env.NEXT_PUBLIC_APP_VERSION?.trim() || FALLBACK_VERSION
|
||||
const sha = shortenSha(process.env.NEXT_PUBLIC_GIT_SHA ?? "")
|
||||
|
||||
return {
|
||||
version,
|
||||
sha,
|
||||
}
|
||||
}
|
||||
66
apps/admin/src/lib/media/local-storage.ts
Normal file
66
apps/admin/src/lib/media/local-storage.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
import { mkdir, rm, writeFile } from "node:fs/promises"
|
||||
import path from "node:path"
|
||||
|
||||
import { buildMediaStorageKey } from "@/lib/media/storage-key"
|
||||
|
||||
type StoreLocalUploadParams = {
|
||||
file: File
|
||||
tenantId: string
|
||||
assetId: string
|
||||
fileRole: string
|
||||
variant: string
|
||||
}
|
||||
|
||||
type StoredUpload = {
|
||||
storageKey: string
|
||||
}
|
||||
|
||||
export function resolveLocalMediaBaseDirectory(): string {
|
||||
const configured = process.env.CMS_MEDIA_LOCAL_STORAGE_DIR?.trim()
|
||||
|
||||
if (configured) {
|
||||
return path.resolve(configured)
|
||||
}
|
||||
|
||||
return path.resolve(process.cwd(), ".data", "media")
|
||||
}
|
||||
|
||||
export async function storeUploadLocally(params: StoreLocalUploadParams): Promise<StoredUpload> {
|
||||
const storageKey = buildMediaStorageKey({
|
||||
tenantId: params.tenantId,
|
||||
assetId: params.assetId,
|
||||
fileRole: params.fileRole,
|
||||
variant: params.variant,
|
||||
fileName: params.file.name,
|
||||
})
|
||||
const baseDirectory = resolveLocalMediaBaseDirectory()
|
||||
const outputPath = path.join(baseDirectory, storageKey)
|
||||
|
||||
await mkdir(path.dirname(outputPath), { recursive: true })
|
||||
|
||||
const bytes = new Uint8Array(await params.file.arrayBuffer())
|
||||
await writeFile(outputPath, bytes)
|
||||
|
||||
return { storageKey }
|
||||
}
|
||||
|
||||
export async function deleteLocalStorageObject(storageKey: string): Promise<boolean> {
|
||||
const baseDirectory = resolveLocalMediaBaseDirectory()
|
||||
const outputPath = path.join(baseDirectory, storageKey)
|
||||
|
||||
try {
|
||||
await rm(outputPath)
|
||||
return true
|
||||
} catch (error) {
|
||||
const code =
|
||||
typeof error === "object" && error !== null && "code" in error
|
||||
? String((error as { code?: unknown }).code)
|
||||
: ""
|
||||
|
||||
if (code === "ENOENT") {
|
||||
return false
|
||||
}
|
||||
|
||||
throw error
|
||||
}
|
||||
}
|
||||
103
apps/admin/src/lib/media/s3-storage.ts
Normal file
103
apps/admin/src/lib/media/s3-storage.ts
Normal file
@@ -0,0 +1,103 @@
|
||||
import { DeleteObjectCommand, PutObjectCommand, S3Client } from "@aws-sdk/client-s3"
|
||||
|
||||
import { buildMediaStorageKey } from "@/lib/media/storage-key"
|
||||
|
||||
type StoreS3UploadParams = {
|
||||
file: File
|
||||
tenantId: string
|
||||
assetId: string
|
||||
fileRole: string
|
||||
variant: string
|
||||
}
|
||||
|
||||
type StoredUpload = {
|
||||
storageKey: string
|
||||
}
|
||||
|
||||
type S3Config = {
|
||||
bucket: string
|
||||
region: string
|
||||
endpoint?: string
|
||||
accessKeyId: string
|
||||
secretAccessKey: string
|
||||
forcePathStyle?: boolean
|
||||
}
|
||||
|
||||
function parseBoolean(value: string | undefined): boolean {
|
||||
return value?.toLowerCase() === "true"
|
||||
}
|
||||
|
||||
export function resolveS3Config(): S3Config {
|
||||
const bucket = process.env.CMS_MEDIA_S3_BUCKET?.trim()
|
||||
const region = process.env.CMS_MEDIA_S3_REGION?.trim()
|
||||
const accessKeyId = process.env.CMS_MEDIA_S3_ACCESS_KEY_ID?.trim()
|
||||
const secretAccessKey = process.env.CMS_MEDIA_S3_SECRET_ACCESS_KEY?.trim()
|
||||
const endpoint = process.env.CMS_MEDIA_S3_ENDPOINT?.trim() || undefined
|
||||
|
||||
if (!bucket || !region || !accessKeyId || !secretAccessKey) {
|
||||
throw new Error(
|
||||
"S3 storage selected but required env vars are missing: CMS_MEDIA_S3_BUCKET, CMS_MEDIA_S3_REGION, CMS_MEDIA_S3_ACCESS_KEY_ID, CMS_MEDIA_S3_SECRET_ACCESS_KEY",
|
||||
)
|
||||
}
|
||||
|
||||
return {
|
||||
bucket,
|
||||
region,
|
||||
endpoint,
|
||||
accessKeyId,
|
||||
secretAccessKey,
|
||||
forcePathStyle: parseBoolean(process.env.CMS_MEDIA_S3_FORCE_PATH_STYLE),
|
||||
}
|
||||
}
|
||||
|
||||
export function createS3Client(config: S3Config): S3Client {
|
||||
return new S3Client({
|
||||
region: config.region,
|
||||
endpoint: config.endpoint,
|
||||
forcePathStyle: config.forcePathStyle,
|
||||
credentials: {
|
||||
accessKeyId: config.accessKeyId,
|
||||
secretAccessKey: config.secretAccessKey,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export async function storeUploadToS3(params: StoreS3UploadParams): Promise<StoredUpload> {
|
||||
const config = resolveS3Config()
|
||||
const client = createS3Client(config)
|
||||
const storageKey = buildMediaStorageKey({
|
||||
tenantId: params.tenantId,
|
||||
assetId: params.assetId,
|
||||
fileRole: params.fileRole,
|
||||
variant: params.variant,
|
||||
fileName: params.file.name,
|
||||
})
|
||||
const payload = new Uint8Array(await params.file.arrayBuffer())
|
||||
|
||||
await client.send(
|
||||
new PutObjectCommand({
|
||||
Bucket: config.bucket,
|
||||
Key: storageKey,
|
||||
Body: payload,
|
||||
ContentType: params.file.type || undefined,
|
||||
ContentLength: params.file.size,
|
||||
CacheControl: "public, max-age=31536000, immutable",
|
||||
}),
|
||||
)
|
||||
|
||||
return { storageKey }
|
||||
}
|
||||
|
||||
export async function deleteS3Object(storageKey: string): Promise<boolean> {
|
||||
const config = resolveS3Config()
|
||||
const client = createS3Client(config)
|
||||
|
||||
await client.send(
|
||||
new DeleteObjectCommand({
|
||||
Bucket: config.bucket,
|
||||
Key: storageKey,
|
||||
}),
|
||||
)
|
||||
|
||||
return true
|
||||
}
|
||||
19
apps/admin/src/lib/media/storage-key.test.ts
Normal file
19
apps/admin/src/lib/media/storage-key.test.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import { describe, expect, it } from "vitest"
|
||||
|
||||
import { buildMediaStorageKey } from "@/lib/media/storage-key"
|
||||
|
||||
describe("buildMediaStorageKey", () => {
|
||||
it("builds asset-centric key with fileRole and variant", () => {
|
||||
const key = buildMediaStorageKey({
|
||||
tenantId: "default",
|
||||
assetId: "550e8400-e29b-41d4-a716-446655440000",
|
||||
fileRole: "original",
|
||||
variant: "thumb",
|
||||
fileName: "My File.PNG",
|
||||
})
|
||||
|
||||
expect(key).toBe(
|
||||
"tenant/default/asset/550e8400-e29b-41d4-a716-446655440000/original/550e8400-e29b-41d4-a716-446655440000__thumb.png",
|
||||
)
|
||||
})
|
||||
})
|
||||
49
apps/admin/src/lib/media/storage-key.ts
Normal file
49
apps/admin/src/lib/media/storage-key.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import path from "node:path"
|
||||
|
||||
const FALLBACK_EXTENSION = "bin"
|
||||
const DEFAULT_VARIANT = "original"
|
||||
|
||||
type BuildMediaStorageKeyParams = {
|
||||
tenantId: string
|
||||
assetId: string
|
||||
fileRole: string
|
||||
variant?: string
|
||||
fileName: string
|
||||
}
|
||||
|
||||
function normalizeSegment(value: string): string {
|
||||
return value
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9._-]+/g, "-")
|
||||
.replace(/^-+|-+$/g, "")
|
||||
}
|
||||
|
||||
function extensionFromFilename(fileName: string): string {
|
||||
const extension = path.extname(fileName).slice(1)
|
||||
|
||||
if (!extension) {
|
||||
return FALLBACK_EXTENSION
|
||||
}
|
||||
|
||||
const normalized = normalizeSegment(extension)
|
||||
|
||||
return normalized.length > 0 ? normalized : FALLBACK_EXTENSION
|
||||
}
|
||||
|
||||
export function buildMediaStorageKey(params: BuildMediaStorageKeyParams): string {
|
||||
const normalizedTenantId = normalizeSegment(params.tenantId) || "default"
|
||||
const normalizedAssetId = normalizeSegment(params.assetId)
|
||||
const normalizedFileRole = normalizeSegment(params.fileRole) || "original"
|
||||
const normalizedVariant = normalizeSegment(params.variant ?? DEFAULT_VARIANT) || DEFAULT_VARIANT
|
||||
const extension = extensionFromFilename(params.fileName)
|
||||
const fileName = `${normalizedAssetId}__${normalizedVariant}.${extension}`
|
||||
|
||||
return [
|
||||
"tenant",
|
||||
normalizedTenantId,
|
||||
"asset",
|
||||
normalizedAssetId,
|
||||
normalizedFileRole,
|
||||
fileName,
|
||||
].join("/")
|
||||
}
|
||||
23
apps/admin/src/lib/media/storage.test.ts
Normal file
23
apps/admin/src/lib/media/storage.test.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import { describe, expect, it } from "vitest"
|
||||
|
||||
import { resolveMediaStorageProvider } from "@/lib/media/storage"
|
||||
|
||||
describe("resolveMediaStorageProvider", () => {
|
||||
it("defaults to s3 when unset", () => {
|
||||
expect(resolveMediaStorageProvider(undefined)).toBe("s3")
|
||||
})
|
||||
|
||||
it("resolves s3", () => {
|
||||
expect(resolveMediaStorageProvider("s3")).toBe("s3")
|
||||
expect(resolveMediaStorageProvider("S3")).toBe("s3")
|
||||
})
|
||||
|
||||
it("resolves local explicitly", () => {
|
||||
expect(resolveMediaStorageProvider("local")).toBe("local")
|
||||
expect(resolveMediaStorageProvider("LOCAL")).toBe("local")
|
||||
})
|
||||
|
||||
it("falls back to s3 for unknown values", () => {
|
||||
expect(resolveMediaStorageProvider("foo")).toBe("s3")
|
||||
})
|
||||
})
|
||||
149
apps/admin/src/lib/media/storage.ts
Normal file
149
apps/admin/src/lib/media/storage.ts
Normal file
@@ -0,0 +1,149 @@
|
||||
import { deleteLocalStorageObject, storeUploadLocally } from "@/lib/media/local-storage"
|
||||
import { deleteS3Object, storeUploadToS3 } from "@/lib/media/s3-storage"
|
||||
|
||||
export type MediaStorageProvider = "local" | "s3"
|
||||
|
||||
type StoreUploadParams = {
|
||||
file: File
|
||||
assetId: string
|
||||
variant: string
|
||||
fileRole: string
|
||||
}
|
||||
|
||||
type StoredUpload = {
|
||||
storageKey: string
|
||||
provider: MediaStorageProvider
|
||||
fallbackReason?: string
|
||||
}
|
||||
|
||||
type S3LikeError = {
|
||||
name?: unknown
|
||||
message?: unknown
|
||||
Code?: unknown
|
||||
code?: unknown
|
||||
$metadata?: {
|
||||
httpStatusCode?: unknown
|
||||
requestId?: unknown
|
||||
}
|
||||
}
|
||||
|
||||
function resolveTenantId(): string {
|
||||
return process.env.CMS_MEDIA_STORAGE_TENANT_ID?.trim() || "default"
|
||||
}
|
||||
|
||||
function describeS3Error(error: unknown): string {
|
||||
if (!error || typeof error !== "object") {
|
||||
return "Unknown S3 error"
|
||||
}
|
||||
|
||||
const err = error as S3LikeError
|
||||
const details: string[] = []
|
||||
|
||||
if (typeof err.name === "string" && err.name.length > 0) {
|
||||
details.push(`name=${err.name}`)
|
||||
}
|
||||
|
||||
if (typeof err.message === "string" && err.message.length > 0) {
|
||||
details.push(`message=${err.message}`)
|
||||
}
|
||||
|
||||
if (typeof err.Code === "string" && err.Code.length > 0) {
|
||||
details.push(`code=${err.Code}`)
|
||||
} else if (typeof err.code === "string" && err.code.length > 0) {
|
||||
details.push(`code=${err.code}`)
|
||||
}
|
||||
|
||||
const status = err.$metadata?.httpStatusCode
|
||||
if (typeof status === "number") {
|
||||
details.push(`status=${status}`)
|
||||
}
|
||||
|
||||
const requestId = err.$metadata?.requestId
|
||||
if (typeof requestId === "string" && requestId.length > 0) {
|
||||
details.push(`requestId=${requestId}`)
|
||||
}
|
||||
|
||||
return details.length > 0 ? details.join(", ") : "Unknown S3 error"
|
||||
}
|
||||
|
||||
export function resolveMediaStorageProvider(raw: string | undefined): MediaStorageProvider {
|
||||
if (raw?.toLowerCase() === "local") {
|
||||
return "local"
|
||||
}
|
||||
|
||||
return "s3"
|
||||
}
|
||||
|
||||
export async function storeUpload(params: StoreUploadParams): Promise<StoredUpload> {
|
||||
const provider = resolveMediaStorageProvider(process.env.CMS_MEDIA_STORAGE_PROVIDER)
|
||||
const tenantId = resolveTenantId()
|
||||
|
||||
if (provider === "s3") {
|
||||
try {
|
||||
const stored = await storeUploadToS3({
|
||||
file: params.file,
|
||||
tenantId,
|
||||
assetId: params.assetId,
|
||||
fileRole: params.fileRole,
|
||||
variant: params.variant,
|
||||
})
|
||||
return {
|
||||
...stored,
|
||||
provider,
|
||||
}
|
||||
} catch (error) {
|
||||
const detail = describeS3Error(error)
|
||||
const fallbackStored = await storeUploadLocally({
|
||||
file: params.file,
|
||||
tenantId,
|
||||
assetId: params.assetId,
|
||||
fileRole: params.fileRole,
|
||||
variant: params.variant,
|
||||
})
|
||||
return {
|
||||
...fallbackStored,
|
||||
provider: "local",
|
||||
fallbackReason: `S3 upload failed; file stored locally instead. ${detail}`,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const stored = await storeUploadLocally({
|
||||
file: params.file,
|
||||
tenantId,
|
||||
assetId: params.assetId,
|
||||
fileRole: params.fileRole,
|
||||
variant: params.variant,
|
||||
})
|
||||
|
||||
return {
|
||||
...stored,
|
||||
provider,
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteStoredMediaObject(storageKey: string): Promise<void> {
|
||||
const preferred = resolveMediaStorageProvider(process.env.CMS_MEDIA_STORAGE_PROVIDER)
|
||||
const deleteOperations =
|
||||
preferred === "s3"
|
||||
? [() => deleteS3Object(storageKey), () => deleteLocalStorageObject(storageKey)]
|
||||
: [() => deleteLocalStorageObject(storageKey), () => deleteS3Object(storageKey)]
|
||||
const errors: string[] = []
|
||||
|
||||
for (const performDelete of deleteOperations) {
|
||||
try {
|
||||
const deleted = await performDelete()
|
||||
|
||||
if (deleted) {
|
||||
return
|
||||
}
|
||||
} catch (error) {
|
||||
const detail = describeS3Error(error)
|
||||
errors.push(detail)
|
||||
}
|
||||
}
|
||||
|
||||
if (errors.length > 0) {
|
||||
throw new Error(`Storage object deletion failed for key "${storageKey}": ${errors.join(" | ")}`)
|
||||
}
|
||||
}
|
||||
30
apps/admin/src/lib/route-guards.ts
Normal file
30
apps/admin/src/lib/route-guards.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { hasPermission, type Permission, type PermissionScope, type Role } from "@cms/content/rbac"
|
||||
import { redirect } from "next/navigation"
|
||||
|
||||
import { resolveRoleFromServerContext } from "@/lib/access-server"
|
||||
|
||||
type RequirePermissionParams = {
|
||||
nextPath: string
|
||||
permission: Permission
|
||||
scope: PermissionScope
|
||||
}
|
||||
|
||||
export async function requireRoleForRoute(nextPath: string): Promise<Role> {
|
||||
const role = await resolveRoleFromServerContext()
|
||||
|
||||
if (!role) {
|
||||
redirect(`/login?next=${encodeURIComponent(nextPath)}`)
|
||||
}
|
||||
|
||||
return role
|
||||
}
|
||||
|
||||
export async function requirePermissionForRoute(params: RequirePermissionParams): Promise<Role> {
|
||||
const role = await requireRoleForRoute(params.nextPath)
|
||||
|
||||
if (!hasPermission(role, params.permission, params.scope)) {
|
||||
redirect(`/unauthorized?required=${params.permission}&scope=${params.scope}`)
|
||||
}
|
||||
|
||||
return role
|
||||
}
|
||||
132
apps/admin/src/messages/de.json
Normal file
132
apps/admin/src/messages/de.json
Normal file
@@ -0,0 +1,132 @@
|
||||
{
|
||||
"common": {
|
||||
"language": "Sprache",
|
||||
"localeNames": {
|
||||
"de": "Deutsch",
|
||||
"en": "Englisch",
|
||||
"es": "Spanisch",
|
||||
"fr": "Französisch"
|
||||
}
|
||||
},
|
||||
"auth": {
|
||||
"badge": "Admin-Authentifizierung",
|
||||
"titles": {
|
||||
"signIn": "Bei CMS Admin anmelden",
|
||||
"signUpOwner": "Willkommen bei CMS Admin",
|
||||
"signUpUser": "Admin-Konto erstellen",
|
||||
"signUpDisabled": "Registrierung ist deaktiviert"
|
||||
},
|
||||
"descriptions": {
|
||||
"signIn": "Better Auth ist in dieser App über /api/auth aktiv.",
|
||||
"signUpOwner": "Erstelle das erste Owner-Konto, um diese Admin-Instanz zu initialisieren.",
|
||||
"signUpUser": "Selbstregistrierung für Admin-Benutzer ist aktiviert.",
|
||||
"signUpDisabled": "Selbstregistrierung wurde von einer Administratorin oder einem Administrator deaktiviert."
|
||||
},
|
||||
"fields": {
|
||||
"name": "Name",
|
||||
"emailOrUsername": "E-Mail oder Benutzername",
|
||||
"email": "E-Mail",
|
||||
"username": "Benutzername (optional)",
|
||||
"password": "Passwort"
|
||||
},
|
||||
"actions": {
|
||||
"signInIdle": "Anmelden",
|
||||
"signInBusy": "Anmeldung läuft...",
|
||||
"signUpOwnerIdle": "Owner-Konto erstellen",
|
||||
"signUpUserIdle": "Konto erstellen",
|
||||
"signUpBusy": "Konto wird erstellt..."
|
||||
},
|
||||
"links": {
|
||||
"needAccount": "Du brauchst ein Konto?",
|
||||
"register": "Registrieren",
|
||||
"alreadyHaveAccount": "Du hast bereits ein Konto?",
|
||||
"goToSignIn": "Zur Anmeldung"
|
||||
},
|
||||
"messages": {
|
||||
"ownerCreated": "Owner-Konto erstellt. Registrierung ist jetzt deaktiviert.",
|
||||
"accountCreated": "Konto erstellt.",
|
||||
"registrationDisabled": "Für diese Admin-Instanz ist die Registrierung deaktiviert. Bitte wende dich an eine Administratorin oder einen Administrator."
|
||||
},
|
||||
"errors": {
|
||||
"nameRequired": "Name ist für die Kontoerstellung erforderlich",
|
||||
"signInFailed": "Anmeldung fehlgeschlagen",
|
||||
"signUpFailed": "Registrierung fehlgeschlagen",
|
||||
"networkSignIn": "Netzwerkfehler bei der Anmeldung",
|
||||
"networkSignUp": "Netzwerkfehler bei der Registrierung"
|
||||
}
|
||||
},
|
||||
"settings": {
|
||||
"badge": "Admin-Einstellungen",
|
||||
"title": "Einstellungen",
|
||||
"description": "Verwalte Laufzeitrichtlinien für Authentifizierung und Onboarding im Admin-Bereich.",
|
||||
"actions": {
|
||||
"backToDashboard": "Zurück zum Dashboard"
|
||||
},
|
||||
"registration": {
|
||||
"title": "Admin-Selbstregistrierung",
|
||||
"description": "Wenn aktiviert, können über /register nach der initialen Owner-Erstellung weitere Admin-Konten erstellt werden.",
|
||||
"currentStatusLabel": "Aktueller Status",
|
||||
"status": {
|
||||
"enabled": "Aktiviert",
|
||||
"disabled": "Deaktiviert"
|
||||
},
|
||||
"checkboxLabel": "Selbstregistrierung auf /register für Admin-Benutzer erlauben",
|
||||
"actions": {
|
||||
"save": "Registrierungsrichtlinie speichern"
|
||||
},
|
||||
"success": {
|
||||
"updated": "Registrierungsrichtlinie aktualisiert."
|
||||
},
|
||||
"errors": {
|
||||
"updateFailed": "Speichern der Einstellungen fehlgeschlagen. Stelle sicher, dass Datenbankmigrationen angewendet wurden."
|
||||
}
|
||||
}
|
||||
},
|
||||
"dashboard": {
|
||||
"badge": "Admin-App",
|
||||
"title": "Content-Dashboard",
|
||||
"description": "Verwalte Beiträge in einer dedizierten Admin-Oberfläche.",
|
||||
"actions": {
|
||||
"openRoadmap": "Roadmap und Fortschritt öffnen"
|
||||
},
|
||||
"notices": {
|
||||
"noCrudPermission": "Du kannst Beiträge lesen, aber deine Rolle darf keine Beiträge erstellen/ändern/löschen.",
|
||||
"crudSandboxTag": "MVP0 Funktionstest"
|
||||
},
|
||||
"posts": {
|
||||
"title": "Beiträge CRUD-Sandbox",
|
||||
"createTitle": "Beitrag erstellen",
|
||||
"fields": {
|
||||
"title": "Titel",
|
||||
"slug": "Slug",
|
||||
"excerpt": "Auszug",
|
||||
"body": "Inhalt",
|
||||
"status": "Status"
|
||||
},
|
||||
"status": {
|
||||
"draft": "Entwurf",
|
||||
"published": "Veröffentlicht"
|
||||
},
|
||||
"actions": {
|
||||
"create": "Beitrag erstellen",
|
||||
"save": "Änderungen speichern",
|
||||
"delete": "Löschen"
|
||||
},
|
||||
"errors": {
|
||||
"createFailed": "Erstellen fehlgeschlagen. Bitte Eingaben prüfen.",
|
||||
"updateFailed": "Aktualisierung fehlgeschlagen. Bitte Eingaben prüfen.",
|
||||
"updateMissingId": "Aktualisierung fehlgeschlagen. Beitrags-ID fehlt.",
|
||||
"deleteFailed": "Löschen fehlgeschlagen.",
|
||||
"deleteMissingId": "Löschen fehlgeschlagen. Beitrags-ID fehlt."
|
||||
},
|
||||
"success": {
|
||||
"created": "Beitrag erstellt.",
|
||||
"updated": "Beitrag aktualisiert.",
|
||||
"deleted": "Beitrag gelöscht."
|
||||
},
|
||||
"fallback": {
|
||||
"noExcerpt": "Kein Auszug"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
132
apps/admin/src/messages/en.json
Normal file
132
apps/admin/src/messages/en.json
Normal file
@@ -0,0 +1,132 @@
|
||||
{
|
||||
"common": {
|
||||
"language": "Language",
|
||||
"localeNames": {
|
||||
"de": "German",
|
||||
"en": "English",
|
||||
"es": "Spanish",
|
||||
"fr": "French"
|
||||
}
|
||||
},
|
||||
"auth": {
|
||||
"badge": "Admin Auth",
|
||||
"titles": {
|
||||
"signIn": "Sign in to CMS Admin",
|
||||
"signUpOwner": "Welcome to CMS Admin",
|
||||
"signUpUser": "Create an admin account",
|
||||
"signUpDisabled": "Registration is disabled"
|
||||
},
|
||||
"descriptions": {
|
||||
"signIn": "Better Auth is active on this app via /api/auth.",
|
||||
"signUpOwner": "Create the first owner account to initialize this admin instance.",
|
||||
"signUpUser": "Self-registration is enabled for admin users.",
|
||||
"signUpDisabled": "Self-registration is currently turned off by an administrator."
|
||||
},
|
||||
"fields": {
|
||||
"name": "Name",
|
||||
"emailOrUsername": "Email or username",
|
||||
"email": "Email",
|
||||
"username": "Username (optional)",
|
||||
"password": "Password"
|
||||
},
|
||||
"actions": {
|
||||
"signInIdle": "Sign in",
|
||||
"signInBusy": "Signing in...",
|
||||
"signUpOwnerIdle": "Create owner account",
|
||||
"signUpUserIdle": "Create account",
|
||||
"signUpBusy": "Creating account..."
|
||||
},
|
||||
"links": {
|
||||
"needAccount": "Need an account?",
|
||||
"register": "Register",
|
||||
"alreadyHaveAccount": "Already have an account?",
|
||||
"goToSignIn": "Go to sign in"
|
||||
},
|
||||
"messages": {
|
||||
"ownerCreated": "Owner account created. Registration is now disabled.",
|
||||
"accountCreated": "Account created.",
|
||||
"registrationDisabled": "Registration is disabled for this admin instance. Ask an administrator to create an account or enable self-registration."
|
||||
},
|
||||
"errors": {
|
||||
"nameRequired": "Name is required for account creation",
|
||||
"signInFailed": "Sign in failed",
|
||||
"signUpFailed": "Sign up failed",
|
||||
"networkSignIn": "Network error while signing in",
|
||||
"networkSignUp": "Network error while signing up"
|
||||
}
|
||||
},
|
||||
"settings": {
|
||||
"badge": "Admin Settings",
|
||||
"title": "Settings",
|
||||
"description": "Manage runtime policies for the admin authentication and onboarding flow.",
|
||||
"actions": {
|
||||
"backToDashboard": "Back to dashboard"
|
||||
},
|
||||
"registration": {
|
||||
"title": "Admin self-registration",
|
||||
"description": "When enabled, /register can create additional admin accounts after initial owner bootstrap.",
|
||||
"currentStatusLabel": "Current status",
|
||||
"status": {
|
||||
"enabled": "Enabled",
|
||||
"disabled": "Disabled"
|
||||
},
|
||||
"checkboxLabel": "Allow self-registration on /register for admin users",
|
||||
"actions": {
|
||||
"save": "Save registration policy"
|
||||
},
|
||||
"success": {
|
||||
"updated": "Registration policy updated."
|
||||
},
|
||||
"errors": {
|
||||
"updateFailed": "Saving settings failed. Ensure database migrations are applied."
|
||||
}
|
||||
}
|
||||
},
|
||||
"dashboard": {
|
||||
"badge": "Admin App",
|
||||
"title": "Content Dashboard",
|
||||
"description": "Manage posts from a dedicated admin surface.",
|
||||
"actions": {
|
||||
"openRoadmap": "Open roadmap and progress"
|
||||
},
|
||||
"notices": {
|
||||
"noCrudPermission": "You can read posts, but your role cannot create/update/delete posts.",
|
||||
"crudSandboxTag": "MVP0 functional test"
|
||||
},
|
||||
"posts": {
|
||||
"title": "Posts CRUD Sandbox",
|
||||
"createTitle": "Create post",
|
||||
"fields": {
|
||||
"title": "Title",
|
||||
"slug": "Slug",
|
||||
"excerpt": "Excerpt",
|
||||
"body": "Body",
|
||||
"status": "Status"
|
||||
},
|
||||
"status": {
|
||||
"draft": "Draft",
|
||||
"published": "Published"
|
||||
},
|
||||
"actions": {
|
||||
"create": "Create post",
|
||||
"save": "Save changes",
|
||||
"delete": "Delete"
|
||||
},
|
||||
"errors": {
|
||||
"createFailed": "Create failed. Please check your input.",
|
||||
"updateFailed": "Update failed. Please check your input.",
|
||||
"updateMissingId": "Update failed. Missing post id.",
|
||||
"deleteFailed": "Delete failed.",
|
||||
"deleteMissingId": "Delete failed. Missing post id."
|
||||
},
|
||||
"success": {
|
||||
"created": "Post created.",
|
||||
"updated": "Post updated.",
|
||||
"deleted": "Post deleted."
|
||||
},
|
||||
"fallback": {
|
||||
"noExcerpt": "No excerpt"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
132
apps/admin/src/messages/es.json
Normal file
132
apps/admin/src/messages/es.json
Normal file
@@ -0,0 +1,132 @@
|
||||
{
|
||||
"common": {
|
||||
"language": "Idioma",
|
||||
"localeNames": {
|
||||
"de": "Alemán",
|
||||
"en": "Inglés",
|
||||
"es": "Español",
|
||||
"fr": "Francés"
|
||||
}
|
||||
},
|
||||
"auth": {
|
||||
"badge": "Autenticación de Admin",
|
||||
"titles": {
|
||||
"signIn": "Iniciar sesión en CMS Admin",
|
||||
"signUpOwner": "Bienvenido a CMS Admin",
|
||||
"signUpUser": "Crear una cuenta de admin",
|
||||
"signUpDisabled": "El registro está deshabilitado"
|
||||
},
|
||||
"descriptions": {
|
||||
"signIn": "Better Auth está activo en esta app mediante /api/auth.",
|
||||
"signUpOwner": "Crea la primera cuenta owner para inicializar esta instancia de administración.",
|
||||
"signUpUser": "El registro automático está habilitado para usuarios admin.",
|
||||
"signUpDisabled": "El auto-registro está desactivado actualmente por un administrador."
|
||||
},
|
||||
"fields": {
|
||||
"name": "Nombre",
|
||||
"emailOrUsername": "Correo o nombre de usuario",
|
||||
"email": "Correo",
|
||||
"username": "Nombre de usuario (opcional)",
|
||||
"password": "Contraseña"
|
||||
},
|
||||
"actions": {
|
||||
"signInIdle": "Iniciar sesión",
|
||||
"signInBusy": "Iniciando sesión...",
|
||||
"signUpOwnerIdle": "Crear cuenta owner",
|
||||
"signUpUserIdle": "Crear cuenta",
|
||||
"signUpBusy": "Creando cuenta..."
|
||||
},
|
||||
"links": {
|
||||
"needAccount": "¿Necesitas una cuenta?",
|
||||
"register": "Registrarse",
|
||||
"alreadyHaveAccount": "¿Ya tienes una cuenta?",
|
||||
"goToSignIn": "Ir a iniciar sesión"
|
||||
},
|
||||
"messages": {
|
||||
"ownerCreated": "Cuenta owner creada. El registro ahora está deshabilitado.",
|
||||
"accountCreated": "Cuenta creada.",
|
||||
"registrationDisabled": "El registro está deshabilitado para esta instancia de administración. Pide a un administrador que cree una cuenta o habilite el auto-registro."
|
||||
},
|
||||
"errors": {
|
||||
"nameRequired": "El nombre es obligatorio para crear la cuenta",
|
||||
"signInFailed": "Error al iniciar sesión",
|
||||
"signUpFailed": "Error al registrarse",
|
||||
"networkSignIn": "Error de red al iniciar sesión",
|
||||
"networkSignUp": "Error de red al registrarse"
|
||||
}
|
||||
},
|
||||
"settings": {
|
||||
"badge": "Ajustes de Admin",
|
||||
"title": "Ajustes",
|
||||
"description": "Gestiona políticas de ejecución para autenticación y onboarding del panel admin.",
|
||||
"actions": {
|
||||
"backToDashboard": "Volver al panel"
|
||||
},
|
||||
"registration": {
|
||||
"title": "Auto-registro de admin",
|
||||
"description": "Cuando está habilitado, /register puede crear cuentas admin adicionales después del bootstrap inicial del owner.",
|
||||
"currentStatusLabel": "Estado actual",
|
||||
"status": {
|
||||
"enabled": "Habilitado",
|
||||
"disabled": "Deshabilitado"
|
||||
},
|
||||
"checkboxLabel": "Permitir auto-registro en /register para usuarios admin",
|
||||
"actions": {
|
||||
"save": "Guardar política de registro"
|
||||
},
|
||||
"success": {
|
||||
"updated": "Política de registro actualizada."
|
||||
},
|
||||
"errors": {
|
||||
"updateFailed": "No se pudieron guardar los ajustes. Asegúrate de que las migraciones de base de datos estén aplicadas."
|
||||
}
|
||||
}
|
||||
},
|
||||
"dashboard": {
|
||||
"badge": "App Admin",
|
||||
"title": "Panel de Contenido",
|
||||
"description": "Gestiona publicaciones desde una superficie de administración dedicada.",
|
||||
"actions": {
|
||||
"openRoadmap": "Abrir hoja de ruta y progreso"
|
||||
},
|
||||
"notices": {
|
||||
"noCrudPermission": "Puedes leer publicaciones, pero tu rol no puede crear/editar/eliminar publicaciones.",
|
||||
"crudSandboxTag": "Prueba funcional MVP0"
|
||||
},
|
||||
"posts": {
|
||||
"title": "Sandbox CRUD de Publicaciones",
|
||||
"createTitle": "Crear publicación",
|
||||
"fields": {
|
||||
"title": "Título",
|
||||
"slug": "Slug",
|
||||
"excerpt": "Extracto",
|
||||
"body": "Contenido",
|
||||
"status": "Estado"
|
||||
},
|
||||
"status": {
|
||||
"draft": "Borrador",
|
||||
"published": "Publicado"
|
||||
},
|
||||
"actions": {
|
||||
"create": "Crear publicación",
|
||||
"save": "Guardar cambios",
|
||||
"delete": "Eliminar"
|
||||
},
|
||||
"errors": {
|
||||
"createFailed": "Error al crear. Revisa tus datos.",
|
||||
"updateFailed": "Error al actualizar. Revisa tus datos.",
|
||||
"updateMissingId": "Error al actualizar. Falta el id de la publicación.",
|
||||
"deleteFailed": "Error al eliminar.",
|
||||
"deleteMissingId": "Error al eliminar. Falta el id de la publicación."
|
||||
},
|
||||
"success": {
|
||||
"created": "Publicación creada.",
|
||||
"updated": "Publicación actualizada.",
|
||||
"deleted": "Publicación eliminada."
|
||||
},
|
||||
"fallback": {
|
||||
"noExcerpt": "Sin extracto"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
132
apps/admin/src/messages/fr.json
Normal file
132
apps/admin/src/messages/fr.json
Normal file
@@ -0,0 +1,132 @@
|
||||
{
|
||||
"common": {
|
||||
"language": "Langue",
|
||||
"localeNames": {
|
||||
"de": "Allemand",
|
||||
"en": "Anglais",
|
||||
"es": "Espagnol",
|
||||
"fr": "Français"
|
||||
}
|
||||
},
|
||||
"auth": {
|
||||
"badge": "Authentification Admin",
|
||||
"titles": {
|
||||
"signIn": "Se connecter à CMS Admin",
|
||||
"signUpOwner": "Bienvenue sur CMS Admin",
|
||||
"signUpUser": "Créer un compte admin",
|
||||
"signUpDisabled": "L’inscription est désactivée"
|
||||
},
|
||||
"descriptions": {
|
||||
"signIn": "Better Auth est actif sur cette application via /api/auth.",
|
||||
"signUpOwner": "Créez le premier compte owner pour initialiser cette instance d’administration.",
|
||||
"signUpUser": "L’auto-inscription est activée pour les utilisateurs admin.",
|
||||
"signUpDisabled": "L’auto-inscription est actuellement désactivée par un administrateur."
|
||||
},
|
||||
"fields": {
|
||||
"name": "Nom",
|
||||
"emailOrUsername": "E-mail ou nom d’utilisateur",
|
||||
"email": "E-mail",
|
||||
"username": "Nom d’utilisateur (optionnel)",
|
||||
"password": "Mot de passe"
|
||||
},
|
||||
"actions": {
|
||||
"signInIdle": "Se connecter",
|
||||
"signInBusy": "Connexion en cours...",
|
||||
"signUpOwnerIdle": "Créer le compte owner",
|
||||
"signUpUserIdle": "Créer un compte",
|
||||
"signUpBusy": "Création du compte..."
|
||||
},
|
||||
"links": {
|
||||
"needAccount": "Besoin d’un compte ?",
|
||||
"register": "S’inscrire",
|
||||
"alreadyHaveAccount": "Vous avez déjà un compte ?",
|
||||
"goToSignIn": "Aller à la connexion"
|
||||
},
|
||||
"messages": {
|
||||
"ownerCreated": "Compte owner créé. L’inscription est maintenant désactivée.",
|
||||
"accountCreated": "Compte créé.",
|
||||
"registrationDisabled": "L’inscription est désactivée pour cette instance admin. Demandez à un administrateur de créer un compte ou de réactiver l’auto-inscription."
|
||||
},
|
||||
"errors": {
|
||||
"nameRequired": "Le nom est requis pour créer un compte",
|
||||
"signInFailed": "Échec de la connexion",
|
||||
"signUpFailed": "Échec de l’inscription",
|
||||
"networkSignIn": "Erreur réseau lors de la connexion",
|
||||
"networkSignUp": "Erreur réseau lors de l’inscription"
|
||||
}
|
||||
},
|
||||
"settings": {
|
||||
"badge": "Paramètres Admin",
|
||||
"title": "Paramètres",
|
||||
"description": "Gérez les politiques d’exécution pour l’authentification et l’onboarding de l’admin.",
|
||||
"actions": {
|
||||
"backToDashboard": "Retour au tableau de bord"
|
||||
},
|
||||
"registration": {
|
||||
"title": "Auto-inscription admin",
|
||||
"description": "Lorsqu’elle est activée, /register peut créer des comptes admin supplémentaires après l’initialisation du premier owner.",
|
||||
"currentStatusLabel": "Statut actuel",
|
||||
"status": {
|
||||
"enabled": "Activé",
|
||||
"disabled": "Désactivé"
|
||||
},
|
||||
"checkboxLabel": "Autoriser l’auto-inscription sur /register pour les utilisateurs admin",
|
||||
"actions": {
|
||||
"save": "Enregistrer la politique d’inscription"
|
||||
},
|
||||
"success": {
|
||||
"updated": "Politique d’inscription mise à jour."
|
||||
},
|
||||
"errors": {
|
||||
"updateFailed": "Échec de l’enregistrement des paramètres. Vérifiez que les migrations de base de données sont appliquées."
|
||||
}
|
||||
}
|
||||
},
|
||||
"dashboard": {
|
||||
"badge": "Application Admin",
|
||||
"title": "Tableau de bord contenu",
|
||||
"description": "Gérez les publications depuis une surface d’administration dédiée.",
|
||||
"actions": {
|
||||
"openRoadmap": "Ouvrir la feuille de route et la progression"
|
||||
},
|
||||
"notices": {
|
||||
"noCrudPermission": "Vous pouvez lire les publications, mais votre rôle ne peut pas créer/modifier/supprimer des publications.",
|
||||
"crudSandboxTag": "Test fonctionnel MVP0"
|
||||
},
|
||||
"posts": {
|
||||
"title": "Sandbox CRUD des publications",
|
||||
"createTitle": "Créer une publication",
|
||||
"fields": {
|
||||
"title": "Titre",
|
||||
"slug": "Slug",
|
||||
"excerpt": "Extrait",
|
||||
"body": "Contenu",
|
||||
"status": "Statut"
|
||||
},
|
||||
"status": {
|
||||
"draft": "Brouillon",
|
||||
"published": "Publié"
|
||||
},
|
||||
"actions": {
|
||||
"create": "Créer une publication",
|
||||
"save": "Enregistrer les modifications",
|
||||
"delete": "Supprimer"
|
||||
},
|
||||
"errors": {
|
||||
"createFailed": "Échec de la création. Vérifiez vos données.",
|
||||
"updateFailed": "Échec de la mise à jour. Vérifiez vos données.",
|
||||
"updateMissingId": "Échec de la mise à jour. ID de publication manquant.",
|
||||
"deleteFailed": "Échec de la suppression.",
|
||||
"deleteMissingId": "Échec de la suppression. ID de publication manquant."
|
||||
},
|
||||
"success": {
|
||||
"created": "Publication créée.",
|
||||
"updated": "Publication mise à jour.",
|
||||
"deleted": "Publication supprimée."
|
||||
},
|
||||
"fallback": {
|
||||
"noExcerpt": "Aucun extrait"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
53
apps/admin/src/providers/admin-i18n-provider.tsx
Normal file
53
apps/admin/src/providers/admin-i18n-provider.tsx
Normal file
@@ -0,0 +1,53 @@
|
||||
"use client"
|
||||
|
||||
import type { AppLocale } from "@cms/i18n"
|
||||
import { createContext, type ReactNode, useContext, useMemo } from "react"
|
||||
|
||||
import type { AdminMessages } from "@/i18n/messages"
|
||||
import { translateMessage } from "@/i18n/messages"
|
||||
|
||||
type AdminI18nContextValue = {
|
||||
locale: AppLocale
|
||||
messages: AdminMessages
|
||||
}
|
||||
|
||||
const AdminI18nContext = createContext<AdminI18nContextValue | null>(null)
|
||||
|
||||
export function AdminI18nProvider({
|
||||
locale,
|
||||
messages,
|
||||
children,
|
||||
}: {
|
||||
locale: AppLocale
|
||||
messages: AdminMessages
|
||||
children: ReactNode
|
||||
}) {
|
||||
const value = useMemo(
|
||||
() => ({
|
||||
locale,
|
||||
messages,
|
||||
}),
|
||||
[locale, messages],
|
||||
)
|
||||
|
||||
return <AdminI18nContext.Provider value={value}>{children}</AdminI18nContext.Provider>
|
||||
}
|
||||
|
||||
export function useAdminI18n(): AdminI18nContextValue {
|
||||
const context = useContext(AdminI18nContext)
|
||||
|
||||
if (!context) {
|
||||
throw new Error("useAdminI18n must be used inside AdminI18nProvider")
|
||||
}
|
||||
|
||||
return context
|
||||
}
|
||||
|
||||
export function useAdminT() {
|
||||
const { messages } = useAdminI18n()
|
||||
|
||||
return useMemo(
|
||||
() => (key: string, fallback?: string) => translateMessage(messages, key, fallback),
|
||||
[messages],
|
||||
)
|
||||
}
|
||||
@@ -11,6 +11,7 @@
|
||||
"typecheck": "tsc -p tsconfig.json --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-s3": "3.988.0",
|
||||
"@cms/content": "workspace:*",
|
||||
"@cms/db": "workspace:*",
|
||||
"@cms/i18n": "workspace:*",
|
||||
|
||||
21
apps/web/src/app/[locale]/[slug]/page.tsx
Normal file
21
apps/web/src/app/[locale]/[slug]/page.tsx
Normal file
@@ -0,0 +1,21 @@
|
||||
import { getPublishedPageBySlugForLocale } from "@cms/db"
|
||||
import { notFound } from "next/navigation"
|
||||
|
||||
import { PublicPageView } from "@/components/public-page-view"
|
||||
|
||||
export const dynamic = "force-dynamic"
|
||||
|
||||
type PageProps = {
|
||||
params: Promise<{ locale: string; slug: string }>
|
||||
}
|
||||
|
||||
export default async function CmsPageRoute({ params }: PageProps) {
|
||||
const { locale, slug } = await params
|
||||
const page = await getPublishedPageBySlugForLocale(slug, locale)
|
||||
|
||||
if (!page) {
|
||||
notFound()
|
||||
}
|
||||
|
||||
return <PublicPageView page={page} />
|
||||
}
|
||||
217
apps/web/src/app/[locale]/commissions/page.tsx
Normal file
217
apps/web/src/app/[locale]/commissions/page.tsx
Normal file
@@ -0,0 +1,217 @@
|
||||
import { createPublicCommissionRequest } from "@cms/db"
|
||||
import { revalidatePath } from "next/cache"
|
||||
import { redirect } from "next/navigation"
|
||||
import { getTranslations } from "next-intl/server"
|
||||
|
||||
export const dynamic = "force-dynamic"
|
||||
|
||||
type SearchParamsInput = Record<string, string | string[] | undefined>
|
||||
|
||||
type PublicCommissionRequestPageProps = {
|
||||
params: Promise<{ locale: string }>
|
||||
searchParams: Promise<SearchParamsInput>
|
||||
}
|
||||
|
||||
function readFirstValue(value: string | string[] | undefined): string | null {
|
||||
if (Array.isArray(value)) {
|
||||
return value[0] ?? null
|
||||
}
|
||||
|
||||
return value ?? null
|
||||
}
|
||||
|
||||
function readInputString(formData: FormData, field: string): string {
|
||||
const value = formData.get(field)
|
||||
return typeof value === "string" ? value.trim() : ""
|
||||
}
|
||||
|
||||
function readNullableString(formData: FormData, field: string): string | null {
|
||||
const value = readInputString(formData, field)
|
||||
return value.length > 0 ? value : null
|
||||
}
|
||||
|
||||
function readNullableNumber(formData: FormData, field: string): number | null {
|
||||
const value = readInputString(formData, field)
|
||||
|
||||
if (!value) {
|
||||
return null
|
||||
}
|
||||
|
||||
const parsed = Number.parseFloat(value)
|
||||
return Number.isFinite(parsed) ? parsed : null
|
||||
}
|
||||
|
||||
function buildRedirect(locale: string, params: { notice?: string; error?: string }) {
|
||||
const query = new URLSearchParams()
|
||||
|
||||
if (params.notice) {
|
||||
query.set("notice", params.notice)
|
||||
}
|
||||
|
||||
if (params.error) {
|
||||
query.set("error", params.error)
|
||||
}
|
||||
|
||||
const serialized = query.toString()
|
||||
return serialized ? `/${locale}/commissions?${serialized}` : `/${locale}/commissions`
|
||||
}
|
||||
|
||||
export default async function PublicCommissionRequestPage({
|
||||
params,
|
||||
searchParams,
|
||||
}: PublicCommissionRequestPageProps) {
|
||||
const { locale } = await params
|
||||
const [resolvedSearchParams, t] = await Promise.all([
|
||||
searchParams,
|
||||
getTranslations("CommissionRequest"),
|
||||
])
|
||||
|
||||
async function submitCommissionRequestAction(formData: FormData) {
|
||||
"use server"
|
||||
|
||||
const budgetMin = readNullableNumber(formData, "budgetMin")
|
||||
const budgetMax = readNullableNumber(formData, "budgetMax")
|
||||
|
||||
if (budgetMin != null && budgetMax != null && budgetMax < budgetMin) {
|
||||
redirect(buildRedirect(locale, { error: "budget_range_invalid" }))
|
||||
}
|
||||
|
||||
try {
|
||||
await createPublicCommissionRequest({
|
||||
customerName: readInputString(formData, "customerName"),
|
||||
customerEmail: readInputString(formData, "customerEmail"),
|
||||
customerPhone: readNullableString(formData, "customerPhone"),
|
||||
customerInstagram: readNullableString(formData, "customerInstagram"),
|
||||
title: readInputString(formData, "title"),
|
||||
description: readNullableString(formData, "description"),
|
||||
budgetMin,
|
||||
budgetMax,
|
||||
})
|
||||
} catch {
|
||||
redirect(buildRedirect(locale, { error: "submission_failed" }))
|
||||
}
|
||||
|
||||
revalidatePath(`/${locale}/commissions`)
|
||||
redirect(buildRedirect(locale, { notice: "submitted" }))
|
||||
}
|
||||
|
||||
const notice = readFirstValue(resolvedSearchParams.notice)
|
||||
const error = readFirstValue(resolvedSearchParams.error)
|
||||
|
||||
return (
|
||||
<section className="mx-auto w-full max-w-3xl space-y-6 px-6 py-16">
|
||||
<header className="space-y-2">
|
||||
<p className="text-sm uppercase tracking-[0.2em] text-neutral-500">{t("badge")}</p>
|
||||
<h1 className="text-4xl font-semibold tracking-tight">{t("title")}</h1>
|
||||
<p className="text-neutral-600">{t("description")}</p>
|
||||
</header>
|
||||
|
||||
{notice === "submitted" ? (
|
||||
<section className="rounded-xl border border-emerald-300 bg-emerald-50 px-4 py-3 text-sm text-emerald-800">
|
||||
{t("success")}
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
{error === "submission_failed" ? (
|
||||
<section className="rounded-xl border border-red-300 bg-red-50 px-4 py-3 text-sm text-red-800">
|
||||
{t("error")}
|
||||
</section>
|
||||
) : null}
|
||||
{error === "budget_range_invalid" ? (
|
||||
<section className="rounded-xl border border-amber-300 bg-amber-50 px-4 py-3 text-sm text-amber-800">
|
||||
{t("budgetRangeError")}
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
<form action={submitCommissionRequestAction} className="space-y-4 rounded-xl border p-6">
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<label className="space-y-1">
|
||||
<span className="text-xs text-neutral-600">{t("fields.customerName")}</span>
|
||||
<input
|
||||
name="customerName"
|
||||
autoComplete="name"
|
||||
required
|
||||
className="w-full rounded border border-neutral-300 px-3 py-2 text-sm"
|
||||
/>
|
||||
</label>
|
||||
<label className="space-y-1">
|
||||
<span className="text-xs text-neutral-600">{t("fields.customerEmail")}</span>
|
||||
<input
|
||||
name="customerEmail"
|
||||
type="email"
|
||||
autoComplete="email"
|
||||
required
|
||||
className="w-full rounded border border-neutral-300 px-3 py-2 text-sm"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<label className="space-y-1">
|
||||
<span className="text-xs text-neutral-600">{t("fields.customerPhone")}</span>
|
||||
<input
|
||||
name="customerPhone"
|
||||
autoComplete="tel"
|
||||
className="w-full rounded border border-neutral-300 px-3 py-2 text-sm"
|
||||
/>
|
||||
</label>
|
||||
<label className="space-y-1">
|
||||
<span className="text-xs text-neutral-600">{t("fields.customerInstagram")}</span>
|
||||
<input
|
||||
name="customerInstagram"
|
||||
className="w-full rounded border border-neutral-300 px-3 py-2 text-sm"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<label className="space-y-1">
|
||||
<span className="text-xs text-neutral-600">{t("fields.title")}</span>
|
||||
<input
|
||||
name="title"
|
||||
required
|
||||
className="w-full rounded border border-neutral-300 px-3 py-2 text-sm"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="space-y-1">
|
||||
<span className="text-xs text-neutral-600">{t("fields.description")}</span>
|
||||
<textarea
|
||||
name="description"
|
||||
rows={6}
|
||||
className="w-full rounded border border-neutral-300 px-3 py-2 text-sm"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<label className="space-y-1">
|
||||
<span className="text-xs text-neutral-600">{t("fields.budgetMin")}</span>
|
||||
<input
|
||||
name="budgetMin"
|
||||
type="number"
|
||||
min="0"
|
||||
step="0.01"
|
||||
className="w-full rounded border border-neutral-300 px-3 py-2 text-sm"
|
||||
/>
|
||||
</label>
|
||||
<label className="space-y-1">
|
||||
<span className="text-xs text-neutral-600">{t("fields.budgetMax")}</span>
|
||||
<input
|
||||
name="budgetMax"
|
||||
type="number"
|
||||
min="0"
|
||||
step="0.01"
|
||||
className="w-full rounded border border-neutral-300 px-3 py-2 text-sm"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
className="rounded-md bg-neutral-900 px-4 py-2 text-sm font-medium text-white hover:bg-neutral-700"
|
||||
>
|
||||
{t("submit")}
|
||||
</button>
|
||||
</form>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -1,7 +1,12 @@
|
||||
import { getPublicHeaderBanner } from "@cms/db"
|
||||
import { notFound } from "next/navigation"
|
||||
import { hasLocale, NextIntlClientProvider } from "next-intl"
|
||||
import { getTranslations } from "next-intl/server"
|
||||
import type { ReactNode } from "react"
|
||||
|
||||
import { PublicAnnouncements } from "@/components/public-announcements"
|
||||
import { PublicHeaderBanner } from "@/components/public-header-banner"
|
||||
import { PublicSiteFooter } from "@/components/public-site-footer"
|
||||
import { PublicSiteHeader } from "@/components/public-site-header"
|
||||
import { routing } from "@/i18n/routing"
|
||||
import { Providers } from "../providers"
|
||||
|
||||
@@ -12,6 +17,28 @@ type LocaleLayoutProps = {
|
||||
}>
|
||||
}
|
||||
|
||||
export async function generateMetadata({ params }: LocaleLayoutProps) {
|
||||
const { locale } = await params
|
||||
|
||||
if (!hasLocale(routing.locales, locale)) {
|
||||
return {}
|
||||
}
|
||||
|
||||
const t = await getTranslations({
|
||||
locale,
|
||||
namespace: "Seo",
|
||||
})
|
||||
|
||||
return {
|
||||
title: t("title"),
|
||||
description: t("description"),
|
||||
openGraph: {
|
||||
title: t("title"),
|
||||
description: t("description"),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export default async function LocaleLayout({ children, params }: LocaleLayoutProps) {
|
||||
const { locale } = await params
|
||||
|
||||
@@ -19,9 +46,17 @@ export default async function LocaleLayout({ children, params }: LocaleLayoutPro
|
||||
notFound()
|
||||
}
|
||||
|
||||
const banner = await getPublicHeaderBanner()
|
||||
|
||||
return (
|
||||
<NextIntlClientProvider locale={locale}>
|
||||
<Providers>{children}</Providers>
|
||||
<Providers>
|
||||
<PublicHeaderBanner banner={banner} />
|
||||
<PublicAnnouncements placement="global_top" locale={locale} />
|
||||
<PublicSiteHeader />
|
||||
<main>{children}</main>
|
||||
<PublicSiteFooter />
|
||||
</Providers>
|
||||
</NextIntlClientProvider>
|
||||
)
|
||||
}
|
||||
|
||||
30
apps/web/src/app/[locale]/news/[slug]/page.tsx
Normal file
30
apps/web/src/app/[locale]/news/[slug]/page.tsx
Normal file
@@ -0,0 +1,30 @@
|
||||
import { getPostBySlugForLocale } from "@cms/db"
|
||||
import { notFound } from "next/navigation"
|
||||
|
||||
export const dynamic = "force-dynamic"
|
||||
|
||||
type PageProps = {
|
||||
params: Promise<{ locale: string; slug: string }>
|
||||
}
|
||||
|
||||
export default async function PublicNewsDetailPage({ params }: PageProps) {
|
||||
const { locale, slug } = await params
|
||||
const post = await getPostBySlugForLocale(slug, locale)
|
||||
|
||||
if (!post || post.status !== "published") {
|
||||
notFound()
|
||||
}
|
||||
|
||||
return (
|
||||
<article className="mx-auto w-full max-w-4xl space-y-4 px-6 py-16">
|
||||
<header className="space-y-2">
|
||||
<p className="text-sm uppercase tracking-[0.2em] text-neutral-500">News</p>
|
||||
<h1 className="text-4xl font-semibold tracking-tight">{post.title}</h1>
|
||||
{post.excerpt ? <p className="text-neutral-600">{post.excerpt}</p> : null}
|
||||
</header>
|
||||
<section className="prose prose-neutral max-w-none whitespace-pre-wrap rounded-xl border border-neutral-200 bg-white p-6 text-neutral-800">
|
||||
{post.body}
|
||||
</section>
|
||||
</article>
|
||||
)
|
||||
}
|
||||
38
apps/web/src/app/[locale]/news/page.tsx
Normal file
38
apps/web/src/app/[locale]/news/page.tsx
Normal file
@@ -0,0 +1,38 @@
|
||||
import { listPostsForLocale } from "@cms/db"
|
||||
import Link from "next/link"
|
||||
|
||||
export const dynamic = "force-dynamic"
|
||||
|
||||
type PublicNewsIndexPageProps = {
|
||||
params: Promise<{ locale: string }>
|
||||
}
|
||||
|
||||
export default async function PublicNewsIndexPage({ params }: PublicNewsIndexPageProps) {
|
||||
const { locale } = await params
|
||||
const posts = await listPostsForLocale(locale)
|
||||
|
||||
return (
|
||||
<section className="mx-auto w-full max-w-4xl space-y-4 px-6 py-16">
|
||||
<header className="space-y-2">
|
||||
<p className="text-sm uppercase tracking-[0.2em] text-neutral-500">News</p>
|
||||
<h1 className="text-4xl font-semibold tracking-tight">Latest updates</h1>
|
||||
</header>
|
||||
|
||||
<div className="space-y-3">
|
||||
{posts.map((post) => (
|
||||
<article key={post.id} className="rounded-lg border border-neutral-200 p-4">
|
||||
<p className="text-xs uppercase tracking-wide text-neutral-500">{post.status}</p>
|
||||
<h2 className="mt-1 text-lg font-medium">{post.title}</h2>
|
||||
<p className="mt-2 text-sm text-neutral-600">{post.excerpt ?? "No excerpt"}</p>
|
||||
<Link
|
||||
href={`/news/${post.slug}`}
|
||||
className="mt-2 inline-block text-sm underline underline-offset-2"
|
||||
>
|
||||
Read post
|
||||
</Link>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -1,41 +1,66 @@
|
||||
import { listPosts } from "@cms/db"
|
||||
import { Button } from "@cms/ui/button"
|
||||
import { getPublishedPageBySlugForLocale, listPostsForLocale } from "@cms/db"
|
||||
import { getTranslations } from "next-intl/server"
|
||||
|
||||
import { LanguageSwitcher } from "@/components/language-switcher"
|
||||
import { PublicAnnouncements } from "@/components/public-announcements"
|
||||
import { PublicPageView } from "@/components/public-page-view"
|
||||
import { Link } from "@/i18n/navigation"
|
||||
|
||||
export const dynamic = "force-dynamic"
|
||||
|
||||
export default async function HomePage() {
|
||||
const [posts, t] = await Promise.all([listPosts(), getTranslations("Home")])
|
||||
type HomePageProps = {
|
||||
params: Promise<{ locale: string }>
|
||||
}
|
||||
|
||||
export default async function HomePage({ params }: HomePageProps) {
|
||||
const { locale } = await params
|
||||
|
||||
const [homePage, posts, t] = await Promise.all([
|
||||
getPublishedPageBySlugForLocale("home", locale),
|
||||
listPostsForLocale(locale),
|
||||
getTranslations("Home"),
|
||||
])
|
||||
|
||||
return (
|
||||
<main className="mx-auto flex min-h-screen w-full max-w-3xl flex-col gap-6 px-6 py-16">
|
||||
<section>
|
||||
{homePage ? <PublicPageView page={homePage} /> : null}
|
||||
<PublicAnnouncements placement="homepage" locale={locale} />
|
||||
|
||||
<section className="mx-auto flex w-full max-w-6xl flex-col gap-6 px-6 py-6 pb-16">
|
||||
<header className="space-y-3">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<p className="text-sm uppercase tracking-[0.2em] text-neutral-500">{t("badge")}</p>
|
||||
<LanguageSwitcher />
|
||||
</div>
|
||||
<h1 className="text-4xl font-semibold tracking-tight">{t("title")}</h1>
|
||||
<h2 className="text-3xl font-semibold tracking-tight">{t("latestPosts")}</h2>
|
||||
<p className="text-neutral-600">{t("description")}</p>
|
||||
</header>
|
||||
|
||||
<section className="space-y-4 rounded-xl border border-neutral-200 p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-xl font-medium">{t("latestPosts")}</h2>
|
||||
<Button variant="secondary">{t("explore")}</Button>
|
||||
<h3 className="text-xl font-medium">{t("latestPosts")}</h3>
|
||||
<div className="flex items-center gap-2">
|
||||
<Link
|
||||
href="/news"
|
||||
className="inline-flex h-10 items-center justify-center rounded-md bg-neutral-100 px-4 py-2 text-sm font-medium text-neutral-900 transition-colors hover:bg-neutral-200"
|
||||
>
|
||||
{t("explore")}
|
||||
</Link>
|
||||
<Link
|
||||
href="/commissions"
|
||||
className="inline-flex h-10 items-center justify-center rounded-md bg-neutral-900 px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-neutral-800"
|
||||
>
|
||||
{t("requestCommission")}
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ul className="space-y-3">
|
||||
{posts.map((post) => (
|
||||
<li key={post.id} className="rounded-lg border border-neutral-200 p-4">
|
||||
<p className="text-xs uppercase tracking-wide text-neutral-500">{post.status}</p>
|
||||
<h3 className="mt-1 text-lg font-medium">{post.title}</h3>
|
||||
<h4 className="mt-1 text-lg font-medium">{post.title}</h4>
|
||||
<p className="mt-2 text-sm text-neutral-600">{post.excerpt ?? t("noExcerpt")}</p>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
</main>
|
||||
</section>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
136
apps/web/src/app/[locale]/portfolio/[slug]/page.tsx
Normal file
136
apps/web/src/app/[locale]/portfolio/[slug]/page.tsx
Normal file
@@ -0,0 +1,136 @@
|
||||
import { getPublishedArtworkBySlug } from "@cms/db"
|
||||
import Image from "next/image"
|
||||
import { notFound } from "next/navigation"
|
||||
import { getTranslations } from "next-intl/server"
|
||||
|
||||
export const dynamic = "force-dynamic"
|
||||
|
||||
type PublicArtworkPageProps = {
|
||||
params: Promise<{ slug: string }>
|
||||
}
|
||||
|
||||
function formatLabelList(values: string[]) {
|
||||
if (values.length === 0) {
|
||||
return "-"
|
||||
}
|
||||
|
||||
return values.join(", ")
|
||||
}
|
||||
|
||||
function formatArtworkPrice(priceAmountCents: number | null, priceCurrency: string | null) {
|
||||
if (!priceAmountCents || !priceCurrency) {
|
||||
return "-"
|
||||
}
|
||||
|
||||
return new Intl.NumberFormat("en-US", {
|
||||
style: "currency",
|
||||
currency: priceCurrency,
|
||||
}).format(priceAmountCents / 100)
|
||||
}
|
||||
|
||||
export default async function PublicArtworkPage({ params }: PublicArtworkPageProps) {
|
||||
const [{ slug }, t] = await Promise.all([params, getTranslations("Portfolio")])
|
||||
const artwork = await getPublishedArtworkBySlug(slug)
|
||||
|
||||
if (!artwork) {
|
||||
notFound()
|
||||
}
|
||||
|
||||
const primaryMedia = artwork.renditions[0]?.mediaAsset ?? null
|
||||
|
||||
return (
|
||||
<section className="mx-auto w-full max-w-5xl space-y-6 px-6 py-16">
|
||||
<header className="space-y-2">
|
||||
<p className="text-sm uppercase tracking-[0.2em] text-neutral-500">{t("badge")}</p>
|
||||
<h1 className="text-4xl font-semibold tracking-tight">{artwork.title}</h1>
|
||||
<p className="text-neutral-600">{artwork.description || t("noDescription")}</p>
|
||||
</header>
|
||||
|
||||
<section className="grid gap-4 md:grid-cols-2">
|
||||
{artwork.renditions.length === 0 ? (
|
||||
<article className="rounded-xl border border-neutral-200 p-6 text-sm text-neutral-600">
|
||||
{t("noPreview")}
|
||||
</article>
|
||||
) : (
|
||||
artwork.renditions.map((rendition) => (
|
||||
<article
|
||||
key={rendition.id}
|
||||
className="overflow-hidden rounded-xl border border-neutral-200"
|
||||
>
|
||||
<Image
|
||||
src={`/api/media/file/${rendition.mediaAssetId}`}
|
||||
alt={rendition.mediaAsset.altText || artwork.title}
|
||||
width={1400}
|
||||
height={1000}
|
||||
className="h-72 w-full object-cover"
|
||||
/>
|
||||
<div className="flex items-center justify-between px-4 py-2 text-xs text-neutral-600">
|
||||
<span>{rendition.slot}</span>
|
||||
<span>
|
||||
{rendition.mediaAsset.width ?? "-"} x {rendition.mediaAsset.height ?? "-"}
|
||||
</span>
|
||||
</div>
|
||||
</article>
|
||||
))
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section className="grid gap-4 rounded-xl border border-neutral-200 p-6 md:grid-cols-2">
|
||||
<div className="space-y-2 text-sm">
|
||||
<p>
|
||||
<strong>{t("fields.medium")}:</strong> {artwork.medium || "-"}
|
||||
</p>
|
||||
<p>
|
||||
<strong>{t("fields.dimensions")}:</strong> {artwork.dimensions || "-"}
|
||||
</p>
|
||||
<p>
|
||||
<strong>{t("fields.year")}:</strong> {artwork.year || "-"}
|
||||
</p>
|
||||
<p>
|
||||
<strong>{t("fields.availability")}:</strong> {artwork.availability || "-"}
|
||||
</p>
|
||||
<p>
|
||||
<strong>{t("fields.price")}:</strong>{" "}
|
||||
{artwork.isPriceVisible
|
||||
? formatArtworkPrice(artwork.priceAmountCents, artwork.priceCurrency)
|
||||
: "-"}
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-2 text-sm">
|
||||
<p>
|
||||
<strong>{t("fields.galleries")}:</strong>{" "}
|
||||
{formatLabelList(artwork.galleryLinks.map((entry) => entry.gallery.name))}
|
||||
</p>
|
||||
<p>
|
||||
<strong>{t("fields.albums")}:</strong>{" "}
|
||||
{formatLabelList(artwork.albumLinks.map((entry) => entry.album.name))}
|
||||
</p>
|
||||
<p>
|
||||
<strong>{t("fields.categories")}:</strong>{" "}
|
||||
{formatLabelList(artwork.categoryLinks.map((entry) => entry.category.name))}
|
||||
</p>
|
||||
<p>
|
||||
<strong>{t("fields.tags")}:</strong>{" "}
|
||||
{formatLabelList(artwork.tagLinks.map((entry) => entry.tag.name))}
|
||||
</p>
|
||||
<p>
|
||||
<strong>{t("fields.licenseType")}:</strong> {primaryMedia?.licenseType || "-"}
|
||||
</p>
|
||||
<p>
|
||||
<strong>{t("fields.licenseUrl")}:</strong> {primaryMedia?.licenseUrl || "-"}
|
||||
</p>
|
||||
<p>
|
||||
<strong>{t("fields.usageContext")}:</strong> {primaryMedia?.usageContext || "-"}
|
||||
</p>
|
||||
<p>
|
||||
<strong>{t("fields.location")}:</strong> {primaryMedia?.location || "-"}
|
||||
</p>
|
||||
<p>
|
||||
<strong>{t("fields.capturedAt")}:</strong>{" "}
|
||||
{primaryMedia?.capturedAt ? primaryMedia.capturedAt.toLocaleDateString("en-US") : "-"}
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
291
apps/web/src/app/[locale]/portfolio/page.tsx
Normal file
291
apps/web/src/app/[locale]/portfolio/page.tsx
Normal file
@@ -0,0 +1,291 @@
|
||||
import { listPublishedArtworks, listPublishedPortfolioGroups } from "@cms/db"
|
||||
import Image from "next/image"
|
||||
import { getTranslations } from "next-intl/server"
|
||||
|
||||
import { Link } from "@/i18n/navigation"
|
||||
|
||||
export const dynamic = "force-dynamic"
|
||||
|
||||
type SearchParamsInput = Record<string, string | string[] | undefined>
|
||||
|
||||
type PortfolioPageProps = {
|
||||
searchParams: Promise<SearchParamsInput>
|
||||
}
|
||||
|
||||
function readFirstValue(value: string | string[] | undefined): string | null {
|
||||
if (Array.isArray(value)) {
|
||||
return value[0] ?? null
|
||||
}
|
||||
|
||||
return value ?? null
|
||||
}
|
||||
|
||||
function resolveGroupFilter(searchParams: SearchParamsInput) {
|
||||
const gallery = readFirstValue(searchParams.gallery)
|
||||
if (gallery) {
|
||||
return { groupType: "gallery" as const, groupSlug: gallery }
|
||||
}
|
||||
|
||||
const album = readFirstValue(searchParams.album)
|
||||
if (album) {
|
||||
return { groupType: "album" as const, groupSlug: album }
|
||||
}
|
||||
|
||||
const category = readFirstValue(searchParams.category)
|
||||
if (category) {
|
||||
return { groupType: "category" as const, groupSlug: category }
|
||||
}
|
||||
|
||||
const tag = readFirstValue(searchParams.tag)
|
||||
if (tag) {
|
||||
return { groupType: "tag" as const, groupSlug: tag }
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
function resolveSort(searchParams: SearchParamsInput): "latest" | "title_asc" | "title_desc" {
|
||||
const sort = readFirstValue(searchParams.sort)
|
||||
|
||||
if (sort === "title_asc" || sort === "title_desc") {
|
||||
return sort
|
||||
}
|
||||
|
||||
return "latest"
|
||||
}
|
||||
|
||||
function buildPortfolioQuery(
|
||||
filter: ReturnType<typeof resolveGroupFilter>,
|
||||
sort: ReturnType<typeof resolveSort>,
|
||||
) {
|
||||
const query: Record<string, string> = {}
|
||||
|
||||
if (filter) {
|
||||
query[filter.groupType] = filter.groupSlug
|
||||
}
|
||||
|
||||
if (sort !== "latest") {
|
||||
query.sort = sort
|
||||
}
|
||||
|
||||
return query
|
||||
}
|
||||
|
||||
function findPreviewAsset(
|
||||
renditions: Array<{
|
||||
slot: string
|
||||
mediaAssetId: string
|
||||
mediaAsset: {
|
||||
id: string
|
||||
altText: string | null
|
||||
title: string
|
||||
}
|
||||
}>,
|
||||
) {
|
||||
const byPreference =
|
||||
renditions.find((item) => item.slot === "card") ??
|
||||
renditions.find((item) => item.slot === "thumbnail") ??
|
||||
renditions.find((item) => item.slot === "full") ??
|
||||
renditions[0]
|
||||
|
||||
return byPreference ?? null
|
||||
}
|
||||
|
||||
export default async function PortfolioPage({ searchParams }: PortfolioPageProps) {
|
||||
const [resolvedSearchParams, t] = await Promise.all([searchParams, getTranslations("Portfolio")])
|
||||
const activeFilter = resolveGroupFilter(resolvedSearchParams)
|
||||
const activeSort = resolveSort(resolvedSearchParams)
|
||||
|
||||
const [groups, artworks] = await Promise.all([
|
||||
listPublishedPortfolioGroups(),
|
||||
listPublishedArtworks(
|
||||
activeFilter
|
||||
? {
|
||||
groupType: activeFilter.groupType,
|
||||
groupSlug: activeFilter.groupSlug,
|
||||
sort: activeSort,
|
||||
}
|
||||
: {
|
||||
sort: activeSort,
|
||||
},
|
||||
),
|
||||
])
|
||||
|
||||
return (
|
||||
<section className="mx-auto w-full max-w-6xl space-y-6 px-6 py-16">
|
||||
<header className="space-y-2">
|
||||
<p className="text-sm uppercase tracking-[0.2em] text-neutral-500">{t("badge")}</p>
|
||||
<h1 className="text-4xl font-semibold tracking-tight">{t("title")}</h1>
|
||||
<p className="text-neutral-600">{t("description")}</p>
|
||||
</header>
|
||||
|
||||
<section className="rounded-xl border border-neutral-200 p-4">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Link
|
||||
href={{
|
||||
pathname: "/portfolio",
|
||||
query: activeSort === "latest" ? undefined : { sort: activeSort },
|
||||
}}
|
||||
className="rounded-md border border-neutral-300 px-3 py-1.5 text-sm text-neutral-700 hover:bg-neutral-100"
|
||||
>
|
||||
{t("filters.clear")}
|
||||
</Link>
|
||||
|
||||
{groups.galleries.map((group) => (
|
||||
<Link
|
||||
key={`gallery-${group.id}`}
|
||||
href={{
|
||||
pathname: "/portfolio",
|
||||
query: {
|
||||
...buildPortfolioQuery(activeFilter, activeSort),
|
||||
gallery: group.slug,
|
||||
album: undefined,
|
||||
category: undefined,
|
||||
tag: undefined,
|
||||
},
|
||||
}}
|
||||
className="rounded-md border border-neutral-300 px-3 py-1.5 text-sm text-neutral-700 hover:bg-neutral-100"
|
||||
>
|
||||
{t("filters.gallery")}: {group.name}
|
||||
</Link>
|
||||
))}
|
||||
|
||||
{groups.albums.map((group) => (
|
||||
<Link
|
||||
key={`album-${group.id}`}
|
||||
href={{
|
||||
pathname: "/portfolio",
|
||||
query: {
|
||||
...buildPortfolioQuery(activeFilter, activeSort),
|
||||
gallery: undefined,
|
||||
album: group.slug,
|
||||
category: undefined,
|
||||
tag: undefined,
|
||||
},
|
||||
}}
|
||||
className="rounded-md border border-neutral-300 px-3 py-1.5 text-sm text-neutral-700 hover:bg-neutral-100"
|
||||
>
|
||||
{t("filters.album")}: {group.name}
|
||||
</Link>
|
||||
))}
|
||||
|
||||
{groups.categories.map((group) => (
|
||||
<Link
|
||||
key={`category-${group.id}`}
|
||||
href={{
|
||||
pathname: "/portfolio",
|
||||
query: {
|
||||
...buildPortfolioQuery(activeFilter, activeSort),
|
||||
gallery: undefined,
|
||||
album: undefined,
|
||||
category: group.slug,
|
||||
tag: undefined,
|
||||
},
|
||||
}}
|
||||
className="rounded-md border border-neutral-300 px-3 py-1.5 text-sm text-neutral-700 hover:bg-neutral-100"
|
||||
>
|
||||
{t("filters.category")}: {group.name}
|
||||
</Link>
|
||||
))}
|
||||
|
||||
{groups.tags.map((group) => (
|
||||
<Link
|
||||
key={`tag-${group.id}`}
|
||||
href={{
|
||||
pathname: "/portfolio",
|
||||
query: {
|
||||
...buildPortfolioQuery(activeFilter, activeSort),
|
||||
gallery: undefined,
|
||||
album: undefined,
|
||||
category: undefined,
|
||||
tag: group.slug,
|
||||
},
|
||||
}}
|
||||
className="rounded-md border border-neutral-300 px-3 py-1.5 text-sm text-neutral-700 hover:bg-neutral-100"
|
||||
>
|
||||
{t("filters.tag")}: {group.name}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="mt-3 flex flex-wrap items-center gap-2">
|
||||
<span className="text-xs uppercase tracking-wide text-neutral-500">
|
||||
{t("sort.label")}:
|
||||
</span>
|
||||
<Link
|
||||
href={{
|
||||
pathname: "/portfolio",
|
||||
query: buildPortfolioQuery(activeFilter, "latest"),
|
||||
}}
|
||||
className="rounded-md border border-neutral-300 px-3 py-1.5 text-sm text-neutral-700 hover:bg-neutral-100"
|
||||
>
|
||||
{t("sort.latest")}
|
||||
</Link>
|
||||
<Link
|
||||
href={{
|
||||
pathname: "/portfolio",
|
||||
query: buildPortfolioQuery(activeFilter, "title_asc"),
|
||||
}}
|
||||
className="rounded-md border border-neutral-300 px-3 py-1.5 text-sm text-neutral-700 hover:bg-neutral-100"
|
||||
>
|
||||
{t("sort.titleAsc")}
|
||||
</Link>
|
||||
<Link
|
||||
href={{
|
||||
pathname: "/portfolio",
|
||||
query: buildPortfolioQuery(activeFilter, "title_desc"),
|
||||
}}
|
||||
className="rounded-md border border-neutral-300 px-3 py-1.5 text-sm text-neutral-700 hover:bg-neutral-100"
|
||||
>
|
||||
{t("sort.titleDesc")}
|
||||
</Link>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{artworks.length === 0 ? (
|
||||
<section className="rounded-xl border border-neutral-200 p-6 text-sm text-neutral-600">
|
||||
{t("empty")}
|
||||
</section>
|
||||
) : (
|
||||
<section className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
|
||||
{artworks.map((artwork) => {
|
||||
const preview = findPreviewAsset(artwork.renditions)
|
||||
|
||||
return (
|
||||
<article
|
||||
key={artwork.id}
|
||||
className="overflow-hidden rounded-xl border border-neutral-200"
|
||||
>
|
||||
{preview ? (
|
||||
<Image
|
||||
src={`/api/media/file/${preview.mediaAssetId}`}
|
||||
alt={preview.mediaAsset.altText || artwork.title}
|
||||
width={1200}
|
||||
height={800}
|
||||
className="h-56 w-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div className="flex h-56 items-center justify-center bg-neutral-100 text-sm text-neutral-500">
|
||||
{t("noPreview")}
|
||||
</div>
|
||||
)}
|
||||
<div className="space-y-2 p-4">
|
||||
<h2 className="text-lg font-medium">{artwork.title}</h2>
|
||||
<p className="line-clamp-3 text-sm text-neutral-600">
|
||||
{artwork.description || t("noDescription")}
|
||||
</p>
|
||||
<Link
|
||||
href={`/portfolio/${artwork.slug}`}
|
||||
className="text-sm underline underline-offset-2"
|
||||
>
|
||||
{t("viewArtwork")}
|
||||
</Link>
|
||||
</div>
|
||||
</article>
|
||||
)
|
||||
})}
|
||||
</section>
|
||||
)}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
47
apps/web/src/app/api/media/file/[id]/route.ts
Normal file
47
apps/web/src/app/api/media/file/[id]/route.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
import { getMediaAssetById } from "@cms/db"
|
||||
|
||||
import { readMediaStorageObject } from "@/lib/media/storage-read"
|
||||
|
||||
export const runtime = "nodejs"
|
||||
|
||||
type RouteContext = {
|
||||
params: Promise<{ id: string }>
|
||||
}
|
||||
|
||||
function toBody(data: Uint8Array): BodyInit {
|
||||
const bytes = new Uint8Array(data.byteLength)
|
||||
bytes.set(data)
|
||||
return bytes
|
||||
}
|
||||
|
||||
export async function GET(_request: Request, context: RouteContext): Promise<Response> {
|
||||
const { id } = await context.params
|
||||
const asset = await getMediaAssetById(id)
|
||||
|
||||
if (!asset || !asset.storageKey || !asset.isPublished) {
|
||||
return Response.json(
|
||||
{
|
||||
message: "Media file not found",
|
||||
},
|
||||
{ status: 404 },
|
||||
)
|
||||
}
|
||||
|
||||
try {
|
||||
const data = await readMediaStorageObject(asset.storageKey)
|
||||
return new Response(toBody(data), {
|
||||
status: 200,
|
||||
headers: {
|
||||
"content-type": asset.mimeType || "application/octet-stream",
|
||||
"cache-control": "public, max-age=3600",
|
||||
},
|
||||
})
|
||||
} catch {
|
||||
return Response.json(
|
||||
{
|
||||
message: "Unable to read media file from configured storage backends",
|
||||
},
|
||||
{ status: 404 },
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -3,9 +3,30 @@ import type { ReactNode } from "react"
|
||||
|
||||
import "./globals.css"
|
||||
|
||||
const metadataBase = new URL(process.env.CMS_WEB_ORIGIN ?? "http://localhost:3000")
|
||||
|
||||
export const metadata: Metadata = {
|
||||
metadataBase,
|
||||
title: {
|
||||
default: "CMS Web",
|
||||
template: "%s | CMS Web",
|
||||
},
|
||||
description: "Public frontend for the CMS monorepo",
|
||||
applicationName: "CMS Web",
|
||||
openGraph: {
|
||||
type: "website",
|
||||
siteName: "CMS Web",
|
||||
title: "CMS Web",
|
||||
description: "Public frontend for the CMS monorepo",
|
||||
url: metadataBase,
|
||||
},
|
||||
alternates: {
|
||||
canonical: "/",
|
||||
},
|
||||
robots: {
|
||||
index: true,
|
||||
follow: true,
|
||||
},
|
||||
}
|
||||
|
||||
export default function RootLayout({ children }: { children: ReactNode }) {
|
||||
|
||||
13
apps/web/src/app/robots.ts
Normal file
13
apps/web/src/app/robots.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import type { MetadataRoute } from "next"
|
||||
|
||||
const baseUrl = process.env.CMS_WEB_ORIGIN ?? "http://localhost:3000"
|
||||
|
||||
export default function robots(): MetadataRoute.Robots {
|
||||
return {
|
||||
rules: {
|
||||
userAgent: "*",
|
||||
allow: "/",
|
||||
},
|
||||
sitemap: `${baseUrl}/sitemap.xml`,
|
||||
}
|
||||
}
|
||||
13
apps/web/src/app/sitemap.ts
Normal file
13
apps/web/src/app/sitemap.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { listPublishedPageSlugs } from "@cms/db"
|
||||
import type { MetadataRoute } from "next"
|
||||
|
||||
const baseUrl = process.env.CMS_WEB_ORIGIN ?? "http://localhost:3000"
|
||||
|
||||
export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
|
||||
const pages = await listPublishedPageSlugs()
|
||||
|
||||
return pages.map((page) => ({
|
||||
url: page.slug === "home" ? `${baseUrl}/` : `${baseUrl}/${page.slug}`,
|
||||
lastModified: page.updatedAt,
|
||||
}))
|
||||
}
|
||||
40
apps/web/src/components/public-announcements.tsx
Normal file
40
apps/web/src/components/public-announcements.tsx
Normal file
@@ -0,0 +1,40 @@
|
||||
import { listActiveAnnouncements, type PublicAnnouncement } from "@cms/db"
|
||||
import Link from "next/link"
|
||||
|
||||
type PublicAnnouncementsProps = {
|
||||
placement: "global_top" | "homepage"
|
||||
locale?: string
|
||||
}
|
||||
|
||||
function AnnouncementCard({ announcement }: { announcement: PublicAnnouncement }) {
|
||||
return (
|
||||
<article className="rounded-lg border border-blue-200 bg-blue-50 px-4 py-3 text-sm text-blue-900">
|
||||
<p className="text-xs uppercase tracking-wide text-blue-700">{announcement.title}</p>
|
||||
<p className="mt-1">{announcement.message}</p>
|
||||
{announcement.ctaLabel && announcement.ctaHref ? (
|
||||
<Link
|
||||
href={announcement.ctaHref}
|
||||
className="mt-2 inline-block font-medium underline underline-offset-2"
|
||||
>
|
||||
{announcement.ctaLabel}
|
||||
</Link>
|
||||
) : null}
|
||||
</article>
|
||||
)
|
||||
}
|
||||
|
||||
export async function PublicAnnouncements({ placement, locale }: PublicAnnouncementsProps) {
|
||||
const announcements = await listActiveAnnouncements(placement, new Date(), locale)
|
||||
|
||||
if (announcements.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="mx-auto w-full max-w-6xl space-y-2 px-6 py-3">
|
||||
{announcements.map((announcement) => (
|
||||
<AnnouncementCard key={announcement.id} announcement={announcement} />
|
||||
))}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
25
apps/web/src/components/public-header-banner.tsx
Normal file
25
apps/web/src/components/public-header-banner.tsx
Normal file
@@ -0,0 +1,25 @@
|
||||
import type { PublicHeaderBanner as PublicHeaderBannerData } from "@cms/db"
|
||||
import Link from "next/link"
|
||||
|
||||
type PublicHeaderBannerProps = {
|
||||
banner: PublicHeaderBannerData | null
|
||||
}
|
||||
|
||||
export function PublicHeaderBanner({ banner }: PublicHeaderBannerProps) {
|
||||
if (!banner) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="border-b border-amber-200 bg-amber-50">
|
||||
<div className="mx-auto flex w-full max-w-6xl flex-wrap items-center justify-between gap-3 px-6 py-2 text-sm text-amber-900">
|
||||
<p>{banner.message}</p>
|
||||
{banner.ctaLabel && banner.ctaHref ? (
|
||||
<Link href={banner.ctaHref} className="font-medium underline underline-offset-2">
|
||||
{banner.ctaLabel}
|
||||
</Link>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
157
apps/web/src/components/public-page-view.tsx
Normal file
157
apps/web/src/components/public-page-view.tsx
Normal file
@@ -0,0 +1,157 @@
|
||||
import { parsePageBlocks } from "@cms/content"
|
||||
import Image from "next/image"
|
||||
|
||||
type PageEntity = {
|
||||
title: string
|
||||
status: string
|
||||
summary: string | null
|
||||
content: string
|
||||
}
|
||||
|
||||
type PublicPageViewProps = {
|
||||
page: PageEntity
|
||||
}
|
||||
|
||||
function resolveFormLink(formKey: string): { href: string; label: string } {
|
||||
const normalized = formKey.trim().toLowerCase()
|
||||
|
||||
if (normalized === "commission" || normalized === "commissions") {
|
||||
return { href: "/commissions", label: "Open commission form" }
|
||||
}
|
||||
|
||||
return { href: `/#form-${normalized || "contact"}`, label: "Open contact form" }
|
||||
}
|
||||
|
||||
export function PublicPageView({ page }: PublicPageViewProps) {
|
||||
const blocks = (() => {
|
||||
try {
|
||||
return parsePageBlocks(page.content)
|
||||
} catch {
|
||||
return [
|
||||
{
|
||||
id: "fallback-rich-text",
|
||||
type: "rich_text" as const,
|
||||
body: page.content,
|
||||
},
|
||||
]
|
||||
}
|
||||
})()
|
||||
|
||||
return (
|
||||
<article className="mx-auto flex w-full max-w-4xl flex-col gap-6 px-6 py-16">
|
||||
<header className="space-y-3">
|
||||
<p className="text-sm uppercase tracking-[0.2em] text-neutral-500">{page.status}</p>
|
||||
<h1 className="text-4xl font-semibold tracking-tight">{page.title}</h1>
|
||||
{page.summary ? <p className="text-neutral-600">{page.summary}</p> : null}
|
||||
</header>
|
||||
|
||||
<section className="space-y-4 rounded-xl border border-neutral-200 bg-white p-6 text-neutral-800">
|
||||
{blocks.map((block) => {
|
||||
if (block.type === "hero") {
|
||||
return (
|
||||
<section key={block.id} className="space-y-2 rounded border border-neutral-200 p-4">
|
||||
<h2 className="text-2xl font-semibold">{block.heading}</h2>
|
||||
{block.subheading ? <p className="text-neutral-600">{block.subheading}</p> : null}
|
||||
{block.ctaLabel && block.ctaHref ? (
|
||||
<a
|
||||
href={block.ctaHref}
|
||||
className="inline-flex rounded bg-neutral-900 px-3 py-1.5 text-sm text-white"
|
||||
>
|
||||
{block.ctaLabel}
|
||||
</a>
|
||||
) : null}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
if (block.type === "rich_text") {
|
||||
return (
|
||||
<section
|
||||
key={block.id}
|
||||
className="prose prose-neutral max-w-none whitespace-pre-wrap"
|
||||
>
|
||||
{block.body}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
if (block.type === "gallery") {
|
||||
return (
|
||||
<section key={block.id} className="space-y-3">
|
||||
{block.title ? <h3 className="text-lg font-medium">{block.title}</h3> : null}
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
{block.imageIds.length === 0 ? (
|
||||
<p className="text-sm text-neutral-500">No media linked yet.</p>
|
||||
) : (
|
||||
block.imageIds.map((imageId) => (
|
||||
<Image
|
||||
key={imageId}
|
||||
src={`/api/media/file/${imageId}`}
|
||||
alt=""
|
||||
width={1200}
|
||||
height={800}
|
||||
className="h-48 w-full rounded border border-neutral-200 object-cover"
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
if (block.type === "cta") {
|
||||
return (
|
||||
<a
|
||||
key={block.id}
|
||||
href={block.href}
|
||||
className={`inline-flex rounded px-3 py-2 text-sm ${
|
||||
block.variant === "secondary"
|
||||
? "border border-neutral-300 text-neutral-800"
|
||||
: "bg-neutral-900 text-white"
|
||||
}`}
|
||||
>
|
||||
{block.label}
|
||||
</a>
|
||||
)
|
||||
}
|
||||
|
||||
if (block.type === "form") {
|
||||
const formLink = resolveFormLink(block.formKey)
|
||||
return (
|
||||
<section key={block.id} className="space-y-2 rounded border border-neutral-200 p-4">
|
||||
<h3 className="text-lg font-medium">{block.title || "Form block"}</h3>
|
||||
<p className="text-sm text-neutral-600">
|
||||
{block.description || "Form integration pending."}
|
||||
</p>
|
||||
<p className="text-xs text-neutral-500">formKey: {block.formKey}</p>
|
||||
<a
|
||||
href={formLink.href}
|
||||
className="inline-flex rounded border border-neutral-300 px-3 py-1.5 text-sm"
|
||||
>
|
||||
{formLink.label}
|
||||
</a>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<section key={block.id} className="space-y-2 rounded border border-neutral-200 p-4">
|
||||
{block.title ? <h3 className="text-lg font-medium">{block.title}</h3> : null}
|
||||
<div className="grid gap-3 md:grid-cols-2">
|
||||
{block.cards.map((card) => (
|
||||
<article key={card.id} className="rounded border border-neutral-200 p-3">
|
||||
<h4 className="font-medium">{card.name}</h4>
|
||||
{card.price ? <p className="text-sm text-neutral-700">{card.price}</p> : null}
|
||||
{card.description ? (
|
||||
<p className="text-sm text-neutral-600">{card.description}</p>
|
||||
) : null}
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
})}
|
||||
</section>
|
||||
</article>
|
||||
)
|
||||
}
|
||||
27
apps/web/src/components/public-site-footer.tsx
Normal file
27
apps/web/src/components/public-site-footer.tsx
Normal file
@@ -0,0 +1,27 @@
|
||||
"use client"
|
||||
|
||||
import { useTranslations } from "next-intl"
|
||||
|
||||
import { getBuildInfo } from "@/lib/build-info"
|
||||
|
||||
export function PublicSiteFooter() {
|
||||
const t = useTranslations("Layout")
|
||||
const year = new Date().getFullYear()
|
||||
const buildInfo = getBuildInfo()
|
||||
|
||||
return (
|
||||
<footer className="border-t border-neutral-200 bg-neutral-50">
|
||||
<div className="mx-auto flex w-full max-w-6xl flex-wrap items-center justify-between gap-2 px-6 py-4 text-sm text-neutral-600">
|
||||
<p>
|
||||
{t("footer.copyright", {
|
||||
year,
|
||||
})}
|
||||
</p>
|
||||
<p>{t("footer.tagline")}</p>
|
||||
<p className="font-mono text-xs text-neutral-500">
|
||||
Build v{buildInfo.version} +sha.{buildInfo.sha}
|
||||
</p>
|
||||
</div>
|
||||
</footer>
|
||||
)
|
||||
}
|
||||
64
apps/web/src/components/public-site-header.tsx
Normal file
64
apps/web/src/components/public-site-header.tsx
Normal file
@@ -0,0 +1,64 @@
|
||||
import { listPublicNavigation } from "@cms/db"
|
||||
import { getLocale, getTranslations } from "next-intl/server"
|
||||
|
||||
import { Link } from "@/i18n/navigation"
|
||||
|
||||
import { LanguageSwitcher } from "./language-switcher"
|
||||
|
||||
export async function PublicSiteHeader() {
|
||||
const locale = await getLocale()
|
||||
const [navItems, t] = await Promise.all([
|
||||
listPublicNavigation("header", locale),
|
||||
getTranslations("Layout.nav"),
|
||||
])
|
||||
const fallbackNavItems = [
|
||||
{
|
||||
id: "fallback-home",
|
||||
href: "/",
|
||||
label: t("home"),
|
||||
},
|
||||
{
|
||||
id: "fallback-portfolio",
|
||||
href: "/portfolio",
|
||||
label: t("portfolio"),
|
||||
},
|
||||
{
|
||||
id: "fallback-news",
|
||||
href: "/news",
|
||||
label: t("news"),
|
||||
},
|
||||
{
|
||||
id: "fallback-commissions",
|
||||
href: "/commissions",
|
||||
label: t("commissions"),
|
||||
},
|
||||
]
|
||||
const resolvedNavItems = navItems.length > 0 ? navItems : fallbackNavItems
|
||||
|
||||
return (
|
||||
<header className="border-b border-neutral-200 bg-white/80 backdrop-blur">
|
||||
<div className="mx-auto flex w-full max-w-6xl flex-wrap items-center justify-between gap-4 px-6 py-4">
|
||||
<Link
|
||||
href="/"
|
||||
className="text-sm font-semibold uppercase tracking-[0.2em] text-neutral-700"
|
||||
>
|
||||
CMS Web
|
||||
</Link>
|
||||
|
||||
<nav className="flex flex-wrap items-center gap-2">
|
||||
{resolvedNavItems.map((item) => (
|
||||
<Link
|
||||
key={item.id}
|
||||
href={item.href}
|
||||
className="rounded-md border border-neutral-300 px-3 py-1.5 text-sm font-medium text-neutral-700 hover:bg-neutral-100"
|
||||
>
|
||||
{item.label}
|
||||
</Link>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
<LanguageSwitcher />
|
||||
</div>
|
||||
</header>
|
||||
)
|
||||
}
|
||||
17
apps/web/src/i18n/request.test.ts
Normal file
17
apps/web/src/i18n/request.test.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { describe, expect, it } from "vitest"
|
||||
|
||||
import { resolveRequestLocale } from "./request"
|
||||
|
||||
describe("resolveRequestLocale", () => {
|
||||
it("accepts supported locales", () => {
|
||||
expect(resolveRequestLocale("de")).toBe("de")
|
||||
expect(resolveRequestLocale("en")).toBe("en")
|
||||
expect(resolveRequestLocale("es")).toBe("es")
|
||||
expect(resolveRequestLocale("fr")).toBe("fr")
|
||||
})
|
||||
|
||||
it("falls back to default locale for unsupported values", () => {
|
||||
expect(resolveRequestLocale("it")).toBe("en")
|
||||
expect(resolveRequestLocale(undefined)).toBe("en")
|
||||
})
|
||||
})
|
||||
@@ -1,11 +1,16 @@
|
||||
import type { AppLocale } from "@cms/i18n"
|
||||
import { hasLocale } from "next-intl"
|
||||
import { getRequestConfig } from "next-intl/server"
|
||||
|
||||
import { routing } from "./routing"
|
||||
|
||||
export function resolveRequestLocale(requested: string | undefined): AppLocale {
|
||||
return hasLocale(routing.locales, requested) ? requested : routing.defaultLocale
|
||||
}
|
||||
|
||||
export default getRequestConfig(async ({ requestLocale }) => {
|
||||
const requested = await requestLocale
|
||||
const locale = hasLocale(routing.locales, requested) ? requested : routing.defaultLocale
|
||||
const locale = resolveRequestLocale(requested)
|
||||
|
||||
return {
|
||||
locale,
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user